Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions docs/integrations/claude-desktop.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,79 @@ if __name__ == "__main__":
mcp.run()
```

## Claude Desktop Compatibility

<Warning>
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.
</Warning>

### Schema Compatibility Issues

When FastMCP generates JSON schemas for complex Python types, it creates `$defs` sections with `$ref` references for reusable components:

```json Problematic Schema (causes null parameters)
{
"$defs": {
"TestColor": {"enum": ["red", "green", "blue"], title: "TestColor", "type": "string"}
},
"properties": {
"color": {"$ref": "#/$defs/TestColor"}
}
}
```

**Result**: Claude Desktop sends null values instead of valid enum options:
```json
{
"color": null // ❌ Should be "red", "green", or "blue"
}
```

### Solution: Schema Dereferencing

FastMCP provides automatic schema dereferencing that resolves `$ref` references in properties while preserving the original `$defs` section:

```json Compatible Schema (works with Claude Desktop)
{
"$defs": {
"TestColor": {"enum": ["red", "green", "blue"], title: "TestColor", "type": "string"}
},
"properties": {
"color": {"enum": ["red", "green", "blue"], title: "TestColor", "type": "string"}
}
}
```

**Result**: Claude Desktop now sends proper parameter values:
```json
{
"color": "red" // ✅ Valid enum value
}
```

### Enabling Dereferencing

<Tabs>
<Tab title="Programmatic">
Enable dereferencing in your server code:

```python server.py {3,4}
from fastmcp import FastMCP
import fastmcp

fastmcp.settings.dereference_json_schemas = True

mcp = FastMCP(name="Compatible Test")

# Your tools with complex types will now be Claude Desktop compatible
@mcp.tool
def test_enum(color: TestColor) -> dict:
"""Process a complex task - schema will be automatically dereferenced."""
return {"status": "success", "color": color.value}
```
</Tab>
</Tabs>

## Install the Server

### FastMCP CLI
Expand Down
7 changes: 6 additions & 1 deletion src/fastmcp/prompts/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from mcp.types import PromptArgument as MCPPromptArgument
from pydantic import Field, TypeAdapter

import fastmcp
from fastmcp.exceptions import PromptError
from fastmcp.server.dependencies import get_context
from fastmcp.utilities.components import FastMCPComponent
Expand Down Expand Up @@ -195,7 +196,11 @@ def from_function(
else:
prune_params = None

parameters = compress_schema(parameters, prune_params=prune_params)
parameters = compress_schema(
parameters,
prune_params=prune_params,
dereference_refs=fastmcp.settings.dereference_json_schemas,
)

# Convert parameters to PromptArguments
arguments: list[PromptArgument] = []
Expand Down
7 changes: 6 additions & 1 deletion src/fastmcp/resources/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
validate_call,
)

import fastmcp
from fastmcp.resources.resource import Resource
from fastmcp.server.dependencies import get_context
from fastmcp.utilities.components import FastMCPComponent
Expand Down Expand Up @@ -274,7 +275,11 @@ def from_function(

# compress the schema
prune_params = [context_kwarg] if context_kwarg else None
parameters = compress_schema(parameters, prune_params=prune_params)
parameters = compress_schema(
parameters,
prune_params=prune_params,
dereference_refs=fastmcp.settings.dereference_json_schemas,
)

# ensure the arguments are properly cast
fn = validate_call(fn)
Expand Down
20 changes: 20 additions & 0 deletions src/fastmcp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,26 @@ def normalize_log_level(cls, v):
),
] = "path"

dereference_json_schemas: Annotated[
bool,
Field(
default=False,
description=inspect.cleandoc(
"""
If True, all JSON schemas generated for tools, prompts, and resources will have
their $refs resolved in properties while preserving the original $defs section.
This fixes compatibility issues with clients like Claude Desktop that fail to properly
handle $ref references, sending null values instead of valid enum parameters.

When enabled, schemas with references like {"$ref": "#/$defs/EnumName"} in properties
will be expanded to include the full definition inline, while keeping the $defs section
intact for reference. This prevents parameter validation errors and ensures clients
can properly generate forms and send valid parameter values.
"""
),
),
] = False

client_init_timeout: Annotated[
float | None,
Field(
Expand Down
12 changes: 10 additions & 2 deletions src/fastmcp/tools/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from mcp.types import Tool as MCPTool
from pydantic import Field, PydanticSchemaGenerationError

import fastmcp
from fastmcp.server.dependencies import get_context
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
Expand Down Expand Up @@ -376,7 +377,11 @@ def from_function(

input_type_adapter = get_cached_typeadapter(fn)
input_schema = input_type_adapter.json_schema()
input_schema = compress_schema(input_schema, prune_params=prune_params)
input_schema = compress_schema(
input_schema,
prune_params=prune_params,
dereference_refs=fastmcp.settings.dereference_json_schemas,
)

output_schema = None
# Get the return annotation from the signature
Expand Down Expand Up @@ -436,7 +441,10 @@ def from_function(
else:
output_schema = base_schema

output_schema = compress_schema(output_schema)
output_schema = compress_schema(
output_schema,
dereference_refs=fastmcp.settings.dereference_json_schemas,
)

except PydanticSchemaGenerationError as e:
if "_UnserializableType" not in str(e):
Expand Down
176 changes: 176 additions & 0 deletions src/fastmcp/utilities/json_schema.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,176 @@
from __future__ import annotations

from collections import defaultdict
from copy import deepcopy
from typing import Any


def _detect_self_reference(schema: dict) -> bool:
"""
Detect if the schema contains self-referencing definitions.

Args:
schema: The JSON schema to check

Returns:
True if self-referencing is detected
"""
defs = schema.get("$defs", {})

def find_refs_in_value(value: Any, parent_def: str) -> bool:
"""Check if a value contains a reference to its parent definition."""
if isinstance(value, dict):
if "$ref" in value:
ref_path = value["$ref"]
# Check if this references the parent definition
if ref_path == f"#/$defs/{parent_def}":
return True
# Check all values in the dict
for v in value.values():
if find_refs_in_value(v, parent_def):
return True
elif isinstance(value, list):
# Check all items in the list
for item in value:
if find_refs_in_value(item, parent_def):
return True
return False

# Check each definition for self-reference
for def_name, def_content in defs.items():
if find_refs_in_value(def_content, def_name):
# Self-reference detected, return original schema
return True

return False


def dereference_json_schema(schema: dict, max_depth: int = 50) -> dict:
"""
Dereference a JSON schema by resolving $ref references while preserving $defs only when corner cases occur.

This function flattens schema properties by:
1. Check for self-reference - if found, return original schema with $defs
2. When encountering $refs in properties, resolve them on-demand
3. Track visited definitions globally to prevent circular expansion
4. Only preserve original $defs if corner cases are encountered:
- Self-reference detected
- Circular references between definitions
- Reference depth exceeds max_depth
- Reference not found in $defs

Args:
schema: The JSON schema to flatten
max_depth: Maximum depth for resolving references (default: 5)

Returns:
Schema with references resolved in properties, keeping $defs only when corner cases occur
"""
# Step 1: Check for self-reference
if _detect_self_reference(schema):
# Self-referencing detected, return original schema with $defs
return schema

# Make a deep copy to work with
result = deepcopy(schema)

# Keep original $defs for potential corner cases
defs = deepcopy(schema.get("$defs", {}))

# Track corner cases that require preserving $defs
corner_cases_detected = {
"circular_ref": False,
"max_depth_reached": False,
"ref_not_found": False,
}

# Step 2: Define resolution function that tracks visits globally and corner cases
def resolve_refs_in_value(value: Any, depth: int, visiting: set[str]) -> Any:
"""
Recursively resolve $refs in a value.

Args:
value: The value to process
depth: Current depth in resolution
visiting: Set of definitions currently being resolved (for cycle detection)

Returns:
Value with $refs resolved (or kept if corner cases occur)
"""
if depth >= max_depth:
corner_cases_detected["max_depth_reached"] = True
return value

if isinstance(value, dict):
if "$ref" in value:
ref_path = value["$ref"]

# Only handle internal references to $defs
if ref_path.startswith("#/$defs/"):
def_name = ref_path.split("/")[-1]

# Check for circular reference
if def_name in visiting:
# Circular reference detected, keep the $ref
corner_cases_detected["circular_ref"] = True
return value

if def_name in defs:
# Add to visiting set
visiting.add(def_name)

# Get the definition and resolve any refs within it
resolved = resolve_refs_in_value(
deepcopy(defs[def_name]), depth + 1, visiting
)

# Remove from visiting set
visiting.remove(def_name)

# Merge resolved definition with additional properties
# Additional properties from the original object take precedence
for key, val in value.items():
if key != "$ref":
resolved[key] = val

return resolved
else:
# Definition not found, keep the $ref
corner_cases_detected["ref_not_found"] = True
return value
else:
# External ref or other type - keep as is
return value
else:
# Regular dict - process all values
return {
key: resolve_refs_in_value(val, depth, visiting)
for key, val in value.items()
}
elif isinstance(value, list):
# Process each item in the list
return [resolve_refs_in_value(item, depth, visiting) for item in value]
else:
# Primitive value - return as is
return value

# Step 3: Process main schema properties with shared visiting set
for key, value in result.items():
if key != "$defs":
# Each top-level property gets its own visiting set
# This allows the same definition to be used in different contexts
result[key] = resolve_refs_in_value(value, 0, set())

# Step 4: Conditionally preserve $defs based on corner cases
if any(corner_cases_detected.values()):
# Corner case detected, preserve original $defs
if "$defs" in schema: # Only add if original schema had $defs
result["$defs"] = defs
else:
# No corner cases, remove $defs if it exists
result.pop("$defs", None)

return result


def _prune_param(schema: dict, param: str) -> dict:
Expand Down Expand Up @@ -186,6 +356,7 @@ def compress_schema(
prune_defs: bool = True,
prune_additional_properties: bool = True,
prune_titles: bool = False,
dereference_refs: bool = False,
) -> dict:
"""
Remove the given parameters from the schema.
Expand All @@ -196,6 +367,7 @@ def compress_schema(
prune_defs: Whether to remove unused definitions
prune_additional_properties: Whether to remove additionalProperties: false
prune_titles: Whether to remove title fields from the schema
dereference_refs: Whether to completely flatten by inlining all $refs (fixes Claude Desktop crashes).
"""
# Remove specific parameters if requested
for param in prune_params or []:
Expand All @@ -210,4 +382,8 @@ def compress_schema(
prune_defs=prune_defs,
)

# Dereference all $refs if requested
if dereference_refs:
schema = dereference_json_schema(schema)

return schema
Loading