Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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. The forced wrapper still has to serialize every item as JSON, so an `Image` or `Audio` in it makes the call fail. Only a bare `list` or `tuple` as the whole annotation is opted out: `list | None` is wrapped like any other union.

## 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, `list[...]`, `tuple[...]`, sets 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.
* 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)**.
13 changes: 9 additions & 4 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,10 +446,11 @@ def func_metadata(
else:
original_annotation = effective_annotation

if structured_output is None and _returns_content(return_type_expr):
if structured_output is None and (return_type_expr in (list, tuple) or _returns_content(return_type_expr)):
# Content blocks and the Image/Audio helpers are what the model reads, not data for the
# application: a derived schema would advertise the block's own model as output_schema (and,
# unless the tool builds its own CallToolResult, echo every block into structured_content).
# A bare list/tuple says nothing about its items, so it may hold blocks too.
# structured_output=True still forces one.
return FuncMetadata(arg_model=arguments_model)

Expand Down Expand Up @@ -540,12 +541,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.
model = dict[str, Any]
Comment thread
Kludex marked this conversation as 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.
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
109 changes: 108 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.
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,99 @@ 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():
"""SDK-defined: the explicit flag wraps a bare list as a list of anything instead of rejecting it."""
meta = func_metadata(_returns_bare_list, structured_output=True)

assert meta.output_schema == snapshot(
{"properties": {"result": {"items": {}, "type": "array"}}, "required": ["result"], "type": "object"}
)
assert meta.convert_result(_returns_bare_list()) == snapshot(
CallToolResult(content=[TextContent(text="1"), TextContent(text="a")], structured_content={"result": [1, "a"]})
)


def _returns_optional_bare_list() -> list | None:
return [1]


def test_a_bare_list_inside_a_union_keeps_its_schema():
"""SDK-defined: only a bare list/tuple as the whole annotation is opted out; nested ones stay wrapped."""
meta = func_metadata(_returns_optional_bare_list)

assert meta.output_schema == snapshot(
{
"properties": {"result": {"anyOf": [{"items": {}, "type": "array"}, {"type": "null"}]}},
"required": ["result"],
"type": "object",
}
)
assert meta.convert_result(_returns_optional_bare_list()) == snapshot(
CallToolResult(content=[TextContent(text="1")], structured_content={"result": [1]})
)


def _returns_bare_dict() -> dict:
return {"id": 1}


def test_a_bare_dict_is_structured_like_dict_str_any():
"""SDK-defined: a bare dict is a JSON object already, so it gets an object schema and is not wrapped."""
meta = func_metadata(_returns_bare_dict)

assert meta.output_schema == snapshot({"additionalProperties": True, "type": "object"})
assert meta.convert_result(_returns_bare_dict()) == snapshot(
CallToolResult(
content=[
TextContent(
text="""\
{
"id": 1
}\
"""
)
],
structured_content={"id": 1},
)
)


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


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


@pytest.mark.parametrize("tool", [_returns_bare_set, _returns_bare_frozenset])
def test_a_bare_set_is_wrapped(tool: Callable[..., Any]):
"""SDK-defined: a bare set/frozenset is wrapped like its parameterized form; it cannot hold content blocks."""
meta = func_metadata(tool)

assert meta.output_schema == snapshot(
{
"properties": {"result": {"items": {}, "type": "array", "uniqueItems": True}},
"required": ["result"],
"type": "object",
}
)
assert meta.convert_result(tool()) == snapshot(
CallToolResult(
content=[
TextContent(
text="""\
[
1
]\
"""
)
],
structured_content={"result": [1]},
)
)


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