diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 2037b860a1..70c43f61a7 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -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, @@ -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() diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 2dfe5d389d..7d407739b9 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -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""" diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 81b490c544..f0c5e0d098 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -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"""