Skip to content
Merged
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
1 change: 1 addition & 0 deletions .loq_cache

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions docs/servers/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ def my_tool() -> None:

By default, FastMCP converts Python functions into MCP tools by inspecting the function's signature and type annotations. This allows you to use standard Python type annotations for your tools. In general, the framework strives to "just work": idiomatic Python behaviors like parameter defaults and type annotations are automatically translated into MCP schemas. However, there are a number of ways to customize the behavior of your tools.

<Note>
FastMCP automatically dereferences `$ref` entries in tool schemas to ensure compatibility with MCP clients that don't fully support JSON Schema references (e.g., VS Code Copilot, Claude Desktop). This means complex Pydantic models with shared types are inlined in the schema rather than using `$defs` references.
</Note>
Comment on lines +176 to +178

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire dereference_refs into tool schema generation

The docs state that FastMCP automatically dereferences $ref in tool schemas, but the new dereference_refs helper is never called from tool schema generation (schemas are still built via compress_schema in src/fastmcp/tools/tool.py and src/fastmcp/tools/tool_transform.py). As a result, $ref/$defs remain in tool schemas and clients that strip $defs will still break; integrate dereference_refs into the schema pipeline or remove this note.

Useful? React with 👍 / 👎.


### Type Annotations

MCP tools have typed arguments, and FastMCP uses type annotations to determine those types. Therefore, you should use standard Python type annotations for tool arguments:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dependencies = [
"uvicorn>=0.35",
"websockets>=15.0.1",
"jsonschema-path>=0.3.4",
"jsonref>=1.1.0",
]

requires-python = ">=3.10"
Expand Down
134 changes: 129 additions & 5 deletions src/fastmcp/utilities/json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,130 @@
from collections import defaultdict
from typing import Any

from jsonref import JsonRefError, replace_refs


def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]:
"""Resolve all $ref references in a JSON schema by inlining definitions.

This function resolves $ref references that point to $defs, replacing them
with the actual definition content while preserving sibling keywords (like
description, default, examples) that Pydantic places alongside $ref.

This is necessary because some MCP clients (e.g., VS Code Copilot) don't
properly handle $ref in tool input schemas.

For self-referencing/circular schemas where full dereferencing is not possible,
this function falls back to resolving only the root-level $ref while preserving
$defs for nested references.

Args:
schema: JSON schema dict that may contain $ref references

Returns:
A new schema dict with $ref resolved where possible and $defs removed
when no longer needed

Example:
>>> schema = {
... "$defs": {"Category": {"enum": ["a", "b"], "type": "string"}},
... "properties": {"cat": {"$ref": "#/$defs/Category", "default": "a"}}
... }
>>> resolved = dereference_refs(schema)
>>> # Result: {"properties": {"cat": {"enum": ["a", "b"], "type": "string", "default": "a"}}}
"""
try:
# Use jsonref to resolve all $ref references
# proxies=False returns plain dicts (not proxy objects)
# lazy_load=False resolves immediately
dereferenced = replace_refs(schema, proxies=False, lazy_load=False)

# Merge sibling keywords that were lost during dereferencing
# Pydantic puts description, default, examples as siblings to $ref
defs = schema.get("$defs", {})
merged = _merge_ref_siblings(schema, dereferenced, defs)
# Type assertion: top-level schema is always a dict
assert isinstance(merged, dict)
dereferenced = merged

# Remove $defs since all references have been resolved
if "$defs" in dereferenced:
dereferenced = {k: v for k, v in dereferenced.items() if k != "$defs"}

return dereferenced

except JsonRefError:
# Self-referencing/circular schemas can't be fully dereferenced
# Fall back to resolving only root-level $ref (for MCP spec compliance)
return resolve_root_ref(schema)


def _merge_ref_siblings(
original: Any,
dereferenced: Any,
defs: dict[str, Any],
visited: set[str] | None = None,
) -> Any:
"""Merge sibling keywords from original $ref nodes into dereferenced schema.

When jsonref resolves $ref, it replaces the entire node with the referenced
definition, losing any sibling keywords like description, default, or examples.
This function walks both trees in parallel and merges those siblings back.

Args:
original: The original schema with $ref and potential siblings
dereferenced: The schema after jsonref processing
defs: The $defs from the original schema, for looking up referenced definitions
visited: Set of definition names already being processed (prevents cycles)

Returns:
The dereferenced schema with sibling keywords restored
"""
if visited is None:
visited = set()

if isinstance(original, dict) and isinstance(dereferenced, dict):
# Check if original had a $ref
if "$ref" in original:
ref = original["$ref"]
siblings = {k: v for k, v in original.items() if k not in ("$ref", "$defs")}

# Look up the referenced definition to process its nested siblings
if isinstance(ref, str) and ref.startswith("#/$defs/"):
def_name = ref.split("/")[-1]
# Prevent infinite recursion on circular references
if def_name in defs and def_name not in visited:
# Recursively process the definition's content for nested siblings
dereferenced = _merge_ref_siblings(
defs[def_name], dereferenced, defs, visited | {def_name}
)

if siblings:
# Merge local siblings, which take precedence
merged = dict(dereferenced)
merged.update(siblings)
return merged
return dereferenced

