Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/servers/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,11 @@ A `list[...]` isn't a JSON object either, so it gets the `{"result": ...}` wrapp

Ask for a two-day forecast and `structured_content` is `{"result": [{...}, {...}]}`. `content` becomes **two** `TextContent` blocks, one per item: a list is flattened for the model rather than dumped as one string.

`tuple[...]`, unions, and `Optional[...]` are wrapped the same way.
`tuple[...]`, unions, and `Optional[...]` are wrapped the same way, and so are `set` and `frozenset`, with or without an item type. A bare `list` or `tuple` is not: see **[Content blocks and media](#content-blocks-and-media)**.

## Dictionaries

`dict[str, ...]` (or `typing.Dict[str, ...]`) is the one generic that already *is* a JSON object, so it isn't wrapped:
`dict[str, ...]` (or `typing.Dict[str, ...]`, or a bare `dict`, read as `dict[str, Any]`) is the one generic that already *is* a JSON object, so it isn't wrapped:

```python title="server.py" hl_lines="9"
--8<-- "docs_src/structured_output/tutorial006.py"
Expand Down Expand Up @@ -212,6 +212,8 @@ The opposite, `structured_output=True`, turns the automatic detection into a req

Content blocks and media (`TextContent`, `EmbeddedResource`, `Image`, `Audio` and friends, on their own, as the items of a `list`, `tuple` or `Sequence`, or as the arms of a union) are opted out for you: they are for the model to read, so auto-detection derives no schema from them (**[Images, audio & icons](media.md)** covers `Image` and `Audio`). `structured_output=True` still forces one for the content-block classes.

A bare `list` or `tuple` is opted out too. Nothing in the annotation says its items aren't blocks, and a schema would send any image it holds twice: once as a block for the model, and again as base64 in `structured_content`. Write the item type (`list[str]`, `tuple[int, ...]`) to get a schema, or pass `structured_output=True` to wrap it as a list of anything.
Comment thread
claude[bot] marked this conversation as resolved.
Resolved

## A class without type hints

There is one way to end up unstructured without asking for it: return a class that has **no annotations on its body**.
Expand Down Expand Up @@ -241,9 +243,9 @@ There is one way to end up unstructured without asking for it: return a class th
## Recap

* The **return type annotation** is the output schema. It's published in `tools/list` as `output_schema`.
* Scalars, lists, tuples and unions are wrapped in `{"result": ...}`. Models, `TypedDict`s, dataclasses, annotated classes and `dict[str, ...]` are objects already and stay as they are.
* Scalars, lists, tuples and unions are wrapped in `{"result": ...}`. Models, `TypedDict`s, dataclasses, annotated classes, `dict[str, ...]` and a bare `dict` are objects already and stay as they are.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
* Every result carries `content` (text, for the model) **and** `structured_content` (data, for the application).
* What you return is validated against the schema. A mismatch is a tool error, not a corrupt result.
* `structured_output=False` opts a tool out. Content blocks, `Image` and `Audio` opt out by default; a class without type hints opts out silently, so watch for it.
* `structured_output=False` opts a tool out. Content blocks, `Image`, `Audio` and a bare `list` or `tuple` opt out by default; a class without type hints opts out silently, so watch for it.

You now own everything a tool can say back. Next, the second primitive: **[Resources](resources.md)**.
14 changes: 9 additions & 5 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ def _is_input_required_type(obj: Any) -> bool:
def _returns_content(annotation: Any) -> bool:
"""Whether a return annotation declares content blocks or the `Image`/`Audio` helpers, bare or as
the items of a list/tuple or the arms of a union: the values `_convert_to_content` renders as blocks
rather than dumping as data. Keep the two in sync."""
rather than dumping as data. Keep the two in sync. A bare `list`/`tuple` may hold blocks too."""
origin = get_origin(annotation)
if origin is None:
return isinstance(annotation, type) and issubclass(annotation, _CONTENT_TYPES)
return annotation in (list, tuple) or (isinstance(annotation, type) and issubclass(annotation, _CONTENT_TYPES))
Comment thread
Kludex marked this conversation as resolved.
Resolved
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
if origin is Annotated:
return _returns_content(get_args(annotation)[0])
if is_union_origin(origin) or origin in _CONTENT_SEQUENCE_ORIGINS:
Comment thread
Kludex marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -540,12 +540,16 @@ def _create_output_model(original_annotation: Any, type_expr: Any, func_name: st
elif is_typeddict(type_annotation):
model = type_annotation

# Case 3: Primitive types that need wrapping
elif type_annotation in (str, int, float, bool, bytes, type(None)):
# Case 3: A bare dict is a JSON object already, like dict[str, Any]
elif type_annotation is dict:
Comment thread
Kludex marked this conversation as resolved.
Resolved
model = dict[str, Any]
Comment thread
Kludex marked this conversation as resolved.
Resolved

# Case 4: Primitive types and bare collections that need wrapping
elif type_annotation in (str, int, float, bool, bytes, type(None), list, tuple, set, frozenset):
Comment thread
Kludex marked this conversation as resolved.
Resolved
model = _create_wrapped_model(func_name, original_annotation)
wrap_output = True

# Case 4: Other class types (dataclasses, regular classes with annotations)
# Case 5: Other class types (dataclasses, regular classes with annotations)
else:
type_hints = get_type_hints(type_annotation)
if type_hints:
Expand Down
57 changes: 56 additions & 1 deletion tests/server/mcpserver/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
# pyright: reportMissingParameterType=false
# pyright: reportUnknownArgumentType=false
# pyright: reportUnknownLambdaType=false
# pyright: reportMissingTypeArgument=false
# pyright: reportUnknownVariableType=false
Comment thread
Kludex marked this conversation as resolved.
Resolved
import typing
from collections.abc import Callable
from dataclasses import dataclass
Expand All @@ -11,6 +13,7 @@
import annotated_types
import pytest
from dirty_equals import IsPartialDict
from inline_snapshot import snapshot
from mcp_types import CallToolResult, ContentBlock, EmbeddedResource, InputRequiredResult, TextContent
from pydantic import BaseModel, Field, ValidationError
from typing_extensions import ReadOnly
Expand Down Expand Up @@ -1008,6 +1011,14 @@ def _returns_call_tool_result_annotated_with_blocks() -> Annotated[CallToolResul
raise NotImplementedError


def _returns_bare_list() -> list:
return [1, "a"]


def _returns_bare_tuple() -> tuple:
raise NotImplementedError


@pytest.mark.parametrize(
"tool",
[
Expand All @@ -1017,12 +1028,15 @@ def _returns_call_tool_result_annotated_with_blocks() -> Annotated[CallToolResul
_returns_audio_clips,
_returns_described_blocks,
_returns_call_tool_result_annotated_with_blocks,
_returns_bare_list,
_returns_bare_tuple,
],
)
def test_content_block_return_annotation_yields_no_output_schema(tool: Callable[..., Any]):
"""SDK-defined: content blocks and the Image/Audio helpers anywhere in the return annotation are
presentation, not data, so auto-detection derives no output schema (and `list[str | Image]` /
`tuple[Audio, ...]`, which pydantic cannot build a schema for, register instead of raising)."""
`tuple[Audio, ...]`, which pydantic cannot build a schema for, register instead of raising). A bare
`list`/`tuple` may hold blocks, so it gets no schema either."""
assert func_metadata(tool).output_schema is None


Expand All @@ -1031,6 +1045,47 @@ def test_structured_output_true_overrides_the_content_block_rule():
assert func_metadata(_returns_block, structured_output=True).output_model is EmbeddedResource


def test_structured_output_true_wraps_a_bare_list():
meta = func_metadata(_returns_bare_list, structured_output=True)

assert meta.output_schema == snapshot(
{"properties": {"result": {"items": {}, "type": "array"}}, "required": ["result"], "type": "object"}
)
result = meta.convert_result(_returns_bare_list())
assert isinstance(result, CallToolResult)
assert result.structured_content == {"result": [1, "a"]}
Comment thread
Kludex marked this conversation as resolved.
Resolved


def test_bare_dict_set_and_frozenset_returns_are_structured():
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
def bare_dict() -> dict:
Comment thread
Kludex marked this conversation as resolved.
Resolved
return {"id": 1}

def bare_set() -> set:
return {1}

def bare_frozenset() -> frozenset:
return frozenset({1})

meta = func_metadata(bare_dict)
assert meta.output_schema == snapshot({"additionalProperties": True, "type": "object"})
result = meta.convert_result(bare_dict())
assert isinstance(result, CallToolResult)
assert result.structured_content == {"id": 1}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved

for func in (bare_set, bare_frozenset):
meta = func_metadata(func)
assert meta.output_schema == snapshot(
{
"properties": {"result": {"items": {}, "type": "array", "uniqueItems": True}},
"required": ["result"],
"type": "object",
}
)
result = meta.convert_result(func())
assert isinstance(result, CallToolResult)
assert result.structured_content == {"result": [1]}
Comment thread
claude[bot] marked this conversation as resolved.
Resolved


class _Report(BaseModel):
summary: str
attachment: EmbeddedResource
Expand Down
Loading