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)