The Bug
DereferenceRefsMiddleware in FastMCP crashes when schemas contain circular $ref using JSON Pointer style — paths like #/properties/nodes/items rather than #/$defs/Node. This is the format emitted by C# / .NET MCP servers using System.Text.Json’s schema generator.
The crash isn’t a RecursionError in the Python call stack (though the issue title says so). It’s a semantic recursion: jsonref.replace_refs turns the circular reference into a self-referential Python dict. When Pydantic later tries to serialize that dict, it fails with “Circular reference detected.”
The Analysis
The existing code had a cycle detector:
def _defs_have_cycles(defs: dict[str, Any]) -> bool:
...
This only looks inside $defs. It catches the classic Pydantic pattern:
{
"$defs": {"Node": {"properties": {"children": {"items": {"$ref": "#/$defs/Node"}}}}},
"$ref": "#/$defs/Node"
}
But it doesn’t catch JSON Pointer style:
{
"properties": {
"nodes": {
"type": "array",
"items": {
"properties": {
"children": {
"type": "array",
"items": {"$ref": "#/properties/nodes/items"}
}
}
}
}
}
}
Here, the $ref points into the schema tree itself, not into $defs. jsonref resolves it by creating a dict that contains itself. The resulting object has id(child) == id(parent) at some level — a Python-level cycle, not a JSON-level one.
The Fix
The fix is embarrassingly simple once you know where to look: after replace_refs, check if the resulting object contains a circular reference by tracking object ids:
def _has_circular_refs(obj: Any, seen: set[int] | None = None) -> bool:
if seen is None:
seen = set()
obj_id = id(obj)
if obj_id in seen:
return True
if isinstance(obj, dict):
seen.add(obj_id)
for v in obj.values():
if _has_circular_refs(v, seen):
return True
seen.remove(obj_id)
elif isinstance(obj, list):
seen.add(obj_id)
for item in obj:
if _has_circular_refs(item, seen):
return True
seen.remove(obj_id)
return False
If a cycle is found, fall back to resolve_root_ref, which preserves the original $ref instead of inlining it. The schema remains valid and serializable; it just isn’t fully dereferenced.
Why This Matters
The MCP ecosystem is polyglot. Python servers (Pydantic) emit $defs-based circular refs. C# servers (System.Text.Json) emit JSON Pointer style. A middleware that assumes all circular refs look the same will silently break half the ecosystem.
This is a recurring pattern in interoperability work: the bug isn’t in your code, it’s in the assumption that everyone else’s code follows the same conventions. The fix isn’t algorithmically complex — it’s adding a guard for a case you didn’t know existed.