Skip to content
Merged
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
21 changes: 20 additions & 1 deletion src/mcp/server/fastmcp/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,25 @@
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)])

Check warning on line 62 in src/mcp/server/fastmcp/utilities/func_metadata.py

View check run for this annotation

Claude / Claude Code Review

Minor/edge: `_inline_root_ref` does unguarded `schema["$defs"][name]` lookups, so a root-level `$ref` without a matching local definition raises KeyError instead of being passed through

Minor/edge: `_inline_root_ref` does unguarded `schema["$defs"][name]` lookups, so a root-level `$ref` without a matching local definition raises KeyError instead of being passed through

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor/edge: _inline_root_ref does unguarded schema["$defs"][name] lookups, so a root-level $ref without a matching local definition raises KeyError instead of being passed through

Extended reasoning...

A user customizes a return model's schema (e.g. model_config = ConfigDict(json_schema_extra={"$ref": "#/$defs/X"}) or a custom schema generator) so the generated schema has a root $ref starting with #/$defs/ but no $defs key or no X entry. Before this change the schema was published as-is; after it, func_metadata raises an uncaught KeyError inside _try_create_model_and_schema (the surrounding try only wraps model_json_schema), so mcp.tool() registration crashes at import/startup instead of registering the tool.

Verification: nit — Line 62 of src/mcp/server/fastmcp/utilities/func_metadata.py performs unguarded lookups: definition = cast(dict[str, Any], schema["$defs"][ref.removeprefix(_LOCAL_DEFS_PREFIX)]). The guard at lines 59-61 only checks that $ref is a string starting with "#/$defs/"; it never checks that $defs exists or contains the referenced name. The enclosing try/except in `_try_create_model_and_sc

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."""

Expand Down Expand Up @@ -428,7 +447,7 @@
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

Expand Down
27 changes: 27 additions & 0 deletions tests/server/fastmcp/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions tests/server/fastmcp/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
Loading