mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Fix: resolve root-level $ref in outputSchema for MCP spec compliance (#2720)
This commit is contained in:
parent
46aeb6b197
commit
8d80299f82
4 changed files with 266 additions and 19 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue