Skip to content

Commit d522df1

Browse files
committed
[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
1 parent 98b7159 commit d522df1

3 files changed

Lines changed: 71 additions & 1 deletion

File tree

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,25 @@ def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None:
4545
raise ValueError(f"JSON schema warning: {kind} - {detail}")
4646

4747

48+
_LOCAL_DEFS_PREFIX = "#/$defs/"
49+
50+
51+
def _inline_root_ref(schema: dict[str, Any]) -> dict[str, Any]:
52+
"""Give a schema whose root is a bare `$ref` into `$defs` an inline root.
53+
54+
pydantic emits a self-referential model as `{"$defs": {...}, "$ref": "#/$defs/Model"}`, with no
55+
`type` at the root; `Tool.outputSchema` requires `type: object` at the root. The referenced
56+
definition is copied onto the root and `$defs` is kept, since nested references still point into
57+
it. Root siblings of the `$ref` win over the definition's keys.
58+
"""
59+
ref = schema.get("$ref")
60+
if not isinstance(ref, str) or not ref.startswith(_LOCAL_DEFS_PREFIX):
61+
return schema
62+
definition = cast(dict[str, Any], schema["$defs"][ref.removeprefix(_LOCAL_DEFS_PREFIX)])
63+
siblings = {key: value for key, value in schema.items() if key != "$ref"}
64+
return {**definition, **siblings}
65+
66+
4867
class ArgModelBase(BaseModel):
4968
"""A model representing the arguments to a function."""
5069

@@ -428,7 +447,7 @@ def _try_create_model_and_schema(
428447
logger.info(f"Cannot create schema for type {type_expr} in {func_name}: {type(e).__name__}: {e}")
429448
return None, None, False
430449

431-
return model, schema, wrap_output
450+
return model, _inline_root_ref(schema), wrap_output
432451

433452
return None, None, False
434453

tests/server/fastmcp/test_func_metadata.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,33 @@ def func_nested() -> PersonWithAddress: # pragma: no cover
983983
}
984984

985985

986+
def test_structured_output_self_referential_model_gets_an_object_root():
987+
"""pydantic publishes a recursive model as a bare root `$ref`; the definition is inlined onto the
988+
root and `$defs` is kept for the nested reference."""
989+
990+
class Node(BaseModel):
991+
name: str
992+
children: list["Node"] = []
993+
994+
def tree() -> Node:
995+
return Node(name="root", children=[Node(name="leaf")])
996+
997+
node_definition: dict[str, Any] = {
998+
"properties": {
999+
"name": {"title": "Name", "type": "string"},
1000+
"children": {"default": [], "items": {"$ref": "#/$defs/Node"}, "title": "Children", "type": "array"},
1001+
},
1002+
"required": ["name"],
1003+
"title": "Node",
1004+
"type": "object",
1005+
}
1006+
meta = func_metadata(tree)
1007+
assert meta.output_schema == {**node_definition, "$defs": {"Node": node_definition}}
1008+
1009+
_, structured_content = meta.convert_result(tree())
1010+
assert structured_content == {"name": "root", "children": [{"name": "leaf", "children": []}]}
1011+
1012+
9861013
def test_structured_output_unserializable_type_error():
9871014
"""Test error when structured_output=True is used with unserializable types"""
9881015
from typing import NamedTuple

tests/server/fastmcp/test_server.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,30 @@ 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+
@pytest.mark.anyio
528+
async def test_tool_structured_output_self_referential_model(self):
529+
"""A self-referential return type publishes an object-rooted outputSchema (required by the
530+
2025-11-25 Tool shape) and its result validates client-side through the kept `$defs`."""
531+
532+
class Node(BaseModel):
533+
name: str
534+
children: list["Node"] = []
535+
536+
def tree() -> Node:
537+
return Node(name="root", children=[Node(name="leaf")])
538+
539+
mcp = FastMCP()
540+
mcp.add_tool(tree)
541+
542+
async with client_session(mcp._mcp_server) as client:
543+
[tool] = (await client.list_tools()).tools
544+
assert tool.outputSchema is not None
545+
assert tool.outputSchema["type"] == "object"
546+
assert tool.outputSchema["properties"]["children"]["items"] == {"$ref": "#/$defs/Node"}
547+
result = await client.call_tool("tree", {})
548+
assert result.isError is False
549+
assert result.structuredContent == {"name": "root", "children": [{"name": "leaf", "children": []}]}
550+
527551
@pytest.mark.anyio
528552
async def test_tool_structured_output_primitive(self):
529553
"""Test tool with structured output returning primitive type"""

0 commit comments

Comments
 (0)