diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index 1dcf1907cc..e6dd501d7c 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. 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**. @@ -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)**. diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 5ef4173174..e5721488f5 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -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) @@ -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: + 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..f929fb3954 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,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