# Recurse into nested structures
result = {}
for key, value in dereferenced.items():
if key in original:
result[key] = _merge_ref_siblings(original[key], value, defs, visited)
else:
result[key] = value
return result

elif isinstance(original, list) and isinstance(dereferenced, list):
# Process list items in parallel
min_len = min(len(original), len(dereferenced))
return [
_merge_ref_siblings(o, d, defs, visited)
for o, d in zip(original[:min_len], dereferenced[:min_len], strict=False)
] + dereferenced[min_len:]

return dereferenced


def resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any]:
"""Resolve $ref at root level to meet MCP spec requirements.
Expand Down Expand Up @@ -43,7 +167,7 @@ def resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any]:
return schema


def _prune_param(schema: dict, param: str) -> dict:
def _prune_param(schema: dict[str, Any], param: str) -> dict[str, Any]:
"""Return a new schema with *param* removed from `properties`, `required`,
and (if no longer referenced) `$defs`.
"""
Expand All @@ -65,11 +189,11 @@ def _prune_param(schema: dict, param: str) -> dict:


def _single_pass_optimize(
schema: dict,
schema: dict[str, Any],
prune_titles: bool = False,
prune_additional_properties: bool = False,
prune_defs: bool = True,
) -> dict:
) -> dict[str, Any]:
"""
Optimize JSON schemas in a single traversal for better performance.

Expand Down Expand Up @@ -238,12 +362,12 @@ def is_def_used(def_name: str, visiting: set[str] | None = None) -> bool:


def compress_schema(
schema: dict,
schema: dict[str, Any],
prune_params: list[str] | None = None,
prune_defs: bool = True,
prune_additional_properties: bool = True,
prune_titles: bool = False,
) -> dict:
) -> dict[str, Any]:
"""
Remove the given parameters from the schema.

Expand Down
109 changes: 109 additions & 0 deletions tests/utilities/test_json_schema.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastmcp.utilities.json_schema import (
_prune_param,
compress_schema,
dereference_refs,
resolve_root_ref,
)

Expand Down Expand Up @@ -508,6 +509,114 @@ def test_title_pruning_with_nested_properties(self):
assert "title" not in compressed["properties"]["normal_field"]


class TestDereferenceRefs:
"""Tests for the dereference_refs function."""

def test_falls_back_for_circular_refs(self):
"""Test that circular references fall back to resolve_root_ref."""
schema = {
"$defs": {
"Node": {
"type": "object",
"properties": {
"children": {
"type": "array",
"items": {"$ref": "#/$defs/Node"},
}
},
}
},
"$ref": "#/$defs/Node",
}
result = dereference_refs(schema)

# Should fall back to resolve_root_ref behavior
# Root should be resolved but nested refs preserved
assert result.get("type") == "object"
assert "$defs" in result # $defs preserved for circular refs

def test_preserves_sibling_keywords(self):
"""Test that sibling keywords (default, description) are preserved.

Pydantic places description, default, examples as siblings to $ref.
These should not be lost during dereferencing.
"""
schema = {
"$defs": {
"Status": {"type": "string", "enum": ["active", "inactive"]},
},
"properties": {
"status": {
"$ref": "#/$defs/Status",
"default": "active",
"description": "The user status",
},
},
"type": "object",
}
result = dereference_refs(schema)

# $ref should be inlined with siblings preserved
status = result["properties"]["status"]
assert status["type"] == "string"
assert status["enum"] == ["active", "inactive"]
assert status["default"] == "active"
assert status["description"] == "The user status"
# $defs should be removed
assert "$defs" not in result

def test_preserves_siblings_in_lists(self):
"""Test that siblings are preserved for $refs inside lists (allOf, anyOf, etc)."""
schema = {
"$defs": {
"StringType": {"type": "string"},
"IntType": {"type": "integer"},
},
"properties": {
"field": {
"anyOf": [
{"$ref": "#/$defs/StringType", "description": "As string"},
{"$ref": "#/$defs/IntType", "description": "As integer"},
]
},
},
}
result = dereference_refs(schema)

# Both items in anyOf should have their siblings preserved
any_of = result["properties"]["field"]["anyOf"]
assert any_of[0]["type"] == "string"
assert any_of[0]["description"] == "As string"
assert any_of[1]["type"] == "integer"
assert any_of[1]["description"] == "As integer"
assert "$defs" not in result

def test_preserves_nested_siblings(self):
"""Test that siblings on nested $refs are preserved."""
schema = {
"$defs": {
"Address": {
"type": "object",
"properties": {
"country": {"$ref": "#/$defs/Country", "default": "US"},
},
},
"Country": {"type": "string", "enum": ["US", "UK", "CA"]},
},
"properties": {
"home_address": {"$ref": "#/$defs/Address"},
},
}
result = dereference_refs(schema)

# The nested $ref's sibling (default) should be preserved
country = result["properties"]["home_address"]["properties"]["country"]
assert country["type"] == "string"
assert country["enum"] == ["US", "UK", "CA"]
assert country["default"] == "US"
assert "$defs" not in result


class TestResolveRootRef:
"""Tests for the resolve_root_ref function.

Expand Down
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading