Skip to content

Commit 492a2fb

Browse files
Give recursive tool output schemas an object root (#3337)
Pydantic emits $ref-only roots for self-referential models, which fails 2025-11-25 tools/list validation. Inline the object definition at the root so legacy clients can list tools. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0d92192 commit 492a2fb

3 files changed

Lines changed: 99 additions & 1 deletion

File tree

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ def _try_create_model_and_schema(
474474
# If we successfully created a model, try to get its schema
475475
# Use StrictJsonSchema to raise exceptions instead of warnings
476476
try:
477-
schema = model.model_json_schema(schema_generator=StrictJsonSchema)
477+
schema = _ensure_object_root_schema(model.model_json_schema(schema_generator=StrictJsonSchema))
478478
except (
479479
PydanticUserError,
480480
TypeError,
@@ -496,6 +496,45 @@ def _try_create_model_and_schema(
496496
return None, None, False
497497

498498

499+
def _ensure_object_root_schema(schema: dict[str, Any]) -> dict[str, Any]:
500+
"""Give recursive Pydantic schemas a root ``type: object``.
501+
502+
Self-referential models serialize as ``{"$defs": {...}, "$ref": "#/$defs/Name"}``
503+
with no root type. That is valid JSON Schema and fine on 2026-07-28 sessions,
504+
but 2025-11-25 ``Tool.outputSchema`` requires ``type: "object"`` at the root, so
505+
a legacy ``tools/list`` fails validation for the entire listing (issue #3337).
506+
507+
Inline the root ``$ref`` when it points at an object definition, keeping ``$defs``
508+
so nested self-references still resolve. Fall back to an ``allOf`` wrapper if the
509+
target is not an object schema.
510+
"""
511+
if schema.get("type") == "object":
512+
return schema
513+
514+
ref = schema.get("$ref")
515+
defs = schema.get("$defs")
516+
if not isinstance(ref, str) or not ref.startswith("#/$defs/") or not isinstance(defs, dict):
517+
return schema
518+
519+
name = ref.rsplit("/", 1)[-1]
520+
target = defs.get(name)
521+
if isinstance(target, dict) and target.get("type") == "object":
522+
inlined = {
523+
**target,
524+
**{key: value for key, value in schema.items() if key not in ("$ref", "$defs") and key not in target},
525+
"$defs": defs,
526+
}
527+
return inlined
528+
529+
wrapped: dict[str, Any] = {
530+
"type": "object",
531+
"allOf": [{"$ref": ref}],
532+
"$defs": defs,
533+
**{key: value for key, value in schema.items() if key not in ("$ref", "$defs", "type", "allOf")},
534+
}
535+
return wrapped
536+
537+
499538
_no_default = object()
500539

501540

tests/server/mcpserver/test_func_metadata.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,27 @@ def func_returning_person() -> PersonModel: # pragma: no cover
624624
}
625625

626626

627+
def test_structured_output_recursive_basemodel():
628+
"""Recursive BaseModel output schemas must have type: object at the root (#3337)."""
629+
630+
class Node(BaseModel):
631+
name: str
632+
children: list["Node"] = []
633+
634+
def tree() -> Node: # pragma: no cover
635+
return Node(name="root")
636+
637+
schema = func_metadata(tree).output_schema
638+
assert schema is not None
639+
assert schema["type"] == "object"
640+
assert schema["title"] == "Node"
641+
assert "name" in schema["properties"]
642+
assert "children" in schema["properties"]
643+
assert "$defs" in schema
644+
assert "Node" in schema["$defs"]
645+
assert "$ref" not in schema
646+
647+
627648
def test_structured_output_primitives():
628649
"""Test structured output with primitive return types"""
629650

tests/server/mcpserver/test_server.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,44 @@ def get_user(user_id: int) -> UserOutput:
524524
assert isinstance(result.content[0], TextContent)
525525
assert '"name": "John Doe"' in result.content[0].text
526526

527+
async def test_tool_recursive_output_schema_lists_on_legacy(self):
528+
"""Recursive return types must not fail the whole tools/list on 2025-11-25 (#3337)."""
529+
530+
class Node(BaseModel):
531+
name: str
532+
children: list["Node"] = []
533+
534+
def tree() -> Node:
535+
"""Return a tree node."""
536+
return Node(name="root")
537+
538+
def other() -> int:
539+
"""Return an integer."""
540+
return 1
541+
542+
mcp = MCPServer("rec")
543+
mcp.add_tool(tree)
544+
mcp.add_tool(other)
545+
546+
async with Client(mcp) as client:
547+
tools = await client.list_tools()
548+
names = sorted(t.name for t in tools.tools)
549+
assert names == ["other", "tree"]
550+
tree_tool = next(t for t in tools.tools if t.name == "tree")
551+
assert tree_tool.output_schema is not None
552+
assert tree_tool.output_schema["type"] == "object"
553+
554+
async with Client(mcp, mode="legacy") as client:
555+
tools = await client.list_tools()
556+
names = sorted(t.name for t in tools.tools)
557+
assert names == ["other", "tree"]
558+
tree_tool = next(t for t in tools.tools if t.name == "tree")
559+
assert tree_tool.output_schema is not None
560+
assert tree_tool.output_schema["type"] == "object"
561+
other_tool = next(t for t in tools.tools if t.name == "other")
562+
assert other_tool.output_schema is not None
563+
assert other_tool.output_schema["type"] == "object"
564+
527565
async def test_tool_structured_output_primitive(self):
528566
"""Test tool with structured output returning primitive type"""
529567

0 commit comments

Comments
 (0)