Skip to content

Commit 75978f6

Browse files
committed
chore: add schema dereference middleware
1 parent f803782 commit 75978f6

File tree

4 files changed

+562
-0
lines changed

4 files changed

+562
-0
lines changed

docs/servers/middleware.mdx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,35 @@ mcp.add_middleware(StructuredLoggingMiddleware(include_payloads=True))
484484

485485
The built-in versions include payload logging, structured JSON output, custom logger support, payload size limits, and operation-specific hooks for granular control.
486486

487+
### Schema Dereference Middleware
488+
489+
Some MCP clients have limited support for JSON Schema `$ref` references in tool and resource template schemas. This can lead to null parameters or validation failures when a client cannot resolve references during tool invocation.
490+
491+
<Info>
492+
The schema dereference middleware flattens internal `$ref` references (those pointing to `#/$defs/...`) in listing responses, so clients receive inline schemas that are easier to consume.
493+
</Info>
494+
495+
```python
496+
from fastmcp import FastMCP
497+
from fastmcp.server.middleware.schema_dereference import SchemaDereferenceMiddleware
498+
499+
mcp = FastMCP("MyServer")
500+
mcp.add_middleware(SchemaDereferenceMiddleware())
501+
```
502+
503+
What it does
504+
- Inlines internal `$ref` to `#/$defs/...` for:
505+
- on_list_tools: `Tool.parameters`
506+
- on_list_resource_templates: `ResourceTemplate.parameters`
507+
- Handles common schema shapes:
508+
- Properties, arrays (`items`), maps (`additionalProperties`)
509+
- Composition keywords (`allOf`, `oneOf`, `anyOf`)
510+
- Transitive and reused definitions
511+
512+
<Tip>
513+
Enable this middleware when serving tools or templates to clients that cannot resolve JSON Schema `$ref` internally. It is safe to use with clients that fully support `$ref` as well.
514+
</Tip>
515+
487516
### Rate Limiting Middleware
488517

489518
Rate limiting is essential for protecting your server from abuse, ensuring fair resource usage, and maintaining performance under load. FastMCP includes sophisticated rate limiting middleware at `fastmcp.server.middleware.rate_limiting`.
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
from copy import deepcopy
2+
from typing import Any
3+
4+
import mcp.types as mt
5+
6+
from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
7+
8+
9+
def _detect_self_reference(schema: dict) -> bool:
10+
"""
11+
Detect if the schema contains self-referencing definitions.
12+
Args:
13+
schema: The JSON schema to check
14+
Returns:
15+
True if self-referencing is detected
16+
"""
17+
defs = schema.get("$defs", {})
18+
19+
def find_refs_in_value(value: Any, parent_def: str) -> bool:
20+
"""Check if a value contains a reference to its parent definition."""
21+
if isinstance(value, dict):
22+
if "$ref" in value:
23+
ref_path = value["$ref"]
24+
# Check if this references the parent definition
25+
if ref_path == f"#/$defs/{parent_def}":
26+
return True
27+
# Check all values in the dict
28+
for v in value.values():
29+
if find_refs_in_value(v, parent_def):
30+
return True
31+
elif isinstance(value, list):
32+
# Check all items in the list
33+
for item in value:
34+
if find_refs_in_value(item, parent_def):
35+
return True
36+
return False
37+
38+
# Check each definition for self-reference
39+
for def_name, def_content in defs.items():
40+
if find_refs_in_value(def_content, def_name):
41+
# Self-reference detected, return original schema
42+
return True
43+
44+
return False
45+
46+
47+
def dereference_json_schema(schema: dict) -> dict:
48+
"""
49+
Dereference a JSON schema by resolving $ref references while preserving $defs only when corner cases occur.
50+
This function flattens schema properties by:
51+
1. Check for self-reference - if found, return original schema with $defs
52+
2. When encountering $refs in properties, resolve them on-demand
53+
3. Track visited definitions globally to prevent circular expansion
54+
4. Only preserve original $defs if corner cases are encountered:
55+
- Self-reference detected
56+
- Circular references between definitions
57+
- Reference not found in $defs
58+
Args:
59+
schema: The JSON schema to flatten
60+
Returns:
61+
Schema with references resolved in properties, keeping $defs only when corner cases occur
62+
"""
63+
# Step 1: Check for self-reference
64+
if _detect_self_reference(schema):
65+
# Self-referencing detected, return original schema with $defs
66+
return schema
67+
68+
# Make a deep copy to work with
69+
result = deepcopy(schema)
70+
71+
# Keep original $defs for potential corner cases
72+
defs = deepcopy(schema.get("$defs", {}))
73+
74+
# Track corner cases that require preserving $defs
75+
corner_cases_detected = {
76+
"circular_ref": False,
77+
"ref_not_found": False,
78+
}
79+
80+
# Step 2: Define resolution function that tracks visits globally and corner cases
81+
def resolve_refs_in_value(value: Any, depth: int, visiting: set[str]) -> Any:
82+
"""
83+
Recursively resolve $refs in a value.
84+
Args:
85+
value: The value to process
86+
depth: Current depth in resolution
87+
visiting: Set of definitions currently being resolved (for cycle detection)
88+
Returns:
89+
Value with $refs resolved (or kept if corner cases occur)
90+
"""
91+
if isinstance(value, dict):
92+
if "$ref" in value:
93+
ref_path = value["$ref"]
94+
95+
# Only handle internal references to $defs
96+
if isinstance(ref_path, str) and ref_path.startswith("#/$defs/"):
97+
def_name = ref_path.split("/")[-1]
98+
99+
# Check for circular reference
100+
if def_name in visiting:
101+
# Circular reference detected, keep the $ref
102+
corner_cases_detected["circular_ref"] = True
103+
return value
104+
105+
if def_name in defs:
106+
# Add to visiting set
107+
visiting.add(def_name)
108+
109+
# Get the definition and resolve any refs within it
110+
resolved = resolve_refs_in_value(
111+
deepcopy(defs[def_name]), depth + 1, visiting
112+
)
113+
114+
# Remove from visiting set
115+
visiting.remove(def_name)
116+
117+
# Merge resolved definition with additional properties
118+
# Additional properties from the original object take precedence
119+
for key, val in value.items():
120+
if key != "$ref":
121+
resolved[key] = val
122+
123+
return resolved
124+
else:
125+
# Definition not found, keep the $ref
126+
corner_cases_detected["ref_not_found"] = True
127+
return value
128+
else:
129+
# External ref or other type - keep as is
130+
return value
131+
else:
132+
# Regular dict - process all values
133+
return {
134+
key: resolve_refs_in_value(val, depth, visiting)
135+
for key, val in value.items()
136+
}
137+
elif isinstance(value, list):
138+
# Process each item in the list
139+
return [resolve_refs_in_value(item, depth, visiting) for item in value]
140+
else:
141+
# Primitive value - return as is
142+
return value
143+
144+
# Step 3: Process main schema properties with shared visiting set
145+
for key, value in result.items():
146+
if key != "$defs":
147+
# Each top-level property gets its own visiting set
148+
# This allows the same definition to be used in different contexts
149+
result[key] = resolve_refs_in_value(value, 0, set())
150+
151+
# Step 4: Conditionally preserve $defs based on corner cases
152+
if any(corner_cases_detected.values()):
153+
# Corner case detected, preserve original $defs
154+
if "$defs" in schema: # Only add if original schema had $defs
155+
result["$defs"] = defs
156+
else:
157+
# No corner cases, remove $defs if it exists
158+
result.pop("$defs", None)
159+
160+
return result
161+
162+
163+
class SchemaDereferenceMiddleware(Middleware):
164+
"""Middleware that dereferences $ref in schemas for tools, resource templates.
165+
166+
Applies to list handlers so that clients like Claude Desktop receive flattened schemas
167+
without $ref in properties, preventing null parameter values.
168+
"""
169+
170+
async def on_list_tools(
171+
self,
172+
context: MiddlewareContext[mt.ListToolsRequest],
173+
call_next: CallNext[mt.ListToolsRequest, list],
174+
) -> list:
175+
tools = await call_next(context)
176+
flattened = []
177+
for tool in tools:
178+
params = getattr(tool, "parameters", None)
179+
update: dict[str, Any] = {}
180+
if isinstance(params, dict):
181+
update["parameters"] = dereference_json_schema(params)
182+
if update:
183+
tool = tool.model_copy(update=update)
184+
flattened.append(tool)
185+
return flattened
186+
187+
async def on_list_resource_templates(
188+
self,
189+
context: MiddlewareContext[mt.ListResourceTemplatesRequest],
190+
call_next: CallNext[mt.ListResourceTemplatesRequest, list],
191+
) -> list:
192+
templates = await call_next(context)
193+
flattened = []
194+
for template in templates:
195+
params = getattr(template, "parameters", None)
196+
if isinstance(params, dict):
197+
template = template.model_copy(
198+
update={"parameters": dereference_json_schema(params)}
199+
)
200+
flattened.append(template)
201+
return flattened
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import json
2+
from enum import Enum
3+
4+
from fastmcp import Client, FastMCP
5+
from fastmcp.server.middleware.schema_dereference import (
6+
SchemaDereferenceMiddleware,
7+
)
8+
9+
10+
class ColorEnum(str, Enum):
11+
RED = "red"
12+
GREEN = "green"
13+
BLUE = "blue"
14+
15+
16+
class TestSchemaDereferenceMiddleware:
17+
async def test_dereference_enum_in_tool_parameters(self):
18+
mcp = FastMCP("SchemaDereferenceTest")
19+
mcp.add_middleware(SchemaDereferenceMiddleware())
20+
21+
@mcp.tool
22+
def choose_color(color: ColorEnum) -> str:
23+
return color.value
24+
25+
async with Client(mcp) as client:
26+
tools = await client.list_tools()
27+
28+
tool = next(t for t in tools if t.name == "choose_color")
29+
schema = tool.inputSchema
30+
31+
# Ensure $ref was inlined and $defs removed for simple enum case
32+
assert "$ref" not in json.dumps(schema)
33+
assert "$defs" not in schema
34+
35+
assert "properties" in schema and "color" in schema["properties"]
36+
color_schema = schema["properties"]["color"]
37+
assert color_schema.get("enum") == ["red", "green", "blue"]
38+
assert color_schema.get("type") == "string"
39+
40+
async def test_dereference_enum_in_resource_template_parameters(self):
41+
mcp = FastMCP("SchemaDereferenceTemplateTest")
42+
mcp.add_middleware(SchemaDereferenceMiddleware())
43+
44+
@mcp.resource("color://{color}")
45+
def color_resource(color: ColorEnum) -> str:
46+
return color.value
47+
48+
# Use internal list to inspect template parameters after middleware
49+
templates = await mcp._list_resource_templates()
50+
assert len(templates) == 1
51+
params = templates[0].parameters
52+
53+
# Ensure $ref was inlined and $defs removed for simple enum case
54+
assert "$ref" not in json.dumps(params)
55+
assert "$defs" not in params
56+
57+
assert "properties" in params and "color" in params["properties"]
58+
color_schema = params["properties"]["color"]
59+
assert color_schema.get("enum") == ["red", "green", "blue"]
60+
assert color_schema.get("type") == "string"

0 commit comments

Comments
 (0)