Skip to content

Commit 7349e08

Browse files
authored
Cut cyclic $refs instead of leaving them dangling (#14)
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.
1 parent 94997e0 commit 7349e08

2 files changed

Lines changed: 99 additions & 23 deletions

File tree

‎src/utils/openapi_resolver.py‎

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,35 +35,35 @@ def resolve_refs(spec: dict[str, Any]) -> dict[str, Any]:
3535
# Get the components/schemas section for reference resolution
3636
schemas = spec.get("components", {}).get("schemas", {})
3737

38-
def resolve_schema(obj: Any, visited: set[str] | None = None) -> Any:
38+
def resolve_schema(obj: Any, visited: dict[str, int] | None = None) -> Any:
3939
"""Recursively resolve $ref in an object."""
4040
if visited is None:
41-
visited = set()
41+
visited = {}
4242

4343
if isinstance(obj, dict):
4444
# Check if this is a $ref
4545
if "$ref" in obj and len(obj) == 1:
4646
ref_path = obj["$ref"]
47-
48-
# Prevent infinite recursion
49-
if ref_path in visited:
50-
# Return the ref as-is to avoid infinite loop
47+
schema_name = ref_path.removeprefix("#/components/schemas/")
48+
if schema_name == ref_path or schema_name not in schemas:
5149
return obj
5250

53-
visited.add(ref_path)
54-
55-
# Extract schema name from reference
56-
if ref_path.startswith("#/components/schemas/"):
57-
schema_name = ref_path.split("/")[-1]
58-
if schema_name in schemas:
59-
# Recursively resolve the referenced schema
60-
resolved = resolve_schema(schemas[schema_name].copy(), visited)
61-
visited.remove(ref_path)
62-
return resolved
63-
64-
# If we can't resolve, return as-is
65-
visited.remove(ref_path)
66-
return obj
51+
# A $ref left in place would dangle once FastMCP moves the schema into a
52+
# tool's $defs, so a cycle is cut rather than preserved. springdoc emits
53+
# every polymorphic type as a cycle: the parent lists its subtypes in
54+
# oneOf, and each subtype extends the parent through allOf. Re-expanding
55+
# the parent once without its subtypes keeps the fields they inherit.
56+
depth = visited.get(ref_path, 0)
57+
if depth >= 2:
58+
return _cycle_placeholder(schemas[schema_name])
59+
target = schemas[schema_name]
60+
if depth == 1:
61+
target = _without_subtypes(target)
62+
63+
visited[ref_path] = depth + 1
64+
resolved = resolve_schema(target, visited)
65+
visited[ref_path] = depth
66+
return resolved
6767
else:
6868
# Recursively process all values in the dict
6969
result = {}
@@ -85,6 +85,21 @@ def resolve_schema(obj: Any, visited: set[str] | None = None) -> Any:
8585
return spec
8686

8787

88+
def _without_subtypes(schema: dict[str, Any]) -> dict[str, Any]:
89+
return {
90+
key: value
91+
for key, value in schema.items()
92+
if key not in ("oneOf", "anyOf", "discriminator")
93+
}
94+
95+
96+
def _cycle_placeholder(schema: dict[str, Any]) -> dict[str, Any]:
97+
placeholder: dict[str, Any] = {"type": schema.get("type", "object")}
98+
if "description" in schema:
99+
placeholder["description"] = schema["description"]
100+
return placeholder
101+
102+
88103
# Use if context becomes too large for inline definitions
89104
def resolve_refs_with_defs(spec: dict[str, Any]) -> dict[str, Any]:
90105
"""

‎tests/test_openapi_resolver.py‎

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,73 @@ def test_resolve_circular_refs(self):
122122
# Should not raise an exception and should handle circular refs
123123
resolved = resolve_refs(spec)
124124

125-
# Should have resolved the top-level ref but left circular ref intact
126125
schema = resolved["paths"]["/api/test"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
127126
assert schema["type"] == "object"
128127
assert "value" in schema["properties"]
129-
# Circular ref should be preserved to prevent infinite recursion
130-
assert schema["properties"]["parent"]["$ref"] == "#/components/schemas/Node"
128+
# The cycle is expanded once more, then cut with a placeholder instead of a $ref,
129+
# which FastMCP would turn into a dangling #/$defs pointer
130+
parent = schema["properties"]["parent"]
131+
assert "value" in parent["properties"]
132+
assert parent["properties"]["parent"] == {"type": "object"}
133+
assert "$ref" not in json.dumps(schema)
134+
135+
def test_resolve_polymorphic_refs(self):
136+
"""Test the parent/subtype cycle springdoc emits for polymorphic types."""
137+
spec = {
138+
"paths": {
139+
"/api/test": {
140+
"post": {
141+
"requestBody": {
142+
"content": {
143+
"application/json": {
144+
"schema": {"$ref": "#/components/schemas/Action"}
145+
}
146+
}
147+
}
148+
}
149+
}
150+
},
151+
"components": {
152+
"schemas": {
153+
"Action": {
154+
"required": ["type"],
155+
"type": "object",
156+
"properties": {"type": {"type": "string"}},
157+
"discriminator": {"propertyName": "type"},
158+
"oneOf": [
159+
{"$ref": "#/components/schemas/Action.JQ"},
160+
{"$ref": "#/components/schemas/Action.HTTP"},
161+
],
162+
},
163+
"Action.JQ": {
164+
"type": "object",
165+
"allOf": [
166+
{"$ref": "#/components/schemas/Action"},
167+
{"type": "object", "properties": {"expression": {"type": "string"}}},
168+
],
169+
},
170+
"Action.HTTP": {
171+
"type": "object",
172+
"allOf": [
173+
{"$ref": "#/components/schemas/Action"},
174+
{"type": "object", "properties": {"url": {"type": "string"}}},
175+
],
176+
},
177+
}
178+
},
179+
}
180+
181+
resolved = resolve_refs(spec)
182+
183+
schema = resolved["paths"]["/api/test"]["post"]["requestBody"]["content"]["application/json"]["schema"]
184+
assert "$ref" not in json.dumps(schema)
185+
jq_parent, jq_own = schema["oneOf"][0]["allOf"]
186+
# The subtype keeps the fields it inherits, but not the parent's list of subtypes
187+
assert jq_parent["properties"]["type"] == {"type": "string"}
188+
assert jq_parent["required"] == ["type"]
189+
assert "oneOf" not in jq_parent
190+
assert "discriminator" not in jq_parent
191+
assert jq_own["properties"]["expression"] == {"type": "string"}
131192

132193
def test_resolve_refs_in_arrays(self):
133194
"""Test resolving $refs inside arrays."""

0 commit comments

Comments
 (0)