Backport: Dereference $ref in tool schemas for MCP client compatibility (#2861)

This commit is contained in:
Jeremiah Lowin 2026-01-13 07:46:36 -05:00 committed by GitHub
commit bc2f601e52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 255 additions and 5 deletions

1
.loq_cache Normal file

File diff suppressed because one or more lines are too long

View file

@ -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>
### 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:

View file

@ -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"

View file

@ -3,6 +3,130 @@ from __future__ import annotations
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.
@ -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`.
"""
@ -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.
@ -238,12 +362,12 @@ def _single_pass_optimize(
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.

View file

@ -1,6 +1,7 @@
from fastmcp.utilities.json_schema import (
_prune_param,
compress_schema,
dereference_refs,
resolve_root_ref,
)
@ -508,6 +509,114 @@ class TestCompressSchema:
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.

11
uv.lock generated
View file

@ -689,6 +689,7 @@ dependencies = [
{ name = "cyclopts" },
{ name = "exceptiongroup" },
{ name = "httpx" },
{ name = "jsonref" },
{ name = "jsonschema-path" },
{ name = "mcp" },
{ name = "openapi-pydantic" },
@ -745,6 +746,7 @@ requires-dist = [
{ name = "cyclopts", specifier = ">=4.0.0" },
{ name = "exceptiongroup", specifier = ">=1.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonref", specifier = ">=1.1.0" },
{ name = "jsonschema-path", specifier = ">=0.3.4" },
{ name = "mcp", specifier = ">=1.24.0,<2.0" },
{ name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" },
@ -1100,6 +1102,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" },
]
[[package]]
name = "jsonref"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" },
]
[[package]]
name = "jsonschema"
version = "4.25.1"