Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ def _try_create_model_and_schema(
# If we successfully created a model, try to get its schema
# Use StrictJsonSchema to raise exceptions instead of warnings
try:
schema = model.model_json_schema(schema_generator=StrictJsonSchema)
schema = _ensure_object_root_schema(model.model_json_schema(schema_generator=StrictJsonSchema))
except (
PydanticUserError,
TypeError,
Expand All @@ -496,6 +496,45 @@ def _try_create_model_and_schema(
return None, None, False


def _ensure_object_root_schema(schema: dict[str, Any]) -> dict[str, Any]:
"""Give recursive Pydantic schemas a root ``type: object``.

Self-referential models serialize as ``{"$defs": {...}, "$ref": "#/$defs/Name"}``
with no root type. That is valid JSON Schema and fine on 2026-07-28 sessions,
but 2025-11-25 ``Tool.outputSchema`` requires ``type: "object"`` at the root, so
a legacy ``tools/list`` fails validation for the entire listing (issue #3337).

Inline the root ``$ref`` when it points at an object definition, keeping ``$defs``
so nested self-references still resolve. Fall back to an ``allOf`` wrapper if the
target is not an object schema.
"""
if schema.get("type") == "object":
return schema

ref = schema.get("$ref")
defs = schema.get("$defs")
if not isinstance(ref, str) or not ref.startswith("#/$defs/") or not isinstance(defs, dict):
return schema

name = ref.rsplit("/", 1)[-1]
target = defs.get(name)
if isinstance(target, dict) and target.get("type") == "object":
inlined = {
**target,
**{key: value for key, value in schema.items() if key not in ("$ref", "$defs") and key not in target},
"$defs": defs,
}
return inlined

wrapped: dict[str, Any] = {
"type": "object",
"allOf": [{"$ref": ref}],
"$defs": defs,
**{key: value for key, value in schema.items() if key not in ("$ref", "$defs", "type", "allOf")},
}
return wrapped


_no_default = object()


Expand Down
21 changes: 21 additions & 0 deletions tests/server/mcpserver/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,27 @@ def func_returning_person() -> PersonModel: # pragma: no cover
}


def test_structured_output_recursive_basemodel():
"""Recursive BaseModel output schemas must have type: object at the root (#3337)."""

class Node(BaseModel):
name: str
children: list["Node"] = []

def tree() -> Node: # pragma: no cover
return Node(name="root")

schema = func_metadata(tree).output_schema
assert schema is not None
assert schema["type"] == "object"
assert schema["title"] == "Node"
assert "name" in schema["properties"]
assert "children" in schema["properties"]
assert "$defs" in schema
assert "Node" in schema["$defs"]
assert "$ref" not in schema


def test_structured_output_primitives():
"""Test structured output with primitive return types"""

Expand Down
38 changes: 38 additions & 0 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,44 @@ def get_user(user_id: int) -> UserOutput:
assert isinstance(result.content[0], TextContent)
assert '"name": "John Doe"' in result.content[0].text

async def test_tool_recursive_output_schema_lists_on_legacy(self):
"""Recursive return types must not fail the whole tools/list on 2025-11-25 (#3337)."""

class Node(BaseModel):
name: str
children: list["Node"] = []

def tree() -> Node:
"""Return a tree node."""
return Node(name="root")

def other() -> int:
"""Return an integer."""
return 1

mcp = MCPServer("rec")
mcp.add_tool(tree)
mcp.add_tool(other)

async with Client(mcp) as client:
tools = await client.list_tools()
names = sorted(t.name for t in tools.tools)
assert names == ["other", "tree"]
tree_tool = next(t for t in tools.tools if t.name == "tree")
assert tree_tool.output_schema is not None
assert tree_tool.output_schema["type"] == "object"

async with Client(mcp, mode="legacy") as client:
tools = await client.list_tools()
names = sorted(t.name for t in tools.tools)
assert names == ["other", "tree"]
tree_tool = next(t for t in tools.tools if t.name == "tree")
assert tree_tool.output_schema is not None
assert tree_tool.output_schema["type"] == "object"
other_tool = next(t for t in tools.tools if t.name == "other")
assert other_tool.output_schema is not None
assert other_tool.output_schema["type"] == "object"

async def test_tool_structured_output_primitive(self):
"""Test tool with structured output returning primitive type"""

Expand Down