Skip to content

Commit d56f6c1

Browse files
committed
Give recursive tool return types an object-rooted output schema
pydantic emits a self-referential model as {"$defs": {...}, "$ref": "#/$defs/Model"} with no type at the root. Tool.outputSchema requires type: object at the root on 2025-11-25 and earlier, so a single tool with a recursive return type failed the entire tools/list result for every legacy-negotiated client. Inline the referenced definition onto the root when the generated schema is a bare local $ref, keeping $defs for the nested references. The shape is the same on every protocol version.
1 parent 56af447 commit d56f6c1

3 files changed

Lines changed: 72 additions & 1 deletion

File tree

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

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

7676

77+
_LOCAL_DEFS_PREFIX = "#/$defs/"
78+
79+
80+
def _inline_root_ref(schema: dict[str, Any]) -> dict[str, Any]:
81+
"""Give a schema whose root is a bare `$ref` into `$defs` an inline root.
82+
83+
pydantic emits a self-referential model as `{"$defs": {...}, "$ref": "#/$defs/Model"}`, with no
84+
`type` at the root; `Tool.outputSchema` needs an object root (required on the wire through
85+
2025-11-25). The referenced definition is copied onto the root and `$defs` is kept, since nested
86+
references still point into it. Root siblings of the `$ref` win over the definition's keys.
87+
"""
88+
ref = schema.get("$ref")
89+
if not isinstance(ref, str) or not ref.startswith(_LOCAL_DEFS_PREFIX):
90+
return schema
91+
definition = cast(dict[str, Any], schema["$defs"][ref.removeprefix(_LOCAL_DEFS_PREFIX)])
92+
siblings = {key: value for key, value in schema.items() if key != "$ref"}
93+
return {**definition, **siblings}
94+
95+
7796
class ArgModelBase(BaseModel):
7897
"""A model representing the arguments to a function."""
7998

@@ -108,7 +127,8 @@ class FuncMetadata(BaseModel):
108127
def model_post_init(self, context: Any, /) -> None:
109128
if self.output_model is not None and self.output_schema is None:
110129
# StrictJsonSchema raises instead of warning, so an unserializable return type fails construction.
111-
self.output_schema = self._output_adapter(self.output_model).json_schema(schema_generator=StrictJsonSchema)
130+
schema = self._output_adapter(self.output_model).json_schema(schema_generator=StrictJsonSchema)
131+
self.output_schema = _inline_root_ref(schema)
112132

113133
def _output_adapter(self, output_model: type[Any]) -> TypeAdapter[Any]:
114134
"""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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,6 +1202,34 @@ def func_nested() -> PersonWithAddress: # pragma: no cover
12021202
}
12031203

12041204

1205+
def test_structured_output_self_referential_model_gets_an_object_root():
1206+
"""pydantic publishes a recursive model as a bare root `$ref`; the definition is inlined onto the
1207+
root and `$defs` is kept for the nested reference."""
1208+
1209+
class Node(BaseModel):
1210+
name: str
1211+
children: list["Node"] = []
1212+
1213+
def tree() -> Node:
1214+
return Node(name="root", children=[Node(name="leaf")])
1215+
1216+
node_definition: dict[str, Any] = {
1217+
"properties": {
1218+
"name": {"title": "Name", "type": "string"},
1219+
"children": {"default": [], "items": {"$ref": "#/$defs/Node"}, "title": "Children", "type": "array"},
1220+
},
1221+
"required": ["name"],
1222+
"title": "Node",
1223+
"type": "object",
1224+
}
1225+
meta = func_metadata(tree)
1226+
assert meta.output_schema == {**node_definition, "$defs": {"Node": node_definition}}
1227+
1228+
result = meta.convert_result(tree())
1229+
assert isinstance(result, CallToolResult)
1230+
assert result.structured_content == {"name": "root", "children": [{"name": "leaf", "children": []}]}
1231+
1232+
12051233
def test_structured_output_unserializable_type_error():
12061234
"""Test error when structured_output=True is used with unserializable types"""
12071235

tests/server/mcpserver/test_server.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2167,6 +2167,29 @@ async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult:
21672167
assert exc.value.error.message == "Handler returned an invalid result"
21682168

21692169

2170+
async def test_recursive_tool_return_type_lists_and_calls_on_legacy_session():
2171+
"""A 2025-11-25 session requires `type: object` at the outputSchema root; a self-referential
2172+
return type must not fail the whole listing, and its result validates client-side via `$defs`."""
2173+
2174+
class Node(BaseModel):
2175+
name: str
2176+
children: list["Node"] = []
2177+
2178+
mcp = MCPServer()
2179+
2180+
@mcp.tool()
2181+
def tree() -> Node:
2182+
return Node(name="root", children=[Node(name="leaf")])
2183+
2184+
async with Client(mcp, mode="legacy") as client:
2185+
[tool] = (await client.list_tools()).tools
2186+
assert tool.output_schema is not None
2187+
assert tool.output_schema["type"] == "object"
2188+
assert tool.output_schema["properties"]["children"]["items"] == {"$ref": "#/$defs/Node"}
2189+
result = await client.call_tool("tree", {})
2190+
assert result.structured_content == {"name": "root", "children": [{"name": "leaf", "children": []}]}
2191+
2192+
21702193
async def test_resource_template_input_required_result_on_legacy_session_is_a_serialization_error():
21712194
"""Pins the shared era gate for resources/read: a pre-2026 session has no
21722195
input_required vocabulary, so the runner rejects the frame with -32603."""

0 commit comments

Comments
 (0)