Skip to content

Commit a525a3d

Browse files
authored
Give bare dict, set and frozenset return annotations structured output (#3758)
1 parent 5a3a23a commit a525a3d

3 files changed

Lines changed: 123 additions & 9 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. 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.
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, `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.
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 & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -446,10 +446,11 @@ def func_metadata(
446446
else:
447447
original_annotation = effective_annotation
448448

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

@@ -540,12 +541,16 @@ def _create_output_model(original_annotation: Any, type_expr: Any, func_name: st
540541
elif is_typeddict(type_annotation):
541542
model = type_annotation
542543

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

548-
# Case 4: Other class types (dataclasses, regular classes with annotations)
553+
# Case 5: Other class types (dataclasses, regular classes with annotations)
549554
else:
550555
type_hints = get_type_hints(type_annotation)
551556
if type_hints:

‎tests/server/mcpserver/test_func_metadata.py‎

Lines changed: 108 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,99 @@ 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+
"""SDK-defined: the explicit flag wraps a bare list as a list of anything instead of rejecting it."""
1050+
meta = func_metadata(_returns_bare_list, structured_output=True)
1051+
1052+
assert meta.output_schema == snapshot(
1053+
{"properties": {"result": {"items": {}, "type": "array"}}, "required": ["result"], "type": "object"}
1054+
)
1055+
assert meta.convert_result(_returns_bare_list()) == snapshot(
1056+
CallToolResult(content=[TextContent(text="1"), TextContent(text="a")], structured_content={"result": [1, "a"]})
1057+
)
1058+
1059+
1060+
def _returns_optional_bare_list() -> list | None:
1061+
return [1]
1062+
1063+
1064+
def test_a_bare_list_inside_a_union_keeps_its_schema():
1065+
"""SDK-defined: only a bare list/tuple as the whole annotation is opted out; nested ones stay wrapped."""
1066+
meta = func_metadata(_returns_optional_bare_list)
1067+
1068+
assert meta.output_schema == snapshot(
1069+
{
1070+
"properties": {"result": {"anyOf": [{"items": {}, "type": "array"}, {"type": "null"}]}},
1071+
"required": ["result"],
1072+
"type": "object",
1073+
}
1074+
)
1075+
assert meta.convert_result(_returns_optional_bare_list()) == snapshot(
1076+
CallToolResult(content=[TextContent(text="1")], structured_content={"result": [1]})
1077+
)
1078+
1079+
1080+
def _returns_bare_dict() -> dict:
1081+
return {"id": 1}
1082+
1083+
1084+
def test_a_bare_dict_is_structured_like_dict_str_any():
1085+
"""SDK-defined: a bare dict is a JSON object already, so it gets an object schema and is not wrapped."""
1086+
meta = func_metadata(_returns_bare_dict)
1087+
1088+
assert meta.output_schema == snapshot({"additionalProperties": True, "type": "object"})
1089+
assert meta.convert_result(_returns_bare_dict()) == snapshot(
1090+
CallToolResult(
1091+
content=[
1092+
TextContent(
1093+
text="""\
1094+
{
1095+
"id": 1
1096+
}\
1097+
"""
1098+
)
1099+
],
1100+
structured_content={"id": 1},
1101+
)
1102+
)
1103+
1104+
1105+
def _returns_bare_set() -> set:
1106+
return {1}
1107+
1108+
1109+
def _returns_bare_frozenset() -> frozenset:
1110+
return frozenset({1})
1111+
1112+
1113+
@pytest.mark.parametrize("tool", [_returns_bare_set, _returns_bare_frozenset])
1114+
def test_a_bare_set_is_wrapped(tool: Callable[..., Any]):
1115+
"""SDK-defined: a bare set/frozenset is wrapped like its parameterized form; it cannot hold content blocks."""
1116+
meta = func_metadata(tool)
1117+
1118+
assert meta.output_schema == snapshot(
1119+
{
1120+
"properties": {"result": {"items": {}, "type": "array", "uniqueItems": True}},
1121+
"required": ["result"],
1122+
"type": "object",
1123+
}
1124+
)
1125+
assert meta.convert_result(tool()) == snapshot(
1126+
CallToolResult(
1127+
content=[
1128+
TextContent(
1129+
text="""\
1130+
[
1131+
1
1132+
]\
1133+
"""
1134+
)
1135+
],
1136+
structured_content={"result": [1]},
1137+
)
1138+
)
1139+
1140+
10341141
class _Report(BaseModel):
10351142
summary: str
10361143
attachment: EmbeddedResource

0 commit comments

Comments
 (0)