From 17fa76e863dc7fdaf05fa125730bc853dd7a97fa Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 24 Oct 2026 18:22:35 +0000 Subject: [PATCH 1/2] Give bare `dict`, `set` and `frozenset` return annotations structured output --- docs/servers/structured-output.md | 10 ++-- .../mcpserver/utilities/func_metadata.py | 14 +++-- tests/server/mcpserver/test_func_metadata.py | 57 ++++++++++++++++++- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index 1dcf1907cc..2ece58f17e 100644 --- a/docs/servers/structured-output.md +++ b/docs/servers/structured-output.md @@ -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" @@ -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. + ## 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**. @@ -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. * 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)**. diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 5ef4173174..2fe46df612 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -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)) if origin is Annotated: return _returns_content(get_args(annotation)[0]) if is_union_origin(origin) or origin in _CONTENT_SEQUENCE_ORIGINS: @@ -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: + model = dict[str, Any] + + # 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): 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: diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 5dc67808d9..6204c527b1 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -3,6 +3,8 @@ # pyright: reportMissingParameterType=false # pyright: reportUnknownArgumentType=false # pyright: reportUnknownLambdaType=false +# pyright: reportMissingTypeArgument=false +# pyright: reportUnknownVariableType=false import typing from collections.abc import Callable from dataclasses import dataclass @@ -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 @@ -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", [ @@ -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 @@ -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"]} + + +def test_bare_dict_set_and_frozenset_returns_are_structured(): + def bare_dict() -> dict: + 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} + + 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]} + + class _Report(BaseModel): summary: str attachment: EmbeddedResource From 349a012d4b8d21032951977ae100dbbf5167eb11 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 24 Oct 2026 20:05:08 +0000 Subject: [PATCH 2/2] Opt out only a top-level bare `list` or `tuple` from structured output --- docs/servers/structured-output.md | 4 +- .../mcpserver/utilities/func_metadata.py | 7 +- tests/server/mcpserver/test_func_metadata.py | 102 +++++++++++++----- 3 files changed, 83 insertions(+), 30 deletions(-) diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index 2ece58f17e..e6dd501d7c 100644 --- a/docs/servers/structured-output.md +++ b/docs/servers/structured-output.md @@ -212,7 +212,7 @@ 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. +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 @@ -243,7 +243,7 @@ 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, `dict[str, ...]` and a bare `dict` 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`, `Audio` and a bare `list` or `tuple` opt out by default; a class without type hints opts out silently, so watch for it. diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 2fe46df612..e5721488f5 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -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. A bare `list`/`tuple` may hold blocks too.""" + rather than dumping as data. Keep the two in sync.""" origin = get_origin(annotation) if origin is None: - return annotation in (list, tuple) or (isinstance(annotation, type) and issubclass(annotation, _CONTENT_TYPES)) + return isinstance(annotation, type) and issubclass(annotation, _CONTENT_TYPES) if origin is Annotated: return _returns_content(get_args(annotation)[0]) if is_union_origin(origin) or origin in _CONTENT_SEQUENCE_ORIGINS: @@ -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) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 6204c527b1..f929fb3954 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -1046,44 +1046,96 @@ def test_structured_output_true_overrides_the_content_block_rule(): 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"} ) - result = meta.convert_result(_returns_bare_list()) - assert isinstance(result, CallToolResult) - assert result.structured_content == {"result": [1, "a"]} + assert meta.convert_result(_returns_bare_list()) == snapshot( + CallToolResult(content=[TextContent(text="1"), TextContent(text="a")], structured_content={"result": [1, "a"]}) + ) -def test_bare_dict_set_and_frozenset_returns_are_structured(): - def bare_dict() -> dict: - return {"id": 1} +def _returns_optional_bare_list() -> list | None: + return [1] - def bare_set() -> set: - return {1} - def bare_frozenset() -> frozenset: - return frozenset({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) - 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} + assert meta.convert_result(_returns_bare_dict()) == snapshot( + CallToolResult( + content=[ + TextContent( + text="""\ +{ + "id": 1 +}\ +""" + ) + ], + structured_content={"id": 1}, + ) + ) - 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", - } + +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]}, ) - result = meta.convert_result(func()) - assert isinstance(result, CallToolResult) - assert result.structured_content == {"result": [1]} + ) class _Report(BaseModel):