Skip to content

Commit 907fa6d

Browse files
committed
chore: add dereference_json_schema
1 parent 1ea3ee8 commit 907fa6d

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
@@ -164,6 +164,26 @@ def normalize_log_level(cls, v):
164164
),
165165
] = "path"
166166

167+
dereference_json_schemas: Annotated[
168+
bool,
169+
Field(
170+
default=False,
171+
description=inspect.cleandoc(
172+
"""
173+
If True, all JSON schemas generated for tools, prompts, and resources will have
174+
their $refs resolved in properties while preserving the original $defs section.
175+
This fixes compatibility issues with clients like Claude Desktop that fail to properly
176+
handle $ref references, sending null values instead of valid enum parameters.
177+
178+
When enabled, schemas with references like {"$ref": "#/$defs/EnumName"} in properties
179+
will be expanded to include the full definition inline, while keeping the $defs section
180+
intact for reference. This prevents parameter validation errors and ensures clients
181+
can properly generate forms and send valid parameter values.
182+
"""
183+
),
184+
),
185+
] = False
186+
167187
client_init_timeout: Annotated[
168188
float | None,
169189
Field(

src/fastmcp/tools/tool.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from mcp.types import Tool as MCPTool
1212
from pydantic import Field, PydanticSchemaGenerationError
1313

14+
import fastmcp
1415
from fastmcp.server.dependencies import get_context
1516
from fastmcp.utilities.components import FastMCPComponent
1617
from fastmcp.utilities.json_schema import compress_schema
@@ -368,7 +369,11 @@ def from_function(
368369

369370
input_type_adapter = get_cached_typeadapter(fn)
370371
input_schema = input_type_adapter.json_schema()
371-
input_schema = compress_schema(input_schema, prune_params=prune_params)
372+
input_schema = compress_schema(
373+
input_schema,
374+
prune_params=prune_params,
375+
dereference_refs=fastmcp.settings.dereference_json_schemas,
376+
)
372377

373378
output_schema = None
374379
output_type = inspect.signature(fn).return_annotation
@@ -415,7 +420,10 @@ def from_function(
415420
else:
416421
output_schema = base_schema
417422

418-
output_schema = compress_schema(output_schema)
423+
output_schema = compress_schema(
424+
output_schema,
425+
dereference_refs=fastmcp.settings.dereference_json_schemas,
426+
)
419427

420428
except PydanticSchemaGenerationError as e:
421429
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
@@ -2,6 +2,157 @@
22

33
import copy
44
from collections import defaultdict
5+
from copy import deepcopy
6+
from typing import Any
7+
8+
9+
def _detect_self_reference(schema: dict) -> bool:
10+
"""
11+
Detect if the schema contains self-referencing definitions.
12+
13+
Args:
14+
schema: The JSON schema to check
15+
16+
Returns:
17+
True if self-referencing is detected
18+
"""
19+
defs = schema.get("$defs", {})
20+
21+
def find_refs_in_value(value: Any, parent_def: str) -> bool:
22+
"""Check if a value contains a reference to its parent definition."""
23+
if isinstance(value, dict):
24+
if "$ref" in value:
25+
ref_path = value["$ref"]
26+
# Check if this references the parent definition
27+
if ref_path == f"#/$defs/{parent_def}":
28+
return True
29+
# Check all values in the dict
30+
for v in value.values():
31+
if find_refs_in_value(v, parent_def):
32+
return True
33+
elif isinstance(value, list):
34+
# Check all items in the list
35+
for item in value:
36+
if find_refs_in_value(item, parent_def):
37+
return True
38+
return False
39+
40+
# Check each definition for self-reference
41+
for def_name, def_content in defs.items():
42+
if find_refs_in_value(def_content, def_name):
43+
# Self-reference detected, return original schema
44+
return True
45+
46+
return False
47+
48+
49+
def dereference_json_schema(schema: dict, max_depth: int = 5) -> dict:
50+
"""
51+
Dereference a JSON schema by resolving $ref references while preserving $defs.
52+
53+
This function flattens schema properties by:
54+
1. Check for self-reference - if found, return original schema
55+
2. When encountering $refs in properties, resolve them on-demand
56+
3. Track visited definitions globally to prevent circular expansion
57+
4. Preserve original $defs in the final result
58+
59+
Args:
60+
schema: The JSON schema to flatten
61+
max_depth: Maximum depth for resolving references (default: 5)
62+
63+
Returns:
64+
Schema with references resolved in properties, keeping original $defs
65+
"""
66+
# Step 1: Check for self-reference
67+
if _detect_self_reference(schema):
68+
# Self-referencing detected, return original schema
69+
return schema
70+
71+
# Make a deep copy to work with
72+
result = deepcopy(schema)
73+
74+
# Keep original $defs for the final result
75+
defs = deepcopy(schema.get("$defs", {}))
76+
77+
# Step 2: Define resolution function that tracks visits globally
78+
def resolve_refs_in_value(value: Any, depth: int, visiting: set[str]) -> Any:
79+
"""
80+
Recursively resolve $refs in a value.
81+
82+
Args:
83+
value: The value to process
84+
depth: Current depth in resolution
85+
visiting: Set of definitions currently being resolved (for cycle detection)
86+
87+
Returns:
88+
Value with $refs resolved (or kept if max depth reached)
89+
"""
90+
if depth >= max_depth:
91+
return value
92+
93+
if isinstance(value, dict):
94+
if "$ref" in value:
95+
ref_path = value["$ref"]
96+
97+
# Only handle internal references to $defs
98+
if ref_path.startswith("#/$defs/"):
99+
def_name = ref_path.split("/")[-1]
100+
101+
# Check for circular reference
102+
if def_name in visiting:
103+
# Circular reference detected, keep the $ref
104+
return value
105+
106+
if def_name in defs:
107+
# Add to visiting set
108+
visiting.add(def_name)
109+
110+
# Get the definition and resolve any refs within it
111+
resolved = resolve_refs_in_value(
112+
deepcopy(defs[def_name]), depth + 1, visiting
113+
)
114+
115+
# Remove from visiting set
116+
visiting.remove(def_name)
117+
118+
# Merge resolved definition with additional properties
119+
# Additional properties from the original object take precedence
120+
for key, val in value.items():
121+
if key != "$ref":
122+
resolved[key] = val
123+
124+
return resolved
125+
else:
126+
# Definition not found, keep the $ref
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: Preserve original $defs
152+
if "$defs" in result:
153+
result["$defs"] = defs
154+
155+
return result
5156

6157

7158
def _prune_param(schema: dict, param: str) -> dict:
@@ -144,6 +295,7 @@ def compress_schema(
144295
prune_defs: bool = True,
145296
prune_additional_properties: bool = True,
146297
prune_titles: bool = False,
298+
dereference_refs: bool = False,
147299
) -> dict:
148300
"""
149301
Remove the given parameters from the schema.
@@ -154,6 +306,7 @@ def compress_schema(
154306
prune_defs: Whether to remove unused definitions
155307
prune_additional_properties: Whether to remove additionalProperties: false
156308
prune_titles: Whether to remove title fields from the schema
309+
dereference_refs: Whether to completely flatten by inlining all $refs (fixes Claude Desktop crashes).
157310
"""
158311
# Make a copy so we don't modify the original
159312
schema = copy.deepcopy(schema)
@@ -172,4 +325,8 @@ def compress_schema(
172325
if prune_defs:
173326
schema = _prune_unused_defs(schema)
174327

328+
# Dereference all $refs if requested
329+
if dereference_refs:
330+
schema = dereference_json_schema(schema)
331+
175332
return schema

0 commit comments

Comments
 (0)