From d522df152fad7f7d4a90debb82d228bf9f11f30f Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:59:01 +0000 Subject: [PATCH] [v1.x] 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, and strict clients (TypeScript SDK 1.x, C# SDK 1.x, python-sdk 2.x on a 2025-11-25 session) reject the entire tools/list result when one tool publishes that shape. Inline the referenced definition onto the root when the generated schema is a bare local $ref, keeping $defs for the nested references. Backport of the fix on main. Github-Issue: #3337 --- .../server/fastmcp/utilities/func_metadata.py | 21 ++++++++++++++- tests/server/fastmcp/test_func_metadata.py | 27 +++++++++++++++++++ tests/server/fastmcp/test_server.py | 24 +++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/fastmcp/utilities/func_metadata.py index 241100d317..f064945101 100644 --- a/src/mcp/server/fastmcp/utilities/func_metadata.py +++ b/src/mcp/server/fastmcp/utilities/func_metadata.py @@ -45,6 +45,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` requires `type: object` at the root. 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.""" @@ -428,7 +447,7 @@ def _try_create_model_and_schema( logger.info(f"Cannot create schema for type {type_expr} in {func_name}: {type(e).__name__}: {e}") return None, None, False - return model, schema, wrap_output + return model, _inline_root_ref(schema), wrap_output return None, None, False diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py index 61e524290e..e4041261f9 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/fastmcp/test_func_metadata.py @@ -983,6 +983,33 @@ 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}} + + _, structured_content = meta.convert_result(tree()) + assert 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""" from typing import NamedTuple diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index b134489bc5..3a3ff71422 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -524,6 +524,30 @@ def get_user(user_id: int) -> UserOutput: assert isinstance(result.content[0], TextContent) assert '"name": "John Doe"' in result.content[0].text + @pytest.mark.anyio + async def test_tool_structured_output_self_referential_model(self): + """A self-referential return type publishes an object-rooted outputSchema (required by the + 2025-11-25 Tool shape) and its result validates client-side through the kept `$defs`.""" + + class Node(BaseModel): + name: str + children: list["Node"] = [] + + def tree() -> Node: + return Node(name="root", children=[Node(name="leaf")]) + + mcp = FastMCP() + mcp.add_tool(tree) + + async with client_session(mcp._mcp_server) as client: + [tool] = (await client.list_tools()).tools + assert tool.outputSchema is not None + assert tool.outputSchema["type"] == "object" + assert tool.outputSchema["properties"]["children"]["items"] == {"$ref": "#/$defs/Node"} + result = await client.call_tool("tree", {}) + assert result.isError is False + assert result.structuredContent == {"name": "root", "children": [{"name": "leaf", "children": []}]} + @pytest.mark.anyio async def test_tool_structured_output_primitive(self): """Test tool with structured output returning primitive type"""