diff --git a/docs/python-sdk/fastmcp-utilities-json_schema.mdx b/docs/python-sdk/fastmcp-utilities-json_schema.mdx
index 1cdc53332..e08643cb9 100644
--- a/docs/python-sdk/fastmcp-utilities-json_schema.mdx
+++ b/docs/python-sdk/fastmcp-utilities-json_schema.mdx
@@ -7,7 +7,7 @@ sidebarTitle: json_schema
## Functions
-### `dereference_refs`
+### `dereference_refs`
```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`
+### `resolve_root_ref`
```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`
+### `compress_schema`
```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]
diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py
index da713f802..fc0a0d759 100644
--- a/src/fastmcp/utilities/json_schema.py
+++ b/src/fastmcp/utilities/json_schema.py
@@ -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)
diff --git a/src/fastmcp/utilities/openapi/schemas.py b/src/fastmcp/utilities/openapi/schemas.py
index 5142f12a5..bb057f2eb 100644
--- a/src/fastmcp/utilities/openapi/schemas.py
+++ b/src/fastmcp/utilities/openapi/schemas.py
@@ -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(
diff --git a/tests/utilities/openapi/test_transitive_references.py b/tests/utilities/openapi/test_transitive_references.py
index fbb15f597..01cf189eb 100644
--- a/tests/utilities/openapi/test_transitive_references.py
+++ b/tests/utilities/openapi/test_transitive_references.py
@@ -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)