From 494171950575c08d13c1fdb8a659cacac7b18423 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:23:34 -0500 Subject: [PATCH 1/7] Make $ref dereferencing in schemas optional via server kwarg --- docs/development/v3-notes/v3-features.mdx | 10 ++ src/fastmcp/server/middleware/dereference.py | 78 +++++++++++ src/fastmcp/server/server.py | 8 ++ src/fastmcp/utilities/json_schema.py | 14 +- tests/server/middleware/test_dereference.py | 136 +++++++++++++++++++ tests/utilities/test_json_schema.py | 55 ++++++-- 6 files changed, 285 insertions(+), 16 deletions(-) create mode 100644 src/fastmcp/server/middleware/dereference.py create mode 100644 tests/server/middleware/test_dereference.py diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index f91734ba6d..975651882f 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -70,6 +70,16 @@ Background tasks now use a distributed Redis notification queue for reliable del Auth check functions can now be `async`, enabling authorization decisions that depend on asynchronous operations like reading server state via `Context.get_state` or calling external services ([#3150](https://github.com/jlowin/fastmcp/issues/3150)). Sync and async checks can be freely mixed. Previously, passing an async function as an auth check would silently pass (coroutine objects are truthy). +### Optional `$ref` Dereferencing in Schemas + +Schema `$ref` dereferencing — which inlines all `$defs` for compatibility with MCP clients that don't handle `$ref` — is now controlled by the `dereference_refs` constructor kwarg ([#3141](https://github.com/jlowin/fastmcp/issues/3141)). Default is `True` (dereference on) because the non-compliant clients are popular and the failure mode is silent breakage that server authors can't diagnose. Opt out when you know your clients handle `$ref` and want smaller schemas: + +```python +mcp = FastMCP("my-server", dereference_refs=False) +``` + +Dereferencing is implemented as middleware (`DereferenceRefsMiddleware`) that runs at serve-time, so schemas are stored with `$ref` intact and only inlined when sent to clients. + ### Breaking: Deprecated `FastMCP()` Constructor Kwargs Removed Sixteen deprecated keyword arguments have been removed from `FastMCP.__init__`. Passing any of them now raises `TypeError` with a migration hint. Environment variables (e.g., `FASTMCP_HOST`) continue to work — only the constructor kwargs moved. diff --git a/src/fastmcp/server/middleware/dereference.py b/src/fastmcp/server/middleware/dereference.py new file mode 100644 index 0000000000..1462011414 --- /dev/null +++ b/src/fastmcp/server/middleware/dereference.py @@ -0,0 +1,78 @@ +"""Middleware that dereferences $ref in JSON schemas before sending to clients.""" + +from collections.abc import Sequence +from typing import Any + +import mcp.types as mt +from typing_extensions import override + +from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext +from fastmcp.tools.tool import Tool +from fastmcp.utilities.json_schema import dereference_refs + + +class DereferenceRefsMiddleware(Middleware): + """Dereferences $ref in component schemas before sending to clients. + + Some MCP clients (e.g., VS Code Copilot) don't handle JSON Schema $ref + properly. This middleware inlines all $ref definitions so schemas are + self-contained. Enabled by default via ``FastMCP(dereference_refs=True)``. + """ + + @override + async def on_list_tools( + self, + context: MiddlewareContext[mt.ListToolsRequest], + call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]], + ) -> Sequence[Tool]: + tools = await call_next(context) + return [_dereference_tool(tool) for tool in tools] + + @override + async def on_list_resource_templates( + self, + context: MiddlewareContext[mt.ListResourceTemplatesRequest], + call_next: CallNext[ + mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate] + ], + ) -> Sequence[ResourceTemplate]: + templates = await call_next(context) + return [_dereference_resource_template(t) for t in templates] + + +def _dereference_tool(tool: Tool) -> Tool: + """Return a copy of the tool with dereferenced schemas.""" + updates: dict[str, object] = {} + if "$defs" in tool.parameters or _has_ref(tool.parameters): + updates["parameters"] = dereference_refs(tool.parameters) + if tool.output_schema is not None and ( + "$defs" in tool.output_schema or _has_ref(tool.output_schema) + ): + updates["output_schema"] = dereference_refs(tool.output_schema) + if updates: + return tool.model_copy(update=updates) + return tool + + +def _dereference_resource_template(template: ResourceTemplate) -> ResourceTemplate: + """Return a copy of the template with dereferenced schemas.""" + if "$defs" in template.parameters or _has_ref(template.parameters): + return template.model_copy( + update={"parameters": dereference_refs(template.parameters)} + ) + return template + + +def _has_ref(schema: dict[str, Any]) -> bool: + """Check if a schema contains any $ref.""" + if "$ref" in schema: + return True + for value in schema.values(): + if isinstance(value, dict) and _has_ref(value): + return True + if isinstance(value, list): + for item in value: + if isinstance(item, dict) and _has_ref(item): + return True + return False diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index bd0e5e2648..8339a67e6b 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -229,6 +229,7 @@ def __init__( tools: Sequence[Tool | Callable[..., Any]] | None = None, on_duplicate: DuplicateBehavior | None = None, mask_error_details: bool | None = None, + dereference_refs: bool = True, strict_input_validation: bool | None = None, list_page_size: int | None = None, tasks: bool | None = None, @@ -322,6 +323,13 @@ def __init__( self.middleware: list[Middleware] = list(middleware or []) + if dereference_refs: + from fastmcp.server.middleware.dereference import ( + DereferenceRefsMiddleware, + ) + + self.middleware.append(DereferenceRefsMiddleware()) + # Set up MCP protocol handlers self._setup_handlers() diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py index f302e1cd11..2d4a8b50b0 100644 --- a/src/fastmcp/utilities/json_schema.py +++ b/src/fastmcp/utilities/json_schema.py @@ -366,15 +366,11 @@ def compress_schema( prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False, + dereference: bool = False, ) -> dict[str, Any]: """ Compress and optimize a JSON schema for MCP compatibility. - This function dereferences all $ref entries (inlining definitions) to ensure - compatibility with MCP clients that don't properly handle $ref in schemas - (e.g., VS Code Copilot). It also applies various optimizations to reduce - schema size. - Args: schema: The schema to compress prune_params: List of parameter names to remove from properties @@ -382,10 +378,12 @@ def compress_schema( Defaults to False to maintain MCP client compatibility, as some clients (e.g., Claude) require additionalProperties: false for strict validation. prune_titles: Whether to remove title fields from the schema + dereference: Whether to dereference $ref by inlining definitions. + Defaults to False; dereferencing is typically handled by + middleware at serve-time instead. """ - # Dereference $ref - this inlines all definitions and removes $defs - # Required for MCP client compatibility - schema = dereference_refs(schema) + if dereference: + schema = dereference_refs(schema) # Remove specific parameters if requested for param in prune_params or []: diff --git a/tests/server/middleware/test_dereference.py b/tests/server/middleware/test_dereference.py new file mode 100644 index 0000000000..215b944b86 --- /dev/null +++ b/tests/server/middleware/test_dereference.py @@ -0,0 +1,136 @@ +"""Tests for DereferenceRefsMiddleware.""" + +from enum import Enum + +import pydantic + +from fastmcp import Client, FastMCP + + +class Color(Enum): + RED = "red" + GREEN = "green" + BLUE = "blue" + + +class PaintRequest(pydantic.BaseModel): + color: Color + opacity: float = 1.0 + + +class TestDereferenceRefsMiddleware: + """End-to-end tests for the dereference_refs server kwarg.""" + + async def test_dereference_refs_true_inlines_refs(self): + """With dereference_refs=True (default), tool schemas have $ref inlined.""" + mcp = FastMCP("test", dereference_refs=True) + + @mcp.tool + def paint(request: PaintRequest) -> str: + return "ok" + + async with Client(mcp) as client: + tools = await client.list_tools() + + schema = tools[0].inputSchema + # $defs should be removed — everything inlined + assert "$defs" not in schema + # The Color enum should be inlined into the request property + assert "$ref" not in str(schema) + + async def test_dereference_refs_false_preserves_refs(self): + """With dereference_refs=False, $ref and $defs are preserved.""" + mcp = FastMCP("test", dereference_refs=False) + + @mcp.tool + def paint(request: PaintRequest) -> str: + return "ok" + + async with Client(mcp) as client: + tools = await client.list_tools() + + schema = tools[0].inputSchema + # $defs should still be present + assert "$defs" in schema + + async def test_default_is_true(self): + """Default behavior dereferences $ref.""" + mcp = FastMCP("test") + + @mcp.tool + def paint(request: PaintRequest) -> str: + return "ok" + + async with Client(mcp) as client: + tools = await client.list_tools() + + schema = tools[0].inputSchema + assert "$defs" not in schema + + async def test_does_not_mutate_original_tool(self): + """Middleware should not mutate the shared Tool object.""" + mcp = FastMCP("test", dereference_refs=True) + + @mcp.tool + def paint(request: PaintRequest) -> str: + return "ok" + + # Get the original tool's parameters before middleware runs + original_tools = await mcp._local_provider._list_tools() + assert "$defs" in original_tools[0].parameters + + # List tools through the client (triggers middleware) + async with Client(mcp) as client: + await client.list_tools() + + # The original tool stored in the server should still have $defs + tools_after = await mcp._local_provider._list_tools() + assert "$defs" in tools_after[0].parameters + + async def test_output_schema_dereferenced(self): + """Middleware also dereferences output_schema when present.""" + mcp = FastMCP("test", dereference_refs=True) + + @mcp.tool + def paint(request: PaintRequest) -> PaintRequest: + return request + + async with Client(mcp) as client: + tools = await client.list_tools() + + tool = tools[0] + # Both input and output schemas should be dereferenced + assert "$defs" not in tool.inputSchema + if tool.outputSchema is not None: + assert "$defs" not in tool.outputSchema + + async def test_resource_templates_dereferenced(self): + """Middleware dereferences resource template schemas.""" + mcp = FastMCP("test", dereference_refs=True) + + @mcp.resource("paint://{color}") + def get_paint(color: Color) -> str: + return f"paint: {color}" + + async with Client(mcp) as client: + templates = await client.list_resource_templates() + + # Resource templates also get their schemas dereferenced + # (only if the template parameters have $ref) + assert len(templates) == 1 + + async def test_no_ref_schemas_unchanged(self): + """Tools without $ref should pass through unmodified.""" + mcp = FastMCP("test", dereference_refs=True) + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + async with Client(mcp) as client: + tools = await client.list_tools() + + schema = tools[0].inputSchema + # Simple schema should not have $defs regardless + assert "$defs" not in schema + assert schema["properties"]["a"]["type"] == "integer" diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index 436beb6a29..f5153dc2dc 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -196,8 +196,8 @@ def test_preserves_nested_siblings(self): class TestCompressSchema: """Tests for the compress_schema function.""" - def test_dereferences_by_default(self): - """Test that compress_schema dereferences $refs by default.""" + def test_preserves_refs_by_default(self): + """Test that compress_schema preserves $refs by default.""" schema = { "properties": { "foo": {"$ref": "#/$defs/foo_def"}, @@ -208,10 +208,9 @@ def test_dereferences_by_default(self): } result = compress_schema(schema) - # $ref should be inlined - assert result["properties"]["foo"] == {"type": "string"} - # $defs should be removed - assert "$defs" not in result + # $ref should be preserved (dereferencing is handled by middleware) + assert result["properties"]["foo"] == {"$ref": "#/$defs/foo_def"} + assert "$defs" in result def test_prune_params(self): """Test pruning parameters with compress_schema.""" @@ -271,8 +270,8 @@ def test_combined_operations(self): assert "remove" not in result["properties"] # Check that required list was updated assert result["required"] == ["keep"] - # Check that $defs was removed (dereferenced) - assert "$defs" not in result + # $defs are preserved (dereferencing is handled by middleware, not compress_schema) + assert "$defs" in result # Check that additionalProperties was removed assert "additionalProperties" not in result @@ -442,6 +441,46 @@ def test_mcp_client_compatibility_requires_additional_properties(self): ) +class TestCompressSchemaDereference: + """Tests for the dereference parameter of compress_schema.""" + + SCHEMA_WITH_REFS = { + "properties": { + "foo": {"$ref": "#/$defs/foo_def"}, + }, + "$defs": { + "foo_def": {"type": "string"}, + }, + } + + def test_dereference_true_inlines_refs(self): + result = compress_schema(self.SCHEMA_WITH_REFS, dereference=True) + assert result["properties"]["foo"] == {"type": "string"} + assert "$defs" not in result + + def test_dereference_false_preserves_refs(self): + result = compress_schema(self.SCHEMA_WITH_REFS, dereference=False) + assert result["properties"]["foo"] == {"$ref": "#/$defs/foo_def"} + assert "$defs" in result + + def test_other_optimizations_still_apply_without_dereference(self): + schema = { + "properties": { + "foo": {"$ref": "#/$defs/foo_def"}, + "bar": {"type": "integer", "title": "Bar"}, + }, + "$defs": { + "foo_def": {"type": "string"}, + }, + } + result = compress_schema( + schema, dereference=False, prune_params=["bar"], prune_titles=True + ) + assert "bar" not in result["properties"] + assert "$ref" in result["properties"]["foo"] + assert "$defs" in result + + class TestResolveRootRef: """Tests for the resolve_root_ref function. From 3337f19ee32bb9877d6680f347153b538dcd84e4 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:38:39 -0500 Subject: [PATCH 2/7] Fix tests: compress_schema preserves $ref, prunes unused $defs --- src/fastmcp/utilities/json_schema.py | 19 +++--- tests/server/middleware/test_caching.py | 2 +- tests/tools/tool/test_tool.py | 9 ++- tests/tools/tool_transform/test_schemas.py | 66 ++++++++++++------- .../tool_transform/test_tool_transform.py | 13 ++-- tests/utilities/openapi/test_schemas.py | 33 +++++----- 6 files changed, 83 insertions(+), 59 deletions(-) diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py index 2d4a8b50b0..da713f802d 100644 --- a/src/fastmcp/utilities/json_schema.py +++ b/src/fastmcp/utilities/json_schema.py @@ -385,17 +385,20 @@ def compress_schema( if dereference: schema = dereference_refs(schema) + # Resolve root-level $ref for MCP spec compliance (requires type: object at root) + schema = resolve_root_ref(schema) + # Remove specific parameters if requested for param in prune_params or []: schema = _prune_param(schema, param=param) - # Apply combined optimizations in a single tree traversal - if prune_titles or prune_additional_properties: - schema = _single_pass_optimize( - schema, - prune_titles=prune_titles, - prune_additional_properties=prune_additional_properties, - prune_defs=False, - ) + # Apply combined optimizations in a single tree traversal. + # Always prune unused $defs to keep schemas clean after parameter removal. + schema = _single_pass_optimize( + schema, + prune_titles=prune_titles, + prune_additional_properties=prune_additional_properties, + prune_defs=True, + ) return schema diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index d326e82369..e571c7368e 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -288,7 +288,7 @@ async def caching_server( request: pytest.FixtureRequest, ): """Create a FastMCP server for caching tests.""" - mcp = FastMCP("CachingTestServer") + mcp = FastMCP("CachingTestServer", dereference_refs=False) with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: disk_store: DiskStore = DiskStore(directory=temp_dir) diff --git a/tests/tools/tool/test_tool.py b/tests/tools/tool/test_tool.py index dd75d74432..e2976b674b 100644 --- a/tests/tools/tool/test_tool.py +++ b/tests/tools/tool/test_tool.py @@ -196,9 +196,8 @@ def create_user(user: UserInput, flag: bool) -> dict: "description": "Create a new user.", "tags": set(), "parameters": { - "additionalProperties": False, - "properties": { - "user": { + "$defs": { + "UserInput": { "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, @@ -206,6 +205,10 @@ def create_user(user: UserInput, flag: bool) -> dict: "required": ["name", "age"], "type": "object", }, + }, + "additionalProperties": False, + "properties": { + "user": {"$ref": "#/$defs/UserInput"}, "flag": {"type": "boolean"}, }, "required": ["user", "flag"], diff --git a/tests/tools/tool_transform/test_schemas.py b/tests/tools/tool_transform/test_schemas.py index 51cb89f765..41aa7bbe8e 100644 --- a/tests/tools/tool_transform/test_schemas.py +++ b/tests/tools/tool_transform/test_schemas.py @@ -346,8 +346,8 @@ def test_arg_transform_examples_in_schema(self, add_tool: Tool): def test_merge_schema_with_defs_precedence(self): """Test _merge_schema_with_precedence merges $defs correctly. - Note: This tests the raw merge behavior before dereferencing. - The final schema output will be dereferenced by compress_schema. + Note: compress_schema no longer dereferences $ref by default. + Used definitions are kept in $defs; unused definitions are pruned. """ base_schema = { "type": "object", @@ -374,13 +374,17 @@ def test_merge_schema_with_defs_precedence(self): # SharedType should no longer be present on the schema (unused) assert "SharedType" not in transformed_tool_schema.get("$defs", {}) - # Schema is dereferenced so no $defs in final output + # $ref and $defs are preserved for used definitions assert transformed_tool_schema == snapshot( { "type": "object", "properties": { - "field1": {"type": "string", "description": "base"}, - "field2": {"type": "boolean"}, + "field1": {"$ref": "#/$defs/BaseType"}, + "field2": {"$ref": "#/$defs/OverrideType"}, + }, + "$defs": { + "BaseType": {"type": "string", "description": "base"}, + "OverrideType": {"type": "boolean"}, }, "required": [], "additionalProperties": False, @@ -390,8 +394,8 @@ def test_merge_schema_with_defs_precedence(self): def test_transform_tool_with_complex_defs_pruning(self): """Test that tool transformation properly handles hidden params. - With schema dereferencing, unused types are automatically removed - since $defs is eliminated entirely. + Unused type definitions are pruned from $defs when their + corresponding parameters are hidden. Used types remain as $ref. """ class UsedType(BaseModel): @@ -411,18 +415,21 @@ def complex_tool( complex_tool, transform_args={"unused_param": ArgTransform(hide=True)} ) - # Schema is dereferenced - no $defs - assert "$defs" not in transformed_tool.parameters + # UnusedType should be pruned from $defs, but UsedType remains + assert "UnusedType" not in transformed_tool.parameters.get("$defs", {}) assert transformed_tool.parameters == snapshot( { "type": "object", "properties": { - "used_param": { + "used_param": {"$ref": "#/$defs/UsedType"}, + }, + "$defs": { + "UsedType": { "properties": {"value": {"type": "string"}}, "required": ["value"], "type": "object", - } + }, }, "required": ["used_param"], "additionalProperties": False, @@ -430,7 +437,7 @@ def complex_tool( ) def test_transform_with_custom_function_preserves_needed_types(self): - """Test that custom transform functions preserve necessary types inline.""" + """Test that custom transform functions preserve necessary type definitions.""" class InputType(BaseModel): data: str @@ -452,18 +459,19 @@ async def transform_function(renamed_input: InputType): transform_args={"input_data": ArgTransform(name="renamed_input")}, ) - # Schema is dereferenced - types are inlined - assert "$defs" not in transformed.parameters - + # Used type definitions are preserved as $ref/$defs assert transformed.parameters == snapshot( { "type": "object", "properties": { - "renamed_input": { + "renamed_input": {"$ref": "#/$defs/InputType"}, + }, + "$defs": { + "InputType": { "properties": {"data": {"type": "string"}}, "required": ["data"], "type": "object", - } + }, }, "required": ["renamed_input"], "additionalProperties": False, @@ -471,7 +479,7 @@ async def transform_function(renamed_input: InputType): ) def test_chained_transforms_inline_types(self): - """Test that chained transformations produce correct inlined schemas.""" + """Test that chained transformations produce correct schemas with $ref/$defs.""" class TypeA(BaseModel): a: str @@ -492,19 +500,23 @@ def base_tool(param_a: TypeA, param_b: TypeB, param_c: TypeC) -> str: transform_args={"param_c": ArgTransform(hide=True, default=TypeC(c=True))}, ) - # Schema is dereferenced - types are inlined - assert "$defs" not in transform1.parameters + # TypeC should be pruned from $defs, TypeA and TypeB remain + assert "TypeC" not in transform1.parameters.get("$defs", {}) assert transform1.parameters == snapshot( { "type": "object", "properties": { - "param_a": { + "param_a": {"$ref": "#/$defs/TypeA"}, + "param_b": {"$ref": "#/$defs/TypeB"}, + }, + "$defs": { + "TypeA": { "properties": {"a": {"type": "string"}}, "required": ["a"], "type": "object", }, - "param_b": { + "TypeB": { "properties": {"b": {"type": "integer"}}, "required": ["b"], "type": "object", @@ -521,17 +533,21 @@ def base_tool(param_a: TypeA, param_b: TypeB, param_c: TypeC) -> str: transform_args={"param_b": ArgTransform(hide=True, default=TypeB(b=42))}, ) - assert "$defs" not in transform2.parameters + # TypeB should be pruned from $defs, only TypeA remains + assert "TypeB" not in transform2.parameters.get("$defs", {}) assert transform2.parameters == snapshot( { "type": "object", "properties": { - "param_a": { + "param_a": {"$ref": "#/$defs/TypeA"}, + }, + "$defs": { + "TypeA": { "properties": {"a": {"type": "string"}}, "required": ["a"], "type": "object", - } + }, }, "required": ["param_a"], "additionalProperties": False, diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py index bc3247323f..47ab1853b2 100644 --- a/tests/tools/tool_transform/test_tool_transform.py +++ b/tests/tools/tool_transform/test_tool_transform.py @@ -205,10 +205,11 @@ def tool_with_refs(a: VisibleType, b: HiddenType | None = None) -> int: schema = new_tool.parameters # Only 'a' should be visible assert list(schema["properties"].keys()) == ["a"] - # Schema should be fully dereferenced (no $defs) - assert "$defs" not in schema - # VisibleType should be inlined in the property - assert schema["properties"]["a"] == { + # HiddenType should be pruned from $defs + assert "HiddenType" not in schema.get("$defs", {}) + # VisibleType should remain in $defs and be referenced via $ref + assert schema["properties"]["a"] == {"$ref": "#/$defs/VisibleType"} + assert schema["$defs"]["VisibleType"] == { "properties": {"x": {"type": "integer"}}, "required": ["x"], "type": "object", @@ -396,10 +397,8 @@ def parent_tool(cool_model: CoolModel) -> int: new_tool = Tool.from_tool(tool) - # Both tools should have the same dereferenced schema + # Both tools should have the same schema (with $ref/$defs preserved) assert new_tool.parameters == tool.parameters - # Schema should be fully dereferenced (no $defs) - assert "$defs" not in new_tool.parameters def test_transform_args_validation_unknown_arg(add_tool): diff --git a/tests/utilities/openapi/test_schemas.py b/tests/utilities/openapi/test_schemas.py index 54644bce6e..ceac2bdd67 100644 --- a/tests/utilities/openapi/test_schemas.py +++ b/tests/utilities/openapi/test_schemas.py @@ -581,7 +581,7 @@ def test_request_body_multiple_content_types(self): ) # Should have some properties from one of the content types def test_oneof_reference_dereferenced(self): - """Test that schemas referenced in oneOf are dereferenced.""" + """Test that schemas referenced in oneOf are preserved and unused defs pruned.""" schema = { "type": "object", @@ -594,14 +594,15 @@ def test_oneof_reference_dereferenced(self): result = compress_schema(schema) - # $defs should be removed (all refs dereferenced) - assert "$defs" not in result + # UnusedSchema should be pruned, TestSchema should be kept + assert "UnusedSchema" not in result.get("$defs", {}) + assert result["$defs"]["TestSchema"] == {"type": "string"} - # TestSchema should be inlined in oneOf - assert result["properties"]["data"]["oneOf"] == [{"type": "string"}] + # $ref should be preserved in oneOf + assert result["properties"]["data"]["oneOf"] == [{"$ref": "#/$defs/TestSchema"}] def test_anyof_reference_dereferenced(self): - """Test that schemas referenced in anyOf are dereferenced.""" + """Test that schemas referenced in anyOf are preserved and unused defs pruned.""" schema = { "type": "object", @@ -614,14 +615,15 @@ def test_anyof_reference_dereferenced(self): result = compress_schema(schema) - # $defs should be removed (all refs dereferenced) - assert "$defs" not in result + # UnusedSchema should be pruned, TestSchema should be kept + assert "UnusedSchema" not in result.get("$defs", {}) + assert result["$defs"]["TestSchema"] == {"type": "string"} - # TestSchema should be inlined in anyOf - assert result["properties"]["data"]["anyOf"] == [{"type": "string"}] + # $ref should be preserved in anyOf + assert result["properties"]["data"]["anyOf"] == [{"$ref": "#/$defs/TestSchema"}] def test_allof_reference_dereferenced(self): - """Test that schemas referenced in allOf are dereferenced.""" + """Test that schemas referenced in allOf are preserved and unused defs pruned.""" schema = { "type": "object", @@ -634,8 +636,9 @@ def test_allof_reference_dereferenced(self): result = compress_schema(schema) - # $defs should be removed (all refs dereferenced) - assert "$defs" not in result + # UnusedSchema should be pruned, TestSchema should be kept + assert "UnusedSchema" not in result.get("$defs", {}) + assert result["$defs"]["TestSchema"] == {"type": "string"} - # TestSchema should be inlined in allOf - assert result["properties"]["data"]["allOf"] == [{"type": "string"}] + # $ref should be preserved in allOf + assert result["properties"]["data"]["allOf"] == [{"$ref": "#/$defs/TestSchema"}] From e61bacff91a4661472e9d1e97606472857f74fb3 Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:39:31 +0000 Subject: [PATCH 3/7] chore: Update SDK documentation --- .../fastmcp-server-middleware-dereference.mdx | 35 +++++++ docs/python-sdk/fastmcp-server-server.mdx | 96 +++++++++---------- .../fastmcp-utilities-json_schema.mdx | 10 +- 3 files changed, 87 insertions(+), 54 deletions(-) create mode 100644 docs/python-sdk/fastmcp-server-middleware-dereference.mdx diff --git a/docs/python-sdk/fastmcp-server-middleware-dereference.mdx b/docs/python-sdk/fastmcp-server-middleware-dereference.mdx new file mode 100644 index 0000000000..a9be34607d --- /dev/null +++ b/docs/python-sdk/fastmcp-server-middleware-dereference.mdx @@ -0,0 +1,35 @@ +--- +title: dereference +sidebarTitle: dereference +--- + +# `fastmcp.server.middleware.dereference` + + +Middleware that dereferences $ref in JSON schemas before sending to clients. + +## Classes + +### `DereferenceRefsMiddleware` + + +Dereferences $ref in component schemas before sending to clients. + +Some MCP clients (e.g., VS Code Copilot) don't handle JSON Schema $ref +properly. This middleware inlines all $ref definitions so schemas are +self-contained. Enabled by default via ``FastMCP(dereference_refs=True)``. + + +**Methods:** + +#### `on_list_tools` + +```python +on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] +``` + +#### `on_list_resource_templates` + +```python +on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate]]) -> Sequence[ResourceTemplate] +``` diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index efaa20d12a..cf15f236b8 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty dictionary as the lifespan result. -### `create_proxy` +### `create_proxy` ```python create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -64,49 +64,49 @@ Wrapper for stored context state values. **Methods:** -#### `name` +#### `name` ```python name(self) -> str ``` -#### `instructions` +#### `instructions` ```python instructions(self) -> str | None ``` -#### `instructions` +#### `instructions` ```python instructions(self, value: str | None) -> None ``` -#### `version` +#### `version` ```python version(self) -> str | None ``` -#### `website_url` +#### `website_url` ```python website_url(self) -> str | None ``` -#### `icons` +#### `icons` ```python icons(self) -> list[mcp.types.Icon] ``` -#### `add_middleware` +#### `add_middleware` ```python add_middleware(self, middleware: Middleware) -> None ``` -#### `add_provider` +#### `add_provider` ```python add_provider(self, provider: Provider) -> None @@ -126,7 +126,7 @@ always take precedence over providers. - Prompts become "namespace_promptname" -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -138,7 +138,7 @@ Overrides AggregateProvider.get_tasks() to apply server-level transforms after aggregation. AggregateProvider handles provider-level namespacing. -#### `add_transform` +#### `add_transform` ```python add_transform(self, transform: Transform) -> None @@ -153,7 +153,7 @@ They transform tools, resources, and prompts from ALL providers. - `transform`: The transform to add. -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -165,7 +165,7 @@ Add a tool transformation. Use ``add_transform(ToolTransform({...}))`` instead. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, _tool_name: str) -> None @@ -177,7 +177,7 @@ Remove a tool transformation. Tool transformations are now immutable. Use enable/disable controls instead. -#### `list_tools` +#### `list_tools` ```python list_tools(self) -> Sequence[Tool] @@ -190,7 +190,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, version: VersionSpec | None = None) -> Tool | None @@ -210,7 +210,7 @@ session transforms can override provider-level disables. - The tool if found and enabled, None otherwise. -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> Sequence[Resource] @@ -223,7 +223,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_resource` +#### `get_resource` ```python get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None @@ -242,7 +242,7 @@ transforms (including session-level) have been applied. - The resource if found and enabled, None otherwise. -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self) -> Sequence[ResourceTemplate] @@ -255,7 +255,7 @@ auth filtering, and middleware execution. Returns all versions (no deduplication Protocol handlers deduplicate for MCP wire format. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None @@ -274,7 +274,7 @@ all transforms (including session-level) have been applied. - The template if found and enabled, None otherwise. -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> Sequence[Prompt] @@ -287,7 +287,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None @@ -306,19 +306,19 @@ transforms (including session-level) have been applied. - The prompt if found and enabled, None otherwise. -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult | mcp.types.CreateTaskResult @@ -348,19 +348,19 @@ return ToolResult. - `ValidationError`: If arguments fail validation -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> mcp.types.CreateTaskResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult | mcp.types.CreateTaskResult @@ -389,19 +389,19 @@ return ResourceResult. - `ResourceError`: If resource read fails -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult | mcp.types.CreateTaskResult @@ -431,7 +431,7 @@ return PromptResult. - `PromptError`: If prompt rendering fails -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool | Callable[..., Any]) -> Tool @@ -449,7 +449,7 @@ with the Context type annotation. See the @tool decorator for examples. - The tool instance that was added to the server. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, name: str, version: str | None = None) -> None @@ -465,19 +465,19 @@ Remove tool(s) from the server. - `NotFoundError`: If no matching tool is found. -#### `tool` +#### `tool` ```python tool(self, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] @@ -533,7 +533,7 @@ server.tool(my_function, name="custom_name") ``` -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource | Callable[..., Any]) -> Resource | ResourceTemplate @@ -548,7 +548,7 @@ Add a resource to the server. - The resource instance that was added to the server. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -563,7 +563,7 @@ Add a resource template to the server. - The template instance that was added to the server. -#### `resource` +#### `resource` ```python resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction] @@ -622,7 +622,7 @@ async def get_weather(city: str) -> str: ``` -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt @@ -637,19 +637,19 @@ Add a prompt to the server. - The prompt instance that was added to the server. -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: AnyFunction) -> FunctionPrompt ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionPrompt] ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] @@ -726,7 +726,7 @@ Decorator to register a prompt. ``` -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], namespace: str | None = None, as_proxy: bool | None = None, tool_names: dict[str, str] | None = None, prefix: str | None = None) -> None @@ -773,7 +773,7 @@ mounted server. - `prefix`: Deprecated. Use namespace instead. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None) -> None @@ -814,7 +814,7 @@ templates, and prompts are imported with their original names. objects are imported with their original names. -#### `from_openapi` +#### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, validate_output: bool = True, **settings: Any) -> Self @@ -843,7 +843,7 @@ response structure while still returning structured JSON. - A FastMCP server with an OpenAPIProvider attached. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self @@ -867,7 +867,7 @@ Use this to configure timeout and other client settings. - A FastMCP server with an OpenAPIProvider attached. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -885,7 +885,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str diff --git a/docs/python-sdk/fastmcp-utilities-json_schema.mdx b/docs/python-sdk/fastmcp-utilities-json_schema.mdx index aa8c7b2d57..f45ea01618 100644 --- a/docs/python-sdk/fastmcp-utilities-json_schema.mdx +++ b/docs/python-sdk/fastmcp-utilities-json_schema.mdx @@ -60,17 +60,12 @@ the referenced definition while preserving $defs for nested references. ### `compress_schema` ```python -compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False) -> dict[str, Any] +compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False, dereference: bool = False) -> dict[str, Any] ``` Compress and optimize a JSON schema for MCP compatibility. -This function dereferences all $ref entries (inlining definitions) to ensure -compatibility with MCP clients that don't properly handle $ref in schemas -(e.g., VS Code Copilot). It also applies various optimizations to reduce -schema size. - **Args:** - `schema`: The schema to compress - `prune_params`: List of parameter names to remove from properties @@ -78,4 +73,7 @@ schema size. Defaults to False to maintain MCP client compatibility, as some clients (e.g., Claude) require additionalProperties\: false for strict validation. - `prune_titles`: Whether to remove title fields from the schema +- `dereference`: Whether to dereference $ref by inlining definitions. +Defaults to False; dereferencing is typically handled by +middleware at serve-time instead. From 0755b5e1b82ebbaf2097acff7ff5bf2a554478aa Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:53:22 -0500 Subject: [PATCH 4/7] fix: test_combined_operations expects $defs pruned when unreferenced --- tests/utilities/test_json_schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index f5153dc2dc..a337156d66 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -270,8 +270,8 @@ def test_combined_operations(self): assert "remove" not in result["properties"] # Check that required list was updated assert result["required"] == ["keep"] - # $defs are preserved (dereferencing is handled by middleware, not compress_schema) - assert "$defs" in result + # All $defs entries are now unreferenced after pruning "remove", so they're cleaned up + assert "$defs" not in result # Check that additionalProperties was removed assert "additionalProperties" not in result From 5f1cc324906d245046eb5d7cd7338521e4e09b7b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:28:08 -0500 Subject: [PATCH 5/7] docs: document dereference_refs opt-out on tools page --- docs/servers/tools.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 98c8c0951a..f3c2eb8419 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -175,6 +175,12 @@ By default, FastMCP converts Python functions into MCP tools by inspecting the f 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. + +Dereferencing happens at serve-time via middleware, so your schemas are stored with `$ref` intact and only inlined when sent to clients. If you know your clients handle `$ref` correctly and prefer smaller schemas, you can opt out: + +```python +mcp = FastMCP("my-server", dereference_refs=False) +``` ### Type Annotations From 614bdb42532fccbdf136bfd6a21022ce9fda195f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:36:14 -0500 Subject: [PATCH 6/7] rename dereference_refs kwarg to dereference_schemas --- docs/development/v3-notes/v3-features.mdx | 4 ++-- docs/servers/tools.mdx | 2 +- src/fastmcp/server/middleware/dereference.py | 2 +- src/fastmcp/server/server.py | 4 ++-- tests/server/middleware/test_caching.py | 2 +- tests/server/middleware/test_dereference.py | 22 ++++++++++---------- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 975651882f..67474880d6 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -72,10 +72,10 @@ Auth check functions can now be `async`, enabling authorization decisions that d ### Optional `$ref` Dereferencing in Schemas -Schema `$ref` dereferencing — which inlines all `$defs` for compatibility with MCP clients that don't handle `$ref` — is now controlled by the `dereference_refs` constructor kwarg ([#3141](https://github.com/jlowin/fastmcp/issues/3141)). Default is `True` (dereference on) because the non-compliant clients are popular and the failure mode is silent breakage that server authors can't diagnose. Opt out when you know your clients handle `$ref` and want smaller schemas: +Schema `$ref` dereferencing — which inlines all `$defs` for compatibility with MCP clients that don't handle `$ref` — is now controlled by the `dereference_schemas` constructor kwarg ([#3141](https://github.com/jlowin/fastmcp/issues/3141)). Default is `True` (dereference on) because the non-compliant clients are popular and the failure mode is silent breakage that server authors can't diagnose. Opt out when you know your clients handle `$ref` and want smaller schemas: ```python -mcp = FastMCP("my-server", dereference_refs=False) +mcp = FastMCP("my-server", dereference_schemas=False) ``` Dereferencing is implemented as middleware (`DereferenceRefsMiddleware`) that runs at serve-time, so schemas are stored with `$ref` intact and only inlined when sent to clients. diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index f3c2eb8419..ee5004ae61 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -179,7 +179,7 @@ FastMCP automatically dereferences `$ref` entries in tool schemas to ensure comp Dereferencing happens at serve-time via middleware, so your schemas are stored with `$ref` intact and only inlined when sent to clients. If you know your clients handle `$ref` correctly and prefer smaller schemas, you can opt out: ```python -mcp = FastMCP("my-server", dereference_refs=False) +mcp = FastMCP("my-server", dereference_schemas=False) ``` diff --git a/src/fastmcp/server/middleware/dereference.py b/src/fastmcp/server/middleware/dereference.py index 1462011414..89150d655e 100644 --- a/src/fastmcp/server/middleware/dereference.py +++ b/src/fastmcp/server/middleware/dereference.py @@ -17,7 +17,7 @@ class DereferenceRefsMiddleware(Middleware): Some MCP clients (e.g., VS Code Copilot) don't handle JSON Schema $ref properly. This middleware inlines all $ref definitions so schemas are - self-contained. Enabled by default via ``FastMCP(dereference_refs=True)``. + self-contained. Enabled by default via ``FastMCP(dereference_schemas=True)``. """ @override diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 8339a67e6b..9d13477898 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -229,7 +229,7 @@ def __init__( tools: Sequence[Tool | Callable[..., Any]] | None = None, on_duplicate: DuplicateBehavior | None = None, mask_error_details: bool | None = None, - dereference_refs: bool = True, + dereference_schemas: bool = True, strict_input_validation: bool | None = None, list_page_size: int | None = None, tasks: bool | None = None, @@ -323,7 +323,7 @@ def __init__( self.middleware: list[Middleware] = list(middleware or []) - if dereference_refs: + if dereference_schemas: from fastmcp.server.middleware.dereference import ( DereferenceRefsMiddleware, ) diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index e571c7368e..52e5c90c97 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -288,7 +288,7 @@ async def caching_server( request: pytest.FixtureRequest, ): """Create a FastMCP server for caching tests.""" - mcp = FastMCP("CachingTestServer", dereference_refs=False) + mcp = FastMCP("CachingTestServer", dereference_schemas=False) with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: disk_store: DiskStore = DiskStore(directory=temp_dir) diff --git a/tests/server/middleware/test_dereference.py b/tests/server/middleware/test_dereference.py index 215b944b86..0ababd7de1 100644 --- a/tests/server/middleware/test_dereference.py +++ b/tests/server/middleware/test_dereference.py @@ -19,11 +19,11 @@ class PaintRequest(pydantic.BaseModel): class TestDereferenceRefsMiddleware: - """End-to-end tests for the dereference_refs server kwarg.""" + """End-to-end tests for the dereference_schemas server kwarg.""" - async def test_dereference_refs_true_inlines_refs(self): - """With dereference_refs=True (default), tool schemas have $ref inlined.""" - mcp = FastMCP("test", dereference_refs=True) + async def test_dereference_schemas_true_inlines_refs(self): + """With dereference_schemas=True (default), tool schemas have $ref inlined.""" + mcp = FastMCP("test", dereference_schemas=True) @mcp.tool def paint(request: PaintRequest) -> str: @@ -38,9 +38,9 @@ def paint(request: PaintRequest) -> str: # The Color enum should be inlined into the request property assert "$ref" not in str(schema) - async def test_dereference_refs_false_preserves_refs(self): - """With dereference_refs=False, $ref and $defs are preserved.""" - mcp = FastMCP("test", dereference_refs=False) + async def test_dereference_schemas_false_preserves_refs(self): + """With dereference_schemas=False, $ref and $defs are preserved.""" + mcp = FastMCP("test", dereference_schemas=False) @mcp.tool def paint(request: PaintRequest) -> str: @@ -69,7 +69,7 @@ def paint(request: PaintRequest) -> str: async def test_does_not_mutate_original_tool(self): """Middleware should not mutate the shared Tool object.""" - mcp = FastMCP("test", dereference_refs=True) + mcp = FastMCP("test", dereference_schemas=True) @mcp.tool def paint(request: PaintRequest) -> str: @@ -89,7 +89,7 @@ def paint(request: PaintRequest) -> str: async def test_output_schema_dereferenced(self): """Middleware also dereferences output_schema when present.""" - mcp = FastMCP("test", dereference_refs=True) + mcp = FastMCP("test", dereference_schemas=True) @mcp.tool def paint(request: PaintRequest) -> PaintRequest: @@ -106,7 +106,7 @@ def paint(request: PaintRequest) -> PaintRequest: async def test_resource_templates_dereferenced(self): """Middleware dereferences resource template schemas.""" - mcp = FastMCP("test", dereference_refs=True) + mcp = FastMCP("test", dereference_schemas=True) @mcp.resource("paint://{color}") def get_paint(color: Color) -> str: @@ -121,7 +121,7 @@ def get_paint(color: Color) -> str: async def test_no_ref_schemas_unchanged(self): """Tools without $ref should pass through unmodified.""" - mcp = FastMCP("test", dereference_refs=True) + mcp = FastMCP("test", dereference_schemas=True) @mcp.tool def add(a: int, b: int) -> int: From 4965c96076286f3f257b857d4bddc82004b60ad9 Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:37:00 +0000 Subject: [PATCH 7/7] chore: Update SDK documentation --- docs/python-sdk/fastmcp-server-middleware-dereference.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/python-sdk/fastmcp-server-middleware-dereference.mdx b/docs/python-sdk/fastmcp-server-middleware-dereference.mdx index a9be34607d..702c7a1d73 100644 --- a/docs/python-sdk/fastmcp-server-middleware-dereference.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-dereference.mdx @@ -17,7 +17,7 @@ Dereferences $ref in component schemas before sending to clients. Some MCP clients (e.g., VS Code Copilot) don't handle JSON Schema $ref properly. This middleware inlines all $ref definitions so schemas are -self-contained. Enabled by default via ``FastMCP(dereference_refs=True)``. +self-contained. Enabled by default via ``FastMCP(dereference_schemas=True)``. **Methods:**