diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index ee27071973..8dfab62b38 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -174,6 +174,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. + +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. + + ### 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: diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py index 81d054b64e..4ebf9d1264 100644 --- a/src/fastmcp/utilities/json_schema.py +++ b/src/fastmcp/utilities/json_schema.py @@ -10,8 +10,11 @@ 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. This is necessary because some MCP clients - (e.g., VS Code Copilot) don't properly handle $ref in tool input schemas. + 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 @@ -27,10 +30,10 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: Example: >>> schema = { ... "$defs": {"Category": {"enum": ["a", "b"], "type": "string"}}, - ... "properties": {"cat": {"$ref": "#/$defs/Category"}} + ... "properties": {"cat": {"$ref": "#/$defs/Category", "default": "a"}} ... } >>> resolved = dereference_refs(schema) - >>> # Result: {"properties": {"cat": {"enum": ["a", "b"], "type": "string"}}} + >>> # Result: {"properties": {"cat": {"enum": ["a", "b"], "type": "string", "default": "a"}}} """ try: # Use jsonref to resolve all $ref references @@ -38,8 +41,16 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: # 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 isinstance(dereferenced, dict) and "$defs" in dereferenced: + if "$defs" in dereferenced: dereferenced = {k: v for k, v in dereferenced.items() if k != "$defs"} return dereferenced @@ -50,6 +61,73 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: 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. @@ -89,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`. """ @@ -111,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. @@ -284,11 +362,11 @@ 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_additional_properties: bool = True, prune_titles: bool = False, -) -> dict: +) -> dict[str, Any]: """ Compress and optimize a JSON schema for MCP compatibility. diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index f5174effa7..9c9e777752 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -111,6 +111,87 @@ def test_falls_back_for_circular_refs(self): 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 TestCompressSchema: """Tests for the compress_schema function."""