Skip to content

Commit 180d80f

Browse files
patelchaitanyntkathole
authored andcommitted
fix: patch fastapi_mcp circular recursion that blocks MCP endpoint registration
fastapi_mcp 0.4.0 resolve_schema_references() has no cycle detection. Feast's OpenAPI schema contains self-referential protobuf types (Value -> Struct -> Value) which trigger a RecursionError. The error is silently caught, so the /mcp route never gets registered and CI gets a 404. Add _resolve_schema_references_safe() that tracks a seen-refs set to break circular chains, and monkey-patch it into fastapi_mcp before FastApiMCP processes the schema. Non-circular schemas produce identical output to the original. Signed-off-by: Chaitany patel <patelchaitany93@gmail.com>
1 parent 2e409d9 commit 180d80f

2 files changed

Lines changed: 200 additions & 1 deletion

File tree

sdk/python/feast/infra/mcp_servers/mcp_server.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""
77

88
import logging
9-
from typing import Optional
9+
from typing import Any, Dict, Optional, Set
1010

1111
from feast.feature_store import FeatureStore
1212

@@ -30,13 +30,82 @@ class McpTransportNotSupportedError(RuntimeError):
3030
pass
3131

3232

33+
def _resolve_schema_references_safe(
34+
schema_part: Dict[str, Any],
35+
reference_schema: Dict[str, Any],
36+
_seen_refs: Optional[Set[str]] = None,
37+
) -> Dict[str, Any]:
38+
"""Resolve ``$ref`` pointers in an OpenAPI schema **without** infinite recursion.
39+
40+
``fastapi_mcp`` <=0.4.0 ships a ``resolve_schema_references`` helper that
41+
inlines every ``$ref`` it encounters, but never tracks which refs have
42+
already been visited. Feast's OpenAPI schema contains self-referential
43+
types (protobuf ``Value`` -> ``Struct`` -> ``Value``), which causes a
44+
``RecursionError``.
45+
46+
This replacement keeps a *seen-refs* set and replaces any circular
47+
``$ref`` with an empty object schema instead of recursing forever.
48+
49+
Reference: ``fastapi_mcp/openapi/utils.py`` lines 19-55
50+
https://github.com/tadata-org/fastapi_mcp/blob/main/fastapi_mcp/openapi/utils.py#L19-L55
51+
"""
52+
if _seen_refs is None:
53+
_seen_refs = set()
54+
55+
schema_part = schema_part.copy()
56+
57+
if "$ref" in schema_part:
58+
ref_path = schema_part["$ref"]
59+
if ref_path in _seen_refs:
60+
schema_part.pop("$ref")
61+
schema_part.setdefault("type", "object")
62+
return schema_part
63+
if ref_path.startswith("#/components/schemas/"):
64+
model_name = ref_path.split("/")[-1]
65+
schemas = reference_schema.get("components", {}).get("schemas", {})
66+
if model_name in schemas:
67+
_seen_refs = _seen_refs | {ref_path}
68+
ref_schema = schemas[model_name].copy()
69+
schema_part.pop("$ref")
70+
schema_part.update(ref_schema)
71+
72+
for key, value in schema_part.items():
73+
if isinstance(value, dict):
74+
schema_part[key] = _resolve_schema_references_safe(
75+
value, reference_schema, _seen_refs
76+
)
77+
elif isinstance(value, list):
78+
schema_part[key] = [
79+
_resolve_schema_references_safe(item, reference_schema, _seen_refs)
80+
if isinstance(item, dict)
81+
else item
82+
for item in value
83+
]
84+
85+
return schema_part
86+
87+
88+
def _patch_fastapi_mcp_schema_resolver() -> None:
89+
"""Monkey-patch ``fastapi_mcp.openapi.utils.resolve_schema_references``
90+
with our circular-ref-safe version so that ``FastApiMCP`` can process
91+
Feast's OpenAPI schema without hitting a ``RecursionError``."""
92+
try:
93+
import fastapi_mcp.openapi.utils as _mcp_utils
94+
95+
_mcp_utils.resolve_schema_references = _resolve_schema_references_safe # type: ignore[assignment]
96+
except (ImportError, AttributeError):
97+
pass
98+
99+
33100
def add_mcp_support_to_app(app, store: FeatureStore, config) -> Optional["FastApiMCP"]:
34101
"""Add MCP support to the FastAPI app if enabled in configuration."""
35102
if not MCP_AVAILABLE:
36103
logger.warning("MCP support requested but fastapi_mcp is not available")
37104
return None
38105

39106
try:
107+
_patch_fastapi_mcp_schema_resolver()
108+
40109
# Create MCP server from the FastAPI app
41110
mcp = FastApiMCP(
42111
app,

sdk/python/tests/unit/infra/feature_servers/test_mcp_server.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,136 @@
66

77
from feast.feature_store import FeatureStore
88
from feast.infra.mcp_servers.mcp_config import McpFeatureServerConfig
9+
from feast.infra.mcp_servers.mcp_server import _resolve_schema_references_safe
10+
11+
12+
class TestResolveSchemaReferencesSafe(unittest.TestCase):
13+
"""Tests for the circular-ref-safe OpenAPI schema resolver."""
14+
15+
def test_simple_ref_resolution(self):
16+
reference_schema = {
17+
"components": {
18+
"schemas": {
19+
"Pet": {
20+
"type": "object",
21+
"properties": {"name": {"type": "string"}},
22+
}
23+
}
24+
}
25+
}
26+
schema_part = {"$ref": "#/components/schemas/Pet"}
27+
result = _resolve_schema_references_safe(schema_part, reference_schema)
28+
self.assertEqual(result["type"], "object")
29+
self.assertNotIn("$ref", result)
30+
self.assertIn("name", result["properties"])
31+
32+
def test_circular_ref_breaks_cycle(self):
33+
"""Value -> Struct -> Value must not recurse infinitely."""
34+
reference_schema = {
35+
"components": {
36+
"schemas": {
37+
"Value": {
38+
"type": "object",
39+
"properties": {
40+
"struct_value": {"$ref": "#/components/schemas/Struct"},
41+
},
42+
},
43+
"Struct": {
44+
"type": "object",
45+
"properties": {
46+
"fields": {
47+
"type": "object",
48+
"additionalProperties": {
49+
"$ref": "#/components/schemas/Value"
50+
},
51+
}
52+
},
53+
},
54+
}
55+
}
56+
}
57+
schema_part = {"$ref": "#/components/schemas/Value"}
58+
result = _resolve_schema_references_safe(schema_part, reference_schema)
59+
self.assertEqual(result["type"], "object")
60+
self.assertNotIn("$ref", result)
61+
# The circular Value ref should be replaced with {"type": "object"}
62+
additional = result["properties"]["struct_value"]["properties"]["fields"][
63+
"additionalProperties"
64+
]
65+
self.assertEqual(additional.get("type"), "object")
66+
self.assertNotIn("$ref", additional)
67+
68+
def test_self_referential_schema(self):
69+
"""A schema referencing itself (e.g. a tree node)."""
70+
reference_schema = {
71+
"components": {
72+
"schemas": {
73+
"TreeNode": {
74+
"type": "object",
75+
"properties": {
76+
"children": {
77+
"type": "array",
78+
"items": {"$ref": "#/components/schemas/TreeNode"},
79+
}
80+
},
81+
}
82+
}
83+
}
84+
}
85+
schema_part = {"$ref": "#/components/schemas/TreeNode"}
86+
result = _resolve_schema_references_safe(schema_part, reference_schema)
87+
self.assertEqual(result["type"], "object")
88+
child_items = result["properties"]["children"]["items"]
89+
self.assertEqual(child_items.get("type"), "object")
90+
self.assertNotIn("$ref", child_items)
91+
92+
def test_no_ref_passthrough(self):
93+
schema_part = {"type": "string", "description": "a name"}
94+
result = _resolve_schema_references_safe(schema_part, {})
95+
self.assertEqual(result, schema_part)
96+
97+
def test_unknown_ref_preserved(self):
98+
schema_part = {"$ref": "#/components/schemas/Missing"}
99+
reference_schema = {"components": {"schemas": {}}}
100+
result = _resolve_schema_references_safe(schema_part, reference_schema)
101+
self.assertIn("$ref", result)
102+
103+
def test_does_not_mutate_input(self):
104+
reference_schema = {
105+
"components": {
106+
"schemas": {
107+
"Foo": {"type": "object"},
108+
}
109+
}
110+
}
111+
schema_part = {"$ref": "#/components/schemas/Foo"}
112+
original_copy = schema_part.copy()
113+
_resolve_schema_references_safe(schema_part, reference_schema)
114+
self.assertEqual(schema_part, original_copy)
115+
116+
117+
class TestPatchFastapiMcpSchemaResolver(unittest.TestCase):
118+
"""Tests for the monkey-patch helper."""
119+
120+
@patch("feast.infra.mcp_servers.mcp_server.MCP_AVAILABLE", True)
121+
def test_patch_replaces_function(self):
122+
from feast.infra.mcp_servers.mcp_server import (
123+
_patch_fastapi_mcp_schema_resolver,
124+
_resolve_schema_references_safe,
125+
)
126+
127+
try:
128+
import fastapi_mcp.openapi.utils as mcp_utils
129+
130+
original = mcp_utils.resolve_schema_references
131+
_patch_fastapi_mcp_schema_resolver()
132+
self.assertIs(
133+
mcp_utils.resolve_schema_references,
134+
_resolve_schema_references_safe,
135+
)
136+
mcp_utils.resolve_schema_references = original
137+
except ImportError:
138+
self.skipTest("fastapi_mcp not installed")
9139

10140

11141
class TestMcpFeatureServerConfig(unittest.TestCase):

0 commit comments

Comments
 (0)