diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 191f4737a8..e40a7115ee 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -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, @@ -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}") diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py index b6364757f9..7565c0dbb3 100644 --- a/src/fastmcp/utilities/json_schema.py +++ b/src/fastmcp/utilities/json_schema.py @@ -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: diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 1f21ffc778..1c24e78346 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -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.""" @@ -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.""" diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index 5fce3be685..00162e2c11 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -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 @@ 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