Skip to content

Commit e8f910e

Browse files
shalarn1maddymanu
andauthored
Fix: resolve complex OpenAPI reference chains with inline definitions (#8)
* wip * clean up code * tests * use existing swagger doc * Update comments to explain approach * add todo * clean up client * linter --------- Co-authored-by: Aditya Bansal <[email protected]>
1 parent a9fa9af commit e8f910e

File tree

5 files changed

+438
-1
lines changed

5 files changed

+438
-1
lines changed

server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
from src.server import main
55

66
if __name__ == "__main__":
7-
main()
7+
main()

src/components/customizers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ def customize_components(
5151
# Hide this fully for now.
5252
component.output_schema = None
5353

54+
# SCHEMA REFERENCE HANDLING:
55+
# OpenAPI-generated MCP tool schemas contain $defs with nested $ref references
56+
# (e.g., $ref -> $defs -> $ref chains).
57+
# FastMCP cannot resolve these complex reference chains.
58+
#
59+
# The following code strips $defs from MCP tool input/output schemas generated by OpenAPI docs.
60+
# However, stripping $defs still resulted in "PointerToNowhere" errors due to input schemas
61+
# containing nested $ref references failing to resolve.
62+
#
63+
# Openapi_resolver script replaces ALL $ref with inline schema definitions, and eliminates the need for $defs entirely.
64+
# Keep this code to prevent any remaining $defs from breaking MCP clients.
65+
#
5466
if hasattr(component, 'parameters') and isinstance(component.parameters, dict):
5567
if "$defs" in component.parameters:
5668
logger.debug(f" Found $defs with {len(component.parameters['$defs'])} definitions")

src/server.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .config import Config
1010
from .routes.mappers import custom_route_mapper
1111
from .utils.logging import setup_logging
12+
from .utils.openapi_resolver import resolve_refs
1213

1314
logger = setup_logging()
1415

@@ -38,6 +39,11 @@ def create_mcp_server() -> FastMCP:
3839

3940
openapi_spec = load_openapi_spec()
4041

42+
# Workaround to resolve all $ref references since FastMCP cannot resolve complex reference chains
43+
logger.info("Resolving OpenAPI $ref references...")
44+
openapi_spec = resolve_refs(openapi_spec)
45+
logger.info("OpenAPI $ref references resolved")
46+
4147
client = create_cortex_client()
4248

4349
mcp_server = FastMCP.from_openapi(

src/utils/openapi_resolver.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""
2+
OpenAPI $ref resolver for FastMCP compatibility.
3+
4+
FastMCP cannot resolve complex reference chains (e.g., $ref -> $defs -> $ref chains).
5+
This script replaces ALL $ref in the entire OpenAPI spec with inline schema definitions.
6+
7+
Since customizers.py currently hides output schemas, $ref resolutions are only visible in INPUT schemas.
8+
9+
Performance note: Currently processes all ~800 endpoints but only ~20 become MCP tools.
10+
The dereferencing work is only visible in PointInTimeMetrics tool, since it's the only
11+
MCP-enabled endpoint with $ref chains in its input schema (output schemas are hidden).
12+
13+
TODO: Optimize to only process MCP-enabled paths
14+
"""
15+
16+
from typing import Any
17+
18+
19+
def resolve_refs(spec: dict[str, Any]) -> dict[str, Any]:
20+
"""
21+
Recursively resolve all $ref references in an OpenAPI specification.
22+
23+
This is a workaround for FastMCP's issue with $ref handling where it
24+
doesn't properly include schema definitions when creating tool input schemas.
25+
26+
Args:
27+
spec: OpenAPI specification dictionary
28+
29+
Returns:
30+
Modified spec with all $refs resolved inline
31+
"""
32+
# Create a copy to avoid modifying the original
33+
spec = spec.copy()
34+
35+
# Get the components/schemas section for reference resolution
36+
schemas = spec.get("components", {}).get("schemas", {})
37+
38+
def resolve_schema(obj: Any, visited: set[str] | None = None) -> Any:
39+
"""Recursively resolve $ref in an object."""
40+
if visited is None:
41+
visited = set()
42+
43+
if isinstance(obj, dict):
44+
# Check if this is a $ref
45+
if "$ref" in obj and len(obj) == 1:
46+
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
51+
return obj
52+
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
67+
else:
68+
# Recursively process all values in the dict
69+
result = {}
70+
for key, value in obj.items():
71+
result[key] = resolve_schema(value, visited)
72+
return result
73+
74+
elif isinstance(obj, list):
75+
# Recursively process all items in the list
76+
return [resolve_schema(item, visited) for item in obj]
77+
else:
78+
# Return primitive values as-is
79+
return obj
80+
81+
# Resolve refs in all paths
82+
if "paths" in spec:
83+
spec["paths"] = resolve_schema(spec["paths"])
84+
85+
return spec
86+
87+
88+
# Use if context becomes too large for inline definitions
89+
def resolve_refs_with_defs(spec: dict[str, Any]) -> dict[str, Any]:
90+
"""
91+
Alternative approach: Keep $refs but ensure $defs section is populated.
92+
93+
This transforms OpenAPI $refs to JSON Schema format and includes
94+
all referenced schemas in a $defs section at the root level.
95+
96+
Args:
97+
spec: OpenAPI specification dictionary
98+
99+
Returns:
100+
Modified spec with $refs pointing to $defs and all definitions included
101+
"""
102+
# Create a copy to avoid modifying the original
103+
spec = spec.copy()
104+
105+
# Get the components/schemas section
106+
schemas = spec.get("components", {}).get("schemas", {})
107+
108+
# Create $defs section at root level
109+
if schemas:
110+
spec["$defs"] = schemas.copy()
111+
112+
def transform_refs(obj: Any) -> Any:
113+
"""Transform OpenAPI $refs to JSON Schema $refs."""
114+
if isinstance(obj, dict):
115+
result = {}
116+
for key, value in obj.items():
117+
if key == "$ref" and isinstance(value, str):
118+
# Transform the reference format
119+
if value.startswith("#/components/schemas/"):
120+
schema_name = value.split("/")[-1]
121+
result[key] = f"#/$defs/{schema_name}"
122+
else:
123+
result[key] = value
124+
else:
125+
result[key] = transform_refs(value)
126+
return result
127+
elif isinstance(obj, list):
128+
return [transform_refs(item) for item in obj]
129+
else:
130+
return obj
131+
132+
# Transform all refs in paths
133+
if "paths" in spec:
134+
spec["paths"] = transform_refs(spec["paths"])
135+
136+
return spec

0 commit comments

Comments
 (0)