Skip to content

Commit 8c6a3ca

Browse files
Parker FawcettParker Fawcett
authored andcommitted
Give recursive tool output schemas an object root
Pydantic emits a bare $ref root for self-referential return types, which the published tool schema contract rejects: on 2025-11-25 sessions the whole tools/list result failed validation, so legacy clients lost every tool, not just the recursive one. Generated schemas whose root is a $ref to an object def are now published as {"type": "object", "allOf": [$ref]} with $defs kept, which terminates where inlining the root would not. Hand-built schemas are untouched. Fixes #3337
1 parent 57394b0 commit 8c6a3ca

3 files changed

Lines changed: 89 additions & 1 deletion

File tree

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,30 @@ def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None:
7373
raise ValueError(f"JSON schema warning: {kind} - {detail}")
7474

7575

76+
_DEFS_REF_PREFIX = "#/$defs/"
77+
78+
79+
def _with_object_root(schema: dict[str, Any]) -> dict[str, Any]:
80+
"""Give a generated schema whose root is a bare `$ref` an object root.
81+
82+
Pydantic emits `{"$defs": ..., "$ref": "#/$defs/Model"}` for self-referential
83+
return types, but the published tool schema requires `type: "object"` at the
84+
root. Wrapping the reference (instead of inlining it) terminates on recursive
85+
models; refs to non-object defs are left alone.
86+
"""
87+
ref = schema.get("$ref")
88+
if not isinstance(ref, str) or "type" in schema or not ref.startswith(_DEFS_REF_PREFIX):
89+
return schema
90+
defs: dict[str, Any] = schema["$defs"] if "$defs" in schema else {}
91+
try:
92+
is_object_root = defs[ref[len(_DEFS_REF_PREFIX) :]]["type"] == "object"
93+
except (KeyError, TypeError):
94+
return schema
95+
if not is_object_root:
96+
return schema
97+
return {"type": "object", "allOf": [{"$ref": ref}], **{k: v for k, v in schema.items() if k != "$ref"}}
98+
99+
76100
class ArgModelBase(BaseModel):
77101
"""A model representing the arguments to a function."""
78102

@@ -107,7 +131,9 @@ class FuncMetadata(BaseModel):
107131
def model_post_init(self, context: Any, /) -> None:
108132
if self.output_model is not None and self.output_schema is None:
109133
# StrictJsonSchema raises instead of warning, so an unserializable return type fails construction.
110-
self.output_schema = self._output_adapter(self.output_model).json_schema(schema_generator=StrictJsonSchema)
134+
self.output_schema = _with_object_root(
135+
self._output_adapter(self.output_model).json_schema(schema_generator=StrictJsonSchema)
136+
)
111137

112138
def _output_adapter(self, output_model: type[Any]) -> TypeAdapter[Any]:
113139
"""The validator/serializer for `output_model`, built once and rebuilt only if the field is reassigned."""

tests/server/mcpserver/test_func_metadata.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,41 @@ def func_returning_person() -> PersonModel: # pragma: no cover
628628
}
629629

630630

631+
def test_structured_output_recursive_model():
632+
"""A self-referential return type publishes its schema with `type: "object"` at
633+
the root — pydantic emits a bare `$ref` root, which the published tool schema
634+
contract rejects. The reference is wrapped (not inlined), which terminates on
635+
recursive models."""
636+
637+
class Node(BaseModel):
638+
name: str
639+
children: list["Node"] = []
640+
641+
def func_returning_tree() -> Node: # pragma: no cover
642+
raise NotImplementedError
643+
644+
meta = func_metadata(func_returning_tree)
645+
node_def: dict[str, Any] = {
646+
"properties": {
647+
"name": {"title": "Name", "type": "string"},
648+
"children": {
649+
"default": [],
650+
"items": {"$ref": "#/$defs/Node"},
651+
"title": "Children",
652+
"type": "array",
653+
},
654+
},
655+
"required": ["name"],
656+
"title": "Node",
657+
"type": "object",
658+
}
659+
assert meta.output_schema == {
660+
"type": "object",
661+
"allOf": [{"$ref": "#/$defs/Node"}],
662+
"$defs": {"Node": node_def},
663+
}
664+
665+
631666
def test_structured_output_primitives():
632667
"""Test structured output with primitive return types"""
633668

tests/server/mcpserver/test_server.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2423,3 +2423,30 @@ async def refuse_listen(ctx: ServerRequestContext[Any, Any], call_next: Any) ->
24232423
pass # pragma: no cover - the refusal precedes the stream
24242424
assert exc_info.value.error.code == INVALID_REQUEST
24252425
assert exc_info.value.error.message == "not permitted to watch the requested resources"
2426+
2427+
2428+
@pytest.mark.anyio
2429+
async def test_recursive_tool_output_schema_serves_on_legacy_sessions() -> None:
2430+
"""A self-referential tool return type publishes `type: "object"` at the schema
2431+
root, so `tools/list` succeeds on 2025-11-25 sessions whose OutputSchema model
2432+
rejects a bare `$ref` root instead of failing the entire listing (#3337)."""
2433+
2434+
class Node(BaseModel):
2435+
name: str
2436+
children: list["Node"] = []
2437+
2438+
mcp = MCPServer("rec")
2439+
2440+
@mcp.tool()
2441+
def tree() -> Node:
2442+
return Node(name="root")
2443+
2444+
async with Client(mcp) as client:
2445+
tools = (await client.list_tools()).tools
2446+
assert tools[0].output_schema is not None
2447+
assert tools[0].output_schema["type"] == "object"
2448+
2449+
async with Client(mcp, mode="legacy") as client:
2450+
tools = (await client.list_tools()).tools
2451+
assert tools[0].output_schema is not None
2452+
assert tools[0].output_schema["type"] == "object"

0 commit comments

Comments
 (0)