Fix: resolve root-level $ref in outputSchema for MCP spec compliance (#2720)

This commit is contained in:
lif 2025-12-25 21:42:29 +08:00 committed by GitHub
commit 8d80299f82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 266 additions and 19 deletions

View file

@ -33,7 +33,7 @@ import fastmcp
from fastmcp.server.dependencies import 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,
@ -636,6 +636,10 @@ class ParsedFunction:
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}")

View file

@ -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:

View file

@ -1319,6 +1319,69 @@ class TestAutomaticStructuredContent:
"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."""
@ -1581,28 +1644,18 @@ class TestSerializationAlias:
# 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."""

View file

@ -1,6 +1,7 @@
from fastmcp.utilities.json_schema import (
_prune_param,
compress_schema,
resolve_root_ref,
)
# Wrapper for backward compatibility with tests
@ -505,3 +506,152 @@ class TestCompressSchema:
"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