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
10 changes: 10 additions & 0 deletions docs/development/v3-notes/v3-features.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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_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_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.

### 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.
Expand Down
35 changes: 35 additions & 0 deletions docs/python-sdk/fastmcp-server-middleware-dereference.mdx
Original file line number Diff line number Diff line change
@@ -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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/dereference.py#L15" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>


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_schemas=True)``.


**Methods:**

#### `on_list_tools` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/dereference.py#L24" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>

```python
on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool]
```

#### `on_list_resource_templates` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/dereference.py#L33" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>

```python
on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate]]) -> Sequence[ResourceTemplate]
```
96 changes: 48 additions & 48 deletions docs/python-sdk/fastmcp-server-server.mdx

Large diffs are not rendered by default.

10 changes: 4 additions & 6 deletions docs/python-sdk/fastmcp-utilities-json_schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,22 +60,20 @@ the referenced definition while preserving $defs for nested references.
### `compress_schema` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/json_schema.py#L364" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>

```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
- `prune_additional_properties`: Whether to remove additionalProperties\: false.
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.

6 changes: 6 additions & 0 deletions docs/servers/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ By default, FastMCP converts Python functions into MCP tools by inspecting the f

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

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_schemas=False)
```
</Note>

### Type Annotations
Expand Down
78 changes: 78 additions & 0 deletions src/fastmcp/server/middleware/dereference.py
Original file line number Diff line number Diff line change
@@ -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_schemas=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
8 changes: 8 additions & 0 deletions src/fastmcp/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_schemas: bool = True,
strict_input_validation: bool | None = None,
list_page_size: int | None = None,
tasks: bool | None = None,
Expand Down Expand Up @@ -322,6 +323,13 @@ def __init__(

self.middleware: list[Middleware] = list(middleware or [])

if dereference_schemas:
from fastmcp.server.middleware.dereference import (
DereferenceRefsMiddleware,
)

self.middleware.append(DereferenceRefsMiddleware())

# Set up MCP protocol handlers
self._setup_handlers()

Expand Down
33 changes: 17 additions & 16 deletions src/fastmcp/utilities/json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,38 +366,39 @@ 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
prune_additional_properties: Whether to remove additionalProperties: false.
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)
Comment on lines +385 to +386
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve root $ref normalization when dereference=False

Guarding dereference_refs() behind if dereference: removes the resolve_root_ref() fallback for schemas with a root-level $ref, so callers that now pass through compress_schema(..., dereference=False) can emit output schemas without a root type: object; this is a regression for recursive/self-referential return models when users set FastMCP(dereference_refs=False), because list_tools can return MCP-incompatible outputSchema payloads in that mode.

Useful? React with 👍 / 👎.


# 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
2 changes: 1 addition & 1 deletion tests/server/middleware/test_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_schemas=False)

with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
disk_store: DiskStore = DiskStore(directory=temp_dir)
Expand Down
136 changes: 136 additions & 0 deletions tests/server/middleware/test_dereference.py
Original file line number Diff line number Diff line change
@@ -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_schemas server kwarg."""

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:
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_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:
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_schemas=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_schemas=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_schemas=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_schemas=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"
Loading