From d56f6c137c67709bdd8ed06d37e0e747763e86cc Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:52:34 +0000 Subject: [PATCH] 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. --- .../mcpserver/utilities/func_metadata.py | 22 ++++++++++++++- tests/server/mcpserver/test_func_metadata.py | 28 +++++++++++++++++++ tests/server/mcpserver/test_server.py | 23 +++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 09e3d0a795..cc32433568 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -74,6 +74,25 @@ def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None: raise ValueError(f"JSON schema warning: {kind} - {detail}") +_LOCAL_DEFS_PREFIX = "#/$defs/" + + +def _inline_root_ref(schema: dict[str, Any]) -> dict[str, Any]: + """Give a schema whose root is a bare `$ref` into `$defs` an inline root. + + pydantic emits a self-referential model as `{"$defs": {...}, "$ref": "#/$defs/Model"}`, with no + `type` at the root; `Tool.outputSchema` needs an object root (required on the wire through + 2025-11-25). The referenced definition is copied onto the root and `$defs` is kept, since nested + references still point into it. Root siblings of the `$ref` win over the definition's keys. + """ + ref = schema.get("$ref") + if not isinstance(ref, str) or not ref.startswith(_LOCAL_DEFS_PREFIX): + return schema + definition = cast(dict[str, Any], schema["$defs"][ref.removeprefix(_LOCAL_DEFS_PREFIX)]) + siblings = {key: value for key, value in schema.items() if key != "$ref"} + return {**definition, **siblings} + + class ArgModelBase(BaseModel): """A model representing the arguments to a function.""" @@ -108,7 +127,8 @@ class FuncMetadata(BaseModel): def model_post_init(self, context: Any, /) -> None: if self.output_model is not None and self.output_schema is None: # StrictJsonSchema raises instead of warning, so an unserializable return type fails construction. - self.output_schema = self._output_adapter(self.output_model).json_schema(schema_generator=StrictJsonSchema) + schema = self._output_adapter(self.output_model).json_schema(schema_generator=StrictJsonSchema) + self.output_schema = _inline_root_ref(schema) def _output_adapter(self, output_model: type[Any]) -> TypeAdapter[Any]: """The validator/serializer for `output_model`, built once and rebuilt only if the field is reassigned.""" diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 93a3b1d42e..dba0637ded 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -1202,6 +1202,34 @@ def func_nested() -> PersonWithAddress: # pragma: no cover } +def test_structured_output_self_referential_model_gets_an_object_root(): + """pydantic publishes a recursive model as a bare root `$ref`; the definition is inlined onto the + root and `$defs` is kept for the nested reference.""" + + class Node(BaseModel): + name: str + children: list["Node"] = [] + + def tree() -> Node: + return Node(name="root", children=[Node(name="leaf")]) + + node_definition: dict[str, Any] = { + "properties": { + "name": {"title": "Name", "type": "string"}, + "children": {"default": [], "items": {"$ref": "#/$defs/Node"}, "title": "Children", "type": "array"}, + }, + "required": ["name"], + "title": "Node", + "type": "object", + } + meta = func_metadata(tree) + assert meta.output_schema == {**node_definition, "$defs": {"Node": node_definition}} + + result = meta.convert_result(tree()) + assert isinstance(result, CallToolResult) + assert result.structured_content == {"name": "root", "children": [{"name": "leaf", "children": []}]} + + def test_structured_output_unserializable_type_error(): """Test error when structured_output=True is used with unserializable types""" diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index c398bc6243..3f90ce1368 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -2167,6 +2167,29 @@ async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: assert exc.value.error.message == "Handler returned an invalid result" +async def test_recursive_tool_return_type_lists_and_calls_on_legacy_session(): + """A 2025-11-25 session requires `type: object` at the outputSchema root; a self-referential + return type must not fail the whole listing, and its result validates client-side via `$defs`.""" + + class Node(BaseModel): + name: str + children: list["Node"] = [] + + mcp = MCPServer() + + @mcp.tool() + def tree() -> Node: + return Node(name="root", children=[Node(name="leaf")]) + + async with Client(mcp, mode="legacy") as client: + [tool] = (await client.list_tools()).tools + assert tool.output_schema is not None + assert tool.output_schema["type"] == "object" + assert tool.output_schema["properties"]["children"]["items"] == {"$ref": "#/$defs/Node"} + result = await client.call_tool("tree", {}) + assert result.structured_content == {"name": "root", "children": [{"name": "leaf", "children": []}]} + + async def test_resource_template_input_required_result_on_legacy_session_is_a_serialization_error(): """Pins the shared era gate for resources/read: a pre-2026 session has no input_required vocabulary, so the runner rejects the frame with -32603."""