Skip to content

Commit 349a012

Browse files
committed
Opt out only a top-level bare list or tuple from structured output
1 parent 17fa76e commit 349a012

3 files changed

Lines changed: 83 additions & 30 deletions

File tree

‎docs/servers/structured-output.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ 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.
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.
216216

217217
## A class without type hints
218218

@@ -243,7 +243,7 @@ There is one way to end up unstructured without asking for it: return a class th
243243
## Recap
244244

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

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

Lines changed: 4 additions & 3 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. A bare `list`/`tuple` may hold blocks too."""
54+
rather than dumping as data. Keep the two in sync."""
5555
origin = get_origin(annotation)
5656
if origin is None:
57-
return annotation in (list, tuple) or (isinstance(annotation, type) and issubclass(annotation, _CONTENT_TYPES))
57+
return 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:
@@ -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

‎tests/server/mcpserver/test_func_metadata.py‎

Lines changed: 77 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,44 +1046,96 @@ def test_structured_output_true_overrides_the_content_block_rule():
10461046

10471047

10481048
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."""
10491050
meta = func_metadata(_returns_bare_list, structured_output=True)
10501051

10511052
assert meta.output_schema == snapshot(
10521053
{"properties": {"result": {"items": {}, "type": "array"}}, "required": ["result"], "type": "object"}
10531054
)
1054-
result = meta.convert_result(_returns_bare_list())
1055-
assert isinstance(result, CallToolResult)
1056-
assert result.structured_content == {"result": [1, "a"]}
1055+
assert meta.convert_result(_returns_bare_list()) == snapshot(
1056+
CallToolResult(content=[TextContent(text="1"), TextContent(text="a")], structured_content={"result": [1, "a"]})
1057+
)
10571058

10581059

1059-
def test_bare_dict_set_and_frozenset_returns_are_structured():
1060-
def bare_dict() -> dict:
1061-
return {"id": 1}
1060+
def _returns_optional_bare_list() -> list | None:
1061+
return [1]
10621062

1063-
def bare_set() -> set:
1064-
return {1}
10651063

1066-
def bare_frozenset() -> frozenset:
1067-
return frozenset({1})
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)
10681087

1069-
meta = func_metadata(bare_dict)
10701088
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}
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+
)
10741103

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-
}
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]},
10831137
)
1084-
result = meta.convert_result(func())
1085-
assert isinstance(result, CallToolResult)
1086-
assert result.structured_content == {"result": [1]}
1138+
)
10871139

10881140

10891141
class _Report(BaseModel):

0 commit comments

Comments
 (0)