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
6 changes: 5 additions & 1 deletion src/fastmcp/tools/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from fastmcp.server.dependencies import get_context, without_injected_parameters
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.json_schema import compress_schema, resolve_root_ref
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import (
Audio,
Expand Down Expand Up @@ -559,6 +559,10 @@ def from_function(

output_schema = compress_schema(output_schema, prune_titles=True)

# Resolve root-level $ref to meet MCP spec requirement for type: object
# Self-referential Pydantic models generate schemas with $ref at root
output_schema = resolve_root_ref(output_schema)

except PydanticSchemaGenerationError as e:
if "_UnserializableType" not in str(e):
logger.debug(f"Unable to generate schema for type {output_type!r}")
Expand Down
40 changes: 40 additions & 0 deletions src/fastmcp/utilities/json_schema.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,46 @@
from __future__ import annotations

from collections import defaultdict
from typing import Any


def resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any]:
"""Resolve $ref at root level to meet MCP spec requirements.

MCP specification requires outputSchema to have "type": "object" at the root level.
When Pydantic generates schemas for self-referential models, it uses $ref at the
root level pointing to $defs. This function resolves such references by inlining
the referenced definition while preserving $defs for nested references.

Args:
schema: JSON schema dict that may have $ref at root level

Returns:
A new schema dict with root-level $ref resolved, or the original schema
if no resolution is needed

Example:
>>> schema = {
... "$defs": {"Node": {"type": "object", "properties": {...}}},
... "$ref": "#/$defs/Node"
... }
>>> resolved = resolve_root_ref(schema)
>>> # Result: {"type": "object", "properties": {...}, "$defs": {...}}
"""
# Only resolve if we have $ref at root level with $defs but no explicit type
if "$ref" in schema and "$defs" in schema and "type" not in schema:
ref = schema["$ref"]
# Only handle local $defs references
if isinstance(ref, str) and ref.startswith("#/$defs/"):
def_name = ref.split("/")[-1]
defs = schema["$defs"]
if def_name in defs:
# Create a new schema by copying the referenced definition
resolved = dict(defs[def_name])
# Preserve $defs for nested references (other fields may still use them)
resolved["$defs"] = defs
return resolved
return schema


def _prune_param(schema: dict, param: str) -> dict:
Expand Down
89 changes: 71 additions & 18 deletions tests/tools/test_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1296,6 +1296,69 @@ def return_things() -> ReturnThing:
"stuff": [{"value": 456, "stuff": []}],
}

async def test_self_referencing_pydantic_model_has_type_object_at_root(self):
"""Test that self-referencing Pydantic models have type: object at root.

MCP spec requires outputSchema to have "type": "object" at the root level.
Pydantic generates schemas with $ref at root for self-referential models,
which violates this requirement. FastMCP should resolve the $ref.

Regression test for issue #2455.
"""

class Issue(BaseModel):
id: str
title: str
dependencies: list["Issue"] = []
dependents: list["Issue"] = []

def get_issue(issue_id: str) -> Issue:
return Issue(id=issue_id, title="Test")

tool = Tool.from_function(get_issue)

# The output schema should have "type": "object" at root, not $ref
assert tool.output_schema is not None
assert tool.output_schema.get("type") == "object"
assert "properties" in tool.output_schema
# Should still have $defs for nested references
assert "$defs" in tool.output_schema
# Should NOT have $ref at root level
assert "$ref" not in tool.output_schema

async def test_self_referencing_model_outputschema_mcp_compliant(self):
"""Test that self-referencing model schemas are MCP spec compliant.

The MCP spec requires:
- type: "object" at root level
- properties field
- required field (optional)

This ensures clients can properly validate the schema.

Regression test for issue #2455.
"""

class Node(BaseModel):
id: str
children: list["Node"] = []

def get_node() -> Node:
return Node(id="1")

tool = Tool.from_function(get_node)

# Schema should be MCP-compliant
assert tool.output_schema is not None
assert tool.output_schema.get("type") == "object", (
"MCP spec requires 'type': 'object' at root"
)
assert "properties" in tool.output_schema
assert "id" in tool.output_schema["properties"]
assert "children" in tool.output_schema["properties"]
# Required should include 'id'
assert "id" in tool.output_schema.get("required", [])

async def test_int_return_no_structured_content_without_schema(self):
"""Test that int returns don't create structured content without output schema."""

Expand Down Expand Up @@ -1558,28 +1621,18 @@ async def get_component(
# not the first validation alias 'id'
assert tool.output_schema is not None

# For object types, the schema may use $ref at root (self-referencing types)
# or have properties directly. Check both cases.
if "$ref" in tool.output_schema:
# Schema uses $ref - resolve to get the actual definition
assert "$defs" in tool.output_schema
ref_path = tool.output_schema["$ref"].replace("#/$defs/", "")
component_def = tool.output_schema["$defs"][ref_path]
else:
# Schema has properties directly (wrapped case)
assert "properties" in tool.output_schema
assert "result" in tool.output_schema["properties"]
assert "$defs" in tool.output_schema
# Find the Component definition
component_def = list(tool.output_schema["$defs"].values())[0]
# Object schemas have properties directly at root (MCP spec compliance)
# Root-level $refs are resolved to ensure type: object at root
assert "properties" in tool.output_schema
assert tool.output_schema.get("type") == "object"

# Should have 'componentId' not 'id' in properties
assert "componentId" in component_def["properties"]
assert "id" not in component_def["properties"]
assert "componentId" in tool.output_schema["properties"]
assert "id" not in tool.output_schema["properties"]

# Should require 'componentId' not 'id'
assert "componentId" in component_def["required"]
assert "id" not in component_def.get("required", [])
assert "componentId" in tool.output_schema.get("required", [])
assert "id" not in tool.output_schema.get("required", [])

async def test_tool_execution_with_serialization_alias(self):
"""Test that tool execution works correctly with serialization aliases."""
Expand Down
150 changes: 150 additions & 0 deletions tests/utilities/test_json_schema.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastmcp.utilities.json_schema import (
_prune_param,
compress_schema,
resolve_root_ref,
)

# Wrapper for backward compatibility with tests
Expand Down Expand Up @@ -505,3 +506,152 @@ def test_title_pruning_with_nested_properties(self):
"title" not in compressed["properties"]["title"]["properties"]["subtitle"]
)
assert "title" not in compressed["properties"]["normal_field"]


class TestResolveRootRef:
"""Tests for the resolve_root_ref function.

This function resolves $ref at root level to meet MCP spec requirements.
MCP specification requires outputSchema to have "type": "object" at root.
"""

def test_resolves_simple_root_ref(self):
"""Test that simple $ref at root is resolved."""
schema = {
"$defs": {
"Node": {
"type": "object",
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
},
"required": ["id"],
}
},
"$ref": "#/$defs/Node",
}
result = resolve_root_ref(schema)

# Should have type: object at root now
assert result.get("type") == "object"
assert "properties" in result
assert "id" in result["properties"]
assert "name" in result["properties"]
# Should still have $defs for nested references
assert "$defs" in result
# Should NOT have $ref at root
assert "$ref" not in result

def test_resolves_self_referential_model(self):
"""Test resolving schema for self-referential models like Issue."""
# This is the exact schema Pydantic generates for self-referential models
schema = {
"$defs": {
"Issue": {
"type": "object",
"properties": {
"id": {"type": "string"},
"title": {"type": "string"},
"dependencies": {
"type": "array",
"items": {"$ref": "#/$defs/Issue"},
},
"dependents": {
"type": "array",
"items": {"$ref": "#/$defs/Issue"},
},
},
"required": ["id", "title"],
}
},
"$ref": "#/$defs/Issue",
}
result = resolve_root_ref(schema)

# Should have type: object at root
assert result.get("type") == "object"
assert "properties" in result
assert "id" in result["properties"]
assert "dependencies" in result["properties"]
# Nested $refs should still point to $defs
assert result["properties"]["dependencies"]["items"]["$ref"] == "#/$defs/Issue"
# Should have $defs preserved for nested references
assert "$defs" in result
assert "Issue" in result["$defs"]

def test_does_not_modify_schema_with_type_at_root(self):
"""Test that schemas already having type at root are not modified."""
schema = {
"type": "object",
"properties": {"id": {"type": "string"}},
"$defs": {"SomeType": {"type": "string"}},
"$ref": "#/$defs/SomeType", # This would be unusual but possible
}
result = resolve_root_ref(schema)

# Schema should be unchanged (returned as-is)
assert result is schema

def test_does_not_modify_schema_without_ref(self):
"""Test that schemas without $ref are not modified."""
schema = {
"type": "object",
"properties": {"id": {"type": "string"}},
}
result = resolve_root_ref(schema)

assert result is schema

def test_does_not_modify_schema_without_defs(self):
"""Test that schemas with $ref but without $defs are not modified."""
schema = {
"$ref": "#/$defs/Missing",
}
result = resolve_root_ref(schema)

assert result is schema

def test_does_not_modify_external_ref(self):
"""Test that external $refs (not pointing to $defs) are not resolved."""
schema = {
"$defs": {"Node": {"type": "object"}},
"$ref": "https://example.com/schema.json#/definitions/Node",
}
result = resolve_root_ref(schema)

assert result is schema

def test_preserves_all_defs_for_nested_references(self):
"""Test that $defs are preserved even if multiple definitions exist."""
schema = {
"$defs": {
"Node": {
"type": "object",
"properties": {
"child": {"$ref": "#/$defs/ChildNode"},
},
},
"ChildNode": {
"type": "object",
"properties": {"value": {"type": "string"}},
},
},
"$ref": "#/$defs/Node",
}
result = resolve_root_ref(schema)

# Both defs should be preserved
assert "$defs" in result
assert "Node" in result["$defs"]
assert "ChildNode" in result["$defs"]

def test_handles_missing_def_gracefully(self):
"""Test that missing definition in $defs doesn't cause error."""
schema = {
"$defs": {"OtherType": {"type": "string"}},
"$ref": "#/$defs/Missing",
}
result = resolve_root_ref(schema)

# Should return original schema unchanged
assert result is schema
Loading