Skip to content

Commit 17fa76e

Browse files
committed
Give bare dict, set and frozenset return annotations structured output
1 parent 5a3a23a commit 17fa76e

3 files changed

Lines changed: 71 additions & 10 deletions

File tree

‎docs/servers/structured-output.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,11 @@ A `list[...]` isn't a JSON object either, so it gets the `{"result": ...}` wrapp
143143

144144
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.
145145

146-
`tuple[...]`, unions, and `Optional[...]` are wrapped the same way.
146+
`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)**.
147147

148148
## Dictionaries
149149

150-
`dict[str, ...]` (or `typing.Dict[str, ...]`) is the one generic that already *is* a JSON object, so it isn't wrapped:
150+
`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:
151151

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

213213
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.
214214

215+
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.
216+
215217
## A class without type hints
216218

217219
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
241243
## Recap
242244

243245
* The **return type annotation** is the output schema. It's published in `tools/list` as `output_schema`.
244-
* 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.
246+
* 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.
245247
* Every result carries `content` (text, for the model) **and** `structured_content` (data, for the application).
246248
* What you return is validated against the schema. A mismatch is a tool error, not a corrupt result.
247-
* `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.
249+
* `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.
248250

249251
You now own everything a tool can say back. Next, the second primitive: **[Resources](resources.md)**.

‎src/mcp/server/mcpserver/utilities/func_metadata.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,10 @@ def _is_input_required_type(obj: Any) -> bool:
5151
def _returns_content(annotation: Any) -> bool:
5252
"""Whether a return annotation declares content blocks or the `Image`/`Audio` helpers, bare or as
5353
the items of a list/tuple or the arms of a union: the values `_convert_to_content` renders as blocks
54-
rather than dumping as data. Keep the two in sync."""
54+
rather than dumping as data. Keep the two in sync. A bare `list`/`tuple` may hold blocks too."""
5555
origin = get_origin(annotation)
5656
if origin is None:
57-
return isinstance(annotation, type) and issubclass(annotation, _CONTENT_TYPES)
57+
return annotation in (list, tuple) or (isinstance(annotation, type) and issubclass(annotation, _CONTENT_TYPES))
5858
if origin is Annotated:
5959
return _returns_content(get_args(annotation)[0])
6060
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
540540
elif is_typeddict(type_annotation):
541541
model = type_annotation
542542

543-
# Case 3: Primitive types that need wrapping
544-
elif type_annotation in (str, int, float, bool, bytes, type(None)):
543+
# Case 3: A bare dict is a JSON object already, like dict[str, Any]
544+
elif type_annotation is dict:
545+
model = dict[str, Any]
546+
547+
# Case 4: Primitive types and bare collections that need wrapping
548+
elif type_annotation in (str, int, float, bool, bytes, type(None), list, tuple, set, frozenset):
545549
model = _create_wrapped_model(func_name, original_annotation)
546550
wrap_output = True
547551

548-
# Case 4: Other class types (dataclasses, regular classes with annotations)
552+
# Case 5: Other class types (dataclasses, regular classes with annotations)
549553
else:
550554
type_hints = get_type_hints(type_annotation)
551555
if type_hints:

‎tests/server/mcpserver/test_func_metadata.py‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# pyright: reportMissingParameterType=false
44
# pyright: reportUnknownArgumentType=false
55
# pyright: reportUnknownLambdaType=false
6+
# pyright: reportMissingTypeArgument=false
7+
# pyright: reportUnknownVariableType=false
68
import typing
79
from collections.abc import Callable
810
from dataclasses import dataclass
@@ -11,6 +13,7 @@
1113
import annotated_types
1214
import pytest
1315
from dirty_equals import IsPartialDict
16+
from inline_snapshot import snapshot
1417
from mcp_types import CallToolResult, ContentBlock, EmbeddedResource, InputRequiredResult, TextContent
1518
from pydantic import BaseModel, Field, ValidationError
1619
from typing_extensions import ReadOnly
@@ -1008,6 +1011,14 @@ def _returns_call_tool_result_annotated_with_blocks() -> Annotated[CallToolResul
10081011
raise NotImplementedError
10091012

10101013

1014+
def _returns_bare_list() -> list:
1015+
return [1, "a"]
1016+
1017+
1018+
def _returns_bare_tuple() -> tuple:
1019+
raise NotImplementedError
1020+
1021+
10111022
@pytest.mark.parametrize(
10121023
"tool",
10131024
[
@@ -1017,12 +1028,15 @@ def _returns_call_tool_result_annotated_with_blocks() -> Annotated[CallToolResul
10171028
_returns_audio_clips,
10181029
_returns_described_blocks,
10191030
_returns_call_tool_result_annotated_with_blocks,
1031+
_returns_bare_list,
1032+
_returns_bare_tuple,
10201033
],
10211034
)
10221035
def test_content_block_return_annotation_yields_no_output_schema(tool: Callable[..., Any]):
10231036
"""SDK-defined: content blocks and the Image/Audio helpers anywhere in the return annotation are
10241037
presentation, not data, so auto-detection derives no output schema (and `list[str | Image]` /
1025-
`tuple[Audio, ...]`, which pydantic cannot build a schema for, register instead of raising)."""
1038+
`tuple[Audio, ...]`, which pydantic cannot build a schema for, register instead of raising). A bare
1039+
`list`/`tuple` may hold blocks, so it gets no schema either."""
10261040
assert func_metadata(tool).output_schema is None
10271041

10281042

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

10331047

1048+
def test_structured_output_true_wraps_a_bare_list():
1049+
meta = func_metadata(_returns_bare_list, structured_output=True)
1050+
1051+
assert meta.output_schema == snapshot(
1052+
{"properties": {"result": {"items": {}, "type": "array"}}, "required": ["result"], "type": "object"}
1053+
)
1054+
result = meta.convert_result(_returns_bare_list())
1055+
assert isinstance(result, CallToolResult)
1056+
assert result.structured_content == {"result": [1, "a"]}
1057+
1058+
1059+
def test_bare_dict_set_and_frozenset_returns_are_structured():
1060+
def bare_dict() -> dict:
1061+
return {"id": 1}
1062+
1063+
def bare_set() -> set:
1064+
return {1}
1065+
1066+
def bare_frozenset() -> frozenset:
1067+
return frozenset({1})
1068+
1069+
meta = func_metadata(bare_dict)
1070+
assert meta.output_schema == snapshot({"additionalProperties": True, "type": "object"})
1071+
result = meta.convert_result(bare_dict())
1072+
assert isinstance(result, CallToolResult)
1073+
assert result.structured_content == {"id": 1}
1074+
1075+
for func in (bare_set, bare_frozenset):
1076+
meta = func_metadata(func)
1077+
assert meta.output_schema == snapshot(
1078+
{
1079+
"properties": {"result": {"items": {}, "type": "array", "uniqueItems": True}},
1080+
"required": ["result"],
1081+
"type": "object",
1082+
}
1083+
)
1084+
result = meta.convert_result(func())
1085+
assert isinstance(result, CallToolResult)
1086+
assert result.structured_content == {"result": [1]}
1087+
1088+
10341089
class _Report(BaseModel):
10351090
summary: str
10361091
attachment: EmbeddedResource

0 commit comments

Comments
 (0)