From 8a4aaba9370761d02ea7d186ef11b461fd02fc28 Mon Sep 17 00:00:00 2001 From: Arkadiusz Szarama Date: Wed, 23 Sep 2026 14:47:52 +0200 Subject: [PATCH] Cut cyclic $refs instead of leaving them dangling resolve_refs left a $ref in place when it hit a cycle. FastMCP moves the schema into a tool's $defs, where that $ref points nowhere, so every call to the tool failed argument validation with PointerToNowhere. springdoc emits every polymorphic type as such a cycle (the parent lists subtypes in oneOf, each subtype extends the parent through allOf), so this broke any tool whose input includes one, such as createOrUpdateWorkflow and runWorkflow. Re-expand a cyclic ref once without its oneOf/anyOf/discriminator, so a subtype keeps the fields it inherits, and replace any deeper repeat with a {"type": ...} placeholder. --- src/utils/openapi_resolver.py | 55 ++++++++++++++++++---------- tests/test_openapi_resolver.py | 67 ++++++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 23 deletions(-) diff --git a/src/utils/openapi_resolver.py b/src/utils/openapi_resolver.py index f213536..2a396c5 100644 --- a/src/utils/openapi_resolver.py +++ b/src/utils/openapi_resolver.py @@ -35,35 +35,35 @@ def resolve_refs(spec: dict[str, Any]) -> dict[str, Any]: # Get the components/schemas section for reference resolution schemas = spec.get("components", {}).get("schemas", {}) - def resolve_schema(obj: Any, visited: set[str] | None = None) -> Any: + def resolve_schema(obj: Any, visited: dict[str, int] | None = None) -> Any: """Recursively resolve $ref in an object.""" if visited is None: - visited = set() + visited = {} if isinstance(obj, dict): # Check if this is a $ref if "$ref" in obj and len(obj) == 1: ref_path = obj["$ref"] - - # Prevent infinite recursion - if ref_path in visited: - # Return the ref as-is to avoid infinite loop + schema_name = ref_path.removeprefix("#/components/schemas/") + if schema_name == ref_path or schema_name not in schemas: return obj - visited.add(ref_path) - - # Extract schema name from reference - if ref_path.startswith("#/components/schemas/"): - schema_name = ref_path.split("/")[-1] - if schema_name in schemas: - # Recursively resolve the referenced schema - resolved = resolve_schema(schemas[schema_name].copy(), visited) - visited.remove(ref_path) - return resolved - - # If we can't resolve, return as-is - visited.remove(ref_path) - return obj + # A $ref left in place would dangle once FastMCP moves the schema into a + # tool's $defs, so a cycle is cut rather than preserved. springdoc emits + # every polymorphic type as a cycle: the parent lists its subtypes in + # oneOf, and each subtype extends the parent through allOf. Re-expanding + # the parent once without its subtypes keeps the fields they inherit. + depth = visited.get(ref_path, 0) + if depth >= 2: + return _cycle_placeholder(schemas[schema_name]) + target = schemas[schema_name] + if depth == 1: + target = _without_subtypes(target) + + visited[ref_path] = depth + 1 + resolved = resolve_schema(target, visited) + visited[ref_path] = depth + return resolved else: # Recursively process all values in the dict result = {} @@ -85,6 +85,21 @@ def resolve_schema(obj: Any, visited: set[str] | None = None) -> Any: return spec +def _without_subtypes(schema: dict[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in schema.items() + if key not in ("oneOf", "anyOf", "discriminator") + } + + +def _cycle_placeholder(schema: dict[str, Any]) -> dict[str, Any]: + placeholder: dict[str, Any] = {"type": schema.get("type", "object")} + if "description" in schema: + placeholder["description"] = schema["description"] + return placeholder + + # Use if context becomes too large for inline definitions def resolve_refs_with_defs(spec: dict[str, Any]) -> dict[str, Any]: """ diff --git a/tests/test_openapi_resolver.py b/tests/test_openapi_resolver.py index f0873d8..c8bb467 100644 --- a/tests/test_openapi_resolver.py +++ b/tests/test_openapi_resolver.py @@ -122,12 +122,73 @@ def test_resolve_circular_refs(self): # Should not raise an exception and should handle circular refs resolved = resolve_refs(spec) - # Should have resolved the top-level ref but left circular ref intact schema = resolved["paths"]["/api/test"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] assert schema["type"] == "object" assert "value" in schema["properties"] - # Circular ref should be preserved to prevent infinite recursion - assert schema["properties"]["parent"]["$ref"] == "#/components/schemas/Node" + # The cycle is expanded once more, then cut with a placeholder instead of a $ref, + # which FastMCP would turn into a dangling #/$defs pointer + parent = schema["properties"]["parent"] + assert "value" in parent["properties"] + assert parent["properties"]["parent"] == {"type": "object"} + assert "$ref" not in json.dumps(schema) + + def test_resolve_polymorphic_refs(self): + """Test the parent/subtype cycle springdoc emits for polymorphic types.""" + spec = { + "paths": { + "/api/test": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Action"} + } + } + } + } + } + }, + "components": { + "schemas": { + "Action": { + "required": ["type"], + "type": "object", + "properties": {"type": {"type": "string"}}, + "discriminator": {"propertyName": "type"}, + "oneOf": [ + {"$ref": "#/components/schemas/Action.JQ"}, + {"$ref": "#/components/schemas/Action.HTTP"}, + ], + }, + "Action.JQ": { + "type": "object", + "allOf": [ + {"$ref": "#/components/schemas/Action"}, + {"type": "object", "properties": {"expression": {"type": "string"}}}, + ], + }, + "Action.HTTP": { + "type": "object", + "allOf": [ + {"$ref": "#/components/schemas/Action"}, + {"type": "object", "properties": {"url": {"type": "string"}}}, + ], + }, + } + }, + } + + resolved = resolve_refs(spec) + + schema = resolved["paths"]["/api/test"]["post"]["requestBody"]["content"]["application/json"]["schema"] + assert "$ref" not in json.dumps(schema) + jq_parent, jq_own = schema["oneOf"][0]["allOf"] + # The subtype keeps the fields it inherits, but not the parent's list of subtypes + assert jq_parent["properties"]["type"] == {"type": "string"} + assert jq_parent["required"] == ["type"] + assert "oneOf" not in jq_parent + assert "discriminator" not in jq_parent + assert jq_own["properties"]["expression"] == {"type": "string"} def test_resolve_refs_in_arrays(self): """Test resolving $refs inside arrays."""