Skip to content

Commit d0467fa

Browse files
committed
chore: add dereference_json_schema
1 parent d6bd800 commit d0467fa

File tree

7 files changed

+976
-4
lines changed

7 files changed

+976
-4
lines changed

docs/integrations/claude-desktop.mdx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,79 @@ if __name__ == "__main__":
4848
mcp.run()
4949
```
5050

51+
## Claude Desktop Compatibility
52+
53+
<Warning>
54+
Claude Desktop fails to properly handle JSON schemas that contain `$ref` references, sending null values instead of valid parameter values. This commonly occurs with complex FastMCP servers that use nested Pydantic models, enums, or advanced type annotations, causing parameter validation errors.
55+
</Warning>
56+
57+
### Schema Compatibility Issues
58+
59+
When FastMCP generates JSON schemas for complex Python types, it creates `$defs` sections with `$ref` references for reusable components:
60+
61+
```json Problematic Schema (causes null parameters)
62+
{
63+
"$defs": {
64+
"TestColor": {"enum": ["red", "green", "blue"], title: "TestColor", "type": "string"}
65+
},
66+
"properties": {
67+
"color": {"$ref": "#/$defs/TestColor"}
68+
}
69+
}
70+
```
71+
72+
**Result**: Claude Desktop sends null values instead of valid enum options:
73+
```json
74+
{
75+
"color": null // ❌ Should be "red", "green", or "blue"
76+
}
77+
```
78+
79+
### Solution: Schema Dereferencing
80+
81+
FastMCP provides automatic schema dereferencing that resolves `$ref` references in properties while preserving the original `$defs` section:
82+
83+
```json Compatible Schema (works with Claude Desktop)
84+
{
85+
"$defs": {
86+
"TestColor": {"enum": ["red", "green", "blue"], title: "TestColor", "type": "string"}
87+
},
88+
"properties": {
89+
"color": {"enum": ["red", "green", "blue"], title: "TestColor", "type": "string"}
90+
}
91+
}
92+
```
93+
94+
**Result**: Claude Desktop now sends proper parameter values:
95+
```json
96+
{
97+
"color": "red" // ✅ Valid enum value
98+
}
99+
```
100+
101+
### Enabling Dereferencing
102+
103+
<Tabs>
104+
<Tab title="Programmatic">
105+
Enable dereferencing in your server code:
106+
107+
```python server.py {3,4}
108+
from fastmcp import FastMCP
109+
from fastmcp.settings import settings
110+
111+
settings.dereference_json_schemas = True
112+
113+
mcp = FastMCP(name="Compatible Test")
114+
115+
# Your tools with complex types will now be Claude Desktop compatible
116+
@mcp.tool
117+
def test_enum(color: TestColor) -> dict:
118+
"""Process a complex task - schema will be automatically dereferenced."""
119+
return {"status": "success", "color": color.value}
120+
```
121+
</Tab>
122+
</Tabs>
123+
51124
## Install the Server
52125

53126
### FastMCP CLI

src/fastmcp/prompts/prompt.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from mcp.types import PromptArgument as MCPPromptArgument
1515
from pydantic import Field, TypeAdapter
1616

17+
import fastmcp
1718
from fastmcp.exceptions import PromptError
1819
from fastmcp.server.dependencies import get_context
1920
from fastmcp.utilities.components import FastMCPComponent
@@ -195,7 +196,11 @@ def from_function(
195196
else:
196197
prune_params = None
197198

198-
parameters = compress_schema(parameters, prune_params=prune_params)
199+
parameters = compress_schema(
200+
parameters,
201+
prune_params=prune_params,
202+
dereference_refs=fastmcp.settings.dereference_json_schemas,
203+
)
199204

200205
# Convert parameters to PromptArguments
201206
arguments: list[PromptArgument] = []

src/fastmcp/resources/template.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
validate_call,
1616
)
1717

18+
import fastmcp
1819
from fastmcp.resources.resource import Resource
1920
from fastmcp.server.dependencies import get_context
2021
from fastmcp.utilities.components import FastMCPComponent
@@ -274,7 +275,11 @@ def from_function(
274275

275276
# compress the schema
276277
prune_params = [context_kwarg] if context_kwarg else None
277-
parameters = compress_schema(parameters, prune_params=prune_params)
278+
parameters = compress_schema(
279+
parameters,
280+
prune_params=prune_params,
281+
dereference_refs=fastmcp.settings.dereference_json_schemas,
282+
)
278283

279284
# ensure the arguments are properly cast
280285
fn = validate_call(fn)

src/fastmcp/settings.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,26 @@ def normalize_log_level(cls, v):
212212
),
213213
] = "path"
214214

215+
dereference_json_schemas: Annotated[
216+
bool,
217+
Field(
218+
default=False,
219+
description=inspect.cleandoc(
220+
"""
221+
If True, all JSON schemas generated for tools, prompts, and resources will have
222+
their $refs resolved in properties while preserving the original $defs section.
223+
This fixes compatibility issues with clients like Claude Desktop that fail to properly
224+
handle $ref references, sending null values instead of valid enum parameters.
225+
226+
When enabled, schemas with references like {"$ref": "#/$defs/EnumName"} in properties
227+
will be expanded to include the full definition inline, while keeping the $defs section
228+
intact for reference. This prevents parameter validation errors and ensures clients
229+
can properly generate forms and send valid parameter values.
230+
"""
231+
),
232+
),
233+
] = False
234+
215235
client_init_timeout: Annotated[
216236
float | None,
217237
Field(

src/fastmcp/tools/tool.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from mcp.types import Tool as MCPTool
2020
from pydantic import Field, PydanticSchemaGenerationError
2121

22+
import fastmcp
2223
from fastmcp.server.dependencies import get_context
2324
from fastmcp.utilities.components import FastMCPComponent
2425
from fastmcp.utilities.json_schema import compress_schema
@@ -376,7 +377,11 @@ def from_function(
376377

377378
input_type_adapter = get_cached_typeadapter(fn)
378379
input_schema = input_type_adapter.json_schema()
379-
input_schema = compress_schema(input_schema, prune_params=prune_params)
380+
input_schema = compress_schema(
381+
input_schema,
382+
prune_params=prune_params,
383+
dereference_refs=fastmcp.settings.dereference_json_schemas,
384+
)
380385

381386
output_schema = None
382387
# Get the return annotation from the signature
@@ -436,7 +441,10 @@ def from_function(
436441
else:
437442
output_schema = base_schema
438443

439-
output_schema = compress_schema(output_schema)
444+
output_schema = compress_schema(
445+
output_schema,
446+
dereference_refs=fastmcp.settings.dereference_json_schemas,
447+
)
440448

441449
except PydanticSchemaGenerationError as e:
442450
if "_UnserializableType" not in str(e):

src/fastmcp/utilities/json_schema.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,157 @@
11
from __future__ import annotations
22

33
from collections import defaultdict
4+
from copy import deepcopy
5+
from typing import Any
6+
7+
8+
def _detect_self_reference(schema: dict) -> bool:
9+
"""
10+
Detect if the schema contains self-referencing definitions.
11+
12+
Args:
13+
schema: The JSON schema to check
14+
15+
Returns:
16+
True if self-referencing is detected
17+
"""
18+
defs = schema.get("$defs", {})
19+
20+
def find_refs_in_value(value: Any, parent_def: str) -> bool:
21+
"""Check if a value contains a reference to its parent definition."""
22+
if isinstance(value, dict):
23+
if "$ref" in value:
24+
ref_path = value["$ref"]
25+
# Check if this references the parent definition
26+
if ref_path == f"#/$defs/{parent_def}":
27+
return True
28+
# Check all values in the dict
29+
for v in value.values():
30+
if find_refs_in_value(v, parent_def):
31+
return True
32+
elif isinstance(value, list):
33+
# Check all items in the list
34+
for item in value:
35+
if find_refs_in_value(item, parent_def):
36+
return True
37+
return False
38+
39+
# Check each definition for self-reference
40+
for def_name, def_content in defs.items():
41+
if find_refs_in_value(def_content, def_name):
42+
# Self-reference detected, return original schema
43+
return True
44+
45+
return False
46+
47+
48+
def dereference_json_schema(schema: dict, max_depth: int = 5) -> dict:
49+
"""
50+
Dereference a JSON schema by resolving $ref references while preserving $defs.
51+
52+
This function flattens schema properties by:
53+
1. Check for self-reference - if found, return original schema
54+
2. When encountering $refs in properties, resolve them on-demand
55+
3. Track visited definitions globally to prevent circular expansion
56+
4. Preserve original $defs in the final result
57+
58+
Args:
59+
schema: The JSON schema to flatten
60+
max_depth: Maximum depth for resolving references (default: 5)
61+
62+
Returns:
63+
Schema with references resolved in properties, keeping original $defs
64+
"""
65+
# Step 1: Check for self-reference
66+
if _detect_self_reference(schema):
67+
# Self-referencing detected, return original schema
68+
return schema
69+
70+
# Make a deep copy to work with
71+
result = deepcopy(schema)
72+
73+
# Keep original $defs for the final result
74+
defs = deepcopy(schema.get("$defs", {}))
75+
76+
# Step 2: Define resolution function that tracks visits globally
77+
def resolve_refs_in_value(value: Any, depth: int, visiting: set[str]) -> Any:
78+
"""
79+
Recursively resolve $refs in a value.
80+
81+
Args:
82+
value: The value to process
83+
depth: Current depth in resolution
84+
visiting: Set of definitions currently being resolved (for cycle detection)
85+
86+
Returns:
87+
Value with $refs resolved (or kept if max depth reached)
88+
"""
89+
if depth >= max_depth:
90+
return value
91+
92+
if isinstance(value, dict):
93+
if "$ref" in value:
94+
ref_path = value["$ref"]
95+
96+
# Only handle internal references to $defs
97+
if ref_path.startswith("#/$defs/"):
98+
def_name = ref_path.split("/")[-1]
99+
100+
# Check for circular reference
101+
if def_name in visiting:
102+
# Circular reference detected, keep the $ref
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+
return value
127+
else:
128+
# External ref or other type - keep as is
129+
return value
130+
else:
131+
# Regular dict - process all values
132+
return {
133+
key: resolve_refs_in_value(val, depth, visiting)
134+
for key, val in value.items()
135+
}
136+
elif isinstance(value, list):
137+
# Process each item in the list
138+
return [resolve_refs_in_value(item, depth, visiting) for item in value]
139+
else:
140+
# Primitive value - return as is
141+
return value
142+
143+
# Step 3: Process main schema properties with shared visiting set
144+
for key, value in result.items():
145+
if key != "$defs":
146+
# Each top-level property gets its own visiting set
147+
# This allows the same definition to be used in different contexts
148+
result[key] = resolve_refs_in_value(value, 0, set())
149+
150+
# Step 4: Preserve original $defs
151+
if "$defs" in result:
152+
result["$defs"] = defs
153+
154+
return result
4155

5156

6157
def _prune_param(schema: dict, param: str) -> dict:
@@ -186,6 +337,7 @@ def compress_schema(
186337
prune_defs: bool = True,
187338
prune_additional_properties: bool = True,
188339
prune_titles: bool = False,
340+
dereference_refs: bool = False,
189341
) -> dict:
190342
"""
191343
Remove the given parameters from the schema.
@@ -196,6 +348,7 @@ def compress_schema(
196348
prune_defs: Whether to remove unused definitions
197349
prune_additional_properties: Whether to remove additionalProperties: false
198350
prune_titles: Whether to remove title fields from the schema
351+
dereference_refs: Whether to completely flatten by inlining all $refs (fixes Claude Desktop crashes).
199352
"""
200353
# Remove specific parameters if requested
201354
for param in prune_params or []:
@@ -210,4 +363,8 @@ def compress_schema(
210363
prune_defs=prune_defs,
211364
)
212365

366+
# Dereference all $refs if requested
367+
if dereference_refs:
368+
schema = dereference_json_schema(schema)
369+
213370
return schema

0 commit comments

Comments
 (0)