Fix circular reference crash in OpenAPI schemas (#3245)

* Fix circular reference crash in OpenAPI schemas (#3242, #1206, #1016)

* chore: Update SDK documentation

---------

Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
This commit is contained in:
Jeremiah Lowin 2026-02-20 09:21:54 -05:00 committed by GitHub
commit 1caf40eee8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 311 additions and 5 deletions

View file

@ -7,7 +7,7 @@ sidebarTitle: json_schema
## Functions
### `dereference_refs` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/utilities/json_schema.py#L9" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `dereference_refs` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/utilities/json_schema.py#L56" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
dereference_refs(schema: dict[str, Any]) -> dict[str, Any]
@ -35,7 +35,7 @@ $defs for nested references.
- when no longer needed
### `resolve_root_ref` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/utilities/json_schema.py#L131" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `resolve_root_ref` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/utilities/json_schema.py#L185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any]
@ -57,7 +57,7 @@ the referenced definition while preserving $defs for nested references.
- if no resolution is needed
### `compress_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/utilities/json_schema.py#L364" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `compress_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/src/fastmcp/utilities/json_schema.py#L418" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False, dereference: bool = False) -> dict[str, Any]

View file

@ -6,6 +6,53 @@ from typing import Any
from jsonref import JsonRefError, replace_refs
def _defs_have_cycles(defs: dict[str, Any]) -> bool:
"""Check whether any definitions in ``$defs`` form a reference cycle.
A cycle means a definition directly or transitively references itself
(e.g. Node children Node, or A B A). ``jsonref.replace_refs``
silently produces Python-level object cycles for these, which Pydantic's
serializer rejects.
"""
if not defs:
return False
# Build adjacency: def_name -> set of def_names it references.
edges: dict[str, set[str]] = defaultdict(set)
def _collect_refs(obj: Any, source: str) -> None:
if isinstance(obj, dict):
ref = obj.get("$ref")
if isinstance(ref, str) and ref.startswith("#/$defs/"):
edges[source].add(ref.split("/")[-1])
for v in obj.values():
_collect_refs(v, source)
elif isinstance(obj, list):
for item in obj:
_collect_refs(item, source)
for name, definition in defs.items():
_collect_refs(definition, name)
# DFS cycle detection.
UNVISITED, IN_STACK, DONE = 0, 1, 2
state: dict[str, int] = defaultdict(int)
def _has_cycle(node: str) -> bool:
state[node] = IN_STACK
for neighbor in edges.get(node, ()):
if neighbor not in defs:
continue
if state[neighbor] == IN_STACK:
return True
if state[neighbor] == UNVISITED and _has_cycle(neighbor):
return True
state[node] = DONE
return False
return any(state[name] == UNVISITED and _has_cycle(name) for name in defs)
def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]:
"""Resolve all $ref references in a JSON schema by inlining definitions.
@ -35,6 +82,13 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]:
>>> resolved = dereference_refs(schema)
>>> # Result: {"properties": {"cat": {"enum": ["a", "b"], "type": "string", "default": "a"}}}
"""
# Circular $defs can't be fully inlined — jsonref.replace_refs produces
# Python dicts with object-identity cycles that Pydantic's model_dump
# rejects with "Circular reference detected (id repeated)".
# Detect cycles up front and fall back to root-only resolution.
if _defs_have_cycles(schema.get("$defs", {})):
return resolve_root_ref(schema)
try:
# Use jsonref to resolve all $ref references
# proxies=False returns plain dicts (not proxy objects)

View file

@ -116,8 +116,8 @@ def _replace_ref_with_defs(
elif item_schema := schema.get("items"):
schema["items"] = _replace_ref_with_defs(item_schema)
for section in ["anyOf", "allOf", "oneOf"]:
for i, item in enumerate(schema.get(section, [])):
schema[section][i] = _replace_ref_with_defs(item)
if section in schema:
schema[section] = [_replace_ref_with_defs(item) for item in schema[section]]
if additionalProperties := schema.get("additionalProperties"):
if not isinstance(additionalProperties, bool):
schema["additionalProperties"] = _replace_ref_with_defs(

View file

@ -1,5 +1,8 @@
"""Comprehensive tests for transitive and nested reference handling (Issue #1372)."""
import httpx
from fastmcp import FastMCP
from fastmcp.utilities.openapi.models import (
HTTPRoute,
ParameterInfo,
@ -9,6 +12,7 @@ from fastmcp.utilities.openapi.models import (
from fastmcp.utilities.openapi.parser import parse_openapi_to_http_routes
from fastmcp.utilities.openapi.schemas import (
_combine_schemas_and_map_params,
_replace_ref_with_defs,
extract_output_schema_from_responses,
)
@ -840,3 +844,251 @@ class TestTransitiveAndNestedReferences:
assert "Person" in route.response_schemas
assert "Name" in route.response_schemas
assert "Job" in route.response_schemas
class TestCircularReferencesSerialization:
"""Tests for circular/self-referential schemas surviving MCP serialization.
Issues: #1016, #1206, #3242
The crash occurs when Pydantic's model_dump() encounters the same Python
dict object at multiple positions in the serialization tree. This happens
because _replace_ref_with_defs mutates shared list objects (anyOf/allOf/oneOf)
in place via shallow copy, causing different tools to share internal dict
references.
"""
def test_replace_ref_with_defs_does_not_mutate_input(self):
"""_replace_ref_with_defs must not mutate its input dict's lists."""
schema = {
"oneOf": [
{"$ref": "#/components/schemas/Cat"},
{"$ref": "#/components/schemas/Dog"},
]
}
original_list = schema["oneOf"]
original_items = list(original_list) # snapshot
_replace_ref_with_defs(schema)
# The original list object must not have been mutated
assert original_list is schema["oneOf"]
assert original_list == original_items
def test_replace_ref_with_defs_produces_independent_results(self):
"""Calling _replace_ref_with_defs twice on the same input must produce
independent dict trees with no shared mutable objects."""
schema = {
"type": "object",
"properties": {
"pet": {
"oneOf": [
{"$ref": "#/components/schemas/Cat"},
{"$ref": "#/components/schemas/Dog"},
]
}
},
}
result1 = _replace_ref_with_defs(schema)
result2 = _replace_ref_with_defs(schema)
# The oneOf lists should be different objects
list1 = result1["properties"]["pet"]["oneOf"]
list2 = result2["properties"]["pet"]["oneOf"]
assert list1 is not list2
# Items within the lists should also be independent
assert list1[0] is not list2[0]
def test_circular_output_schema_serialization(self):
"""Output schemas with self-referential types must survive model_dump().
This is the exact crash from issue #3242: Pydantic raises
ValueError('Circular reference detected (id repeated)') when
serializing MCP Tool objects whose schemas share Python dict references.
"""
responses = {
"200": ResponseInfo(
description="A tree node",
content_schema={
"application/json": {"$ref": "#/components/schemas/Node"}
},
)
}
schema_definitions = {
"Node": {
"type": "object",
"properties": {
"value": {"type": "string"},
"children": {
"type": "array",
"items": {"$ref": "#/components/schemas/Node"},
},
},
},
}
output_schema = extract_output_schema_from_responses(
responses, schema_definitions=schema_definitions, openapi_version="3.0.0"
)
assert output_schema is not None
# Build an MCP Tool with this schema and try to serialize it —
# this is the exact path that crashes in the reported issue.
from mcp.types import Tool as MCPTool
tool = MCPTool(
name="get_node",
description="Get a node",
inputSchema={"type": "object", "properties": {}},
outputSchema=output_schema,
)
# This must not raise ValueError: Circular reference detected
tool.model_dump(by_alias=True, mode="json", exclude_none=True)
def test_mutual_circular_references_serialization(self):
"""Mutually circular schemas (A→B→A) must survive serialization."""
responses = {
"200": ResponseInfo(
description="A pull request",
content_schema={
"application/json": {"$ref": "#/components/schemas/PullRequest"}
},
)
}
schema_definitions = {
"PullRequest": {
"type": "object",
"properties": {
"title": {"type": "string"},
"author": {"$ref": "#/components/schemas/User"},
},
},
"User": {
"type": "object",
"properties": {
"name": {"type": "string"},
"pull_requests": {
"type": "array",
"items": {"$ref": "#/components/schemas/PullRequest"},
},
},
},
}
output_schema = extract_output_schema_from_responses(
responses, schema_definitions=schema_definitions, openapi_version="3.0.0"
)
assert output_schema is not None
from mcp.types import Tool as MCPTool
tool = MCPTool(
name="get_pr",
description="Get a pull request",
inputSchema={"type": "object", "properties": {}},
outputSchema=output_schema,
)
tool.model_dump(by_alias=True, mode="json", exclude_none=True)
async def test_multiple_tools_sharing_circular_schemas(self):
"""Multiple tools from the same spec must not share Python dict objects
in their schemas, which would cause Pydantic to raise circular reference
errors when serializing the list_tools response."""
spec = {
"openapi": "3.0.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {
"/nodes": {
"get": {
"operationId": "list_nodes",
"responses": {
"200": {
"description": "List of nodes",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Node"
},
}
}
},
}
},
},
"post": {
"operationId": "create_node",
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Node"}
}
},
},
"responses": {
"201": {
"description": "Created node",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Node"}
}
},
}
},
},
},
"/nodes/{id}": {
"get": {
"operationId": "get_node",
"parameters": [
{
"name": "id",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"responses": {
"200": {
"description": "A node",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Node"}
}
},
}
},
},
},
},
"components": {
"schemas": {
"Node": {
"type": "object",
"properties": {
"value": {"type": "string"},
"children": {
"type": "array",
"items": {"$ref": "#/components/schemas/Node"},
},
},
}
}
},
}
server = FastMCP.from_openapi(spec, httpx.AsyncClient())
tools = await server.list_tools()
assert len(tools) >= 3
# Simulate what the MCP SDK does: serialize all tools together.
# This is the exact crash path — model_dump on a list of tools
# whose schemas share Python dict objects.
mcp_tools = [tool.to_mcp_tool(name=tool.name) for tool in tools]
for mcp_tool in mcp_tools:
mcp_tool.model_dump(by_alias=True, mode="json", exclude_none=True)