Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
36 changes: 36 additions & 0 deletions docs/deployment/http.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,42 @@ export FASTMCP_HTTP_ALLOWED_ORIGINS='["https://app.example.com"]'

Use `host_origin_protection="auto"` to protect localhost-bound direct servers while allowing ASGI, serverless, and reverse-proxy deployments to keep their existing Host handling unless they configure explicit trust rules. Use `host_origin_protection=False` to keep the request guard disabled.

### Gateway Routing Headers

A gateway, load balancer, or reverse proxy in front of your MCP server often needs to route a request before it reads the JSON-RPC body — the body may be an SSE stream, or the gateway may simply want to avoid parsing it. Streamable HTTP clients built on the MCP Python SDK (including FastMCP's own client) attach routing information to each request as HTTP headers so an intermediary can dispatch on headers alone:

- `Mcp-Method` carries the JSON-RPC method (for example `tools/call`) on every request.

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 Gate routing-header docs on the modern protocol

For Streamable HTTP clients that negotiate the legacy protocol era (including Client(..., mode="legacy") or auto-fallback to a legacy server), the SDK's handshake stamp does not emit Mcp-Method, and Mcp-Param-* generation is only installed by the modern session stamp. Therefore the unconditional claim that these clients attach routing information to each request can lead gateways to depend on headers that are absent; qualify this section as requiring the modern 2026-07-28 protocol and document the legacy behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — fixed in 5be0c22.

The section now states up front that the headers are a feature of the modern 2026-07-28 protocol, and a new callout warns that a client on an earlier revision (including legacy mode or auto-fallback to a legacy server) sends no routing headers at all, advising gateway operators to treat the headers as optional — fall back to body inspection or a default backend rather than depending on their presence.

- `Mcp-Name` carries the target's name on named operations — the tool name for `tools/call`, the prompt name for `prompts/get`, the resource URI for `resources/read`.
- `Mcp-Param-*` carries selected argument values for a `tools/call`, one header per opted-in parameter.

FastMCP's HTTP transport neither strips nor rewrites these headers, so a gateway sees them exactly as the client sent them. The `Host`/`Origin` request guard inspects only `Host` and `Origin` and leaves the routing headers untouched.

To expose an argument as an `Mcp-Param-*` header, annotate the parameter with the `x-mcp-header` JSON Schema extension. FastMCP carries the annotation into the tool's advertised input schema, and a conforming client mirrors the argument into a header named `Mcp-Param-<token>`:

```python
from typing import Annotated

from pydantic import Field

from fastmcp import FastMCP

mcp = FastMCP("My Server")

@mcp.tool
def query_tenant(
tenant: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Tenant"})],
sql: str,
) -> str:
"""A call to this tool sends the tenant value as an `Mcp-Param-Tenant` header."""
...
```

A gateway can now route on `Mcp-Param-Tenant` — for example, pinning each tenant to a dedicated backend — without inspecting the request body. The annotation is only permitted on `string`, `integer`, and `boolean` parameters. These headers advertise routing intent; treat them as untrusted hints, since the server still validates the request body as the source of truth.

<Tip>
When you put a FastMCP [proxy](/servers/proxy) in front of another server, the proxy re-advertises each backend tool's `x-mcp-header` annotation, so routing headers work across the proxy hop as well. The headers themselves are regenerated per hop rather than forwarded verbatim, since each describes a single HTTP request.

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.

P1 Badge Fix routing headers across the HTTP proxy hop

With a Streamable HTTP backend, this documented proxy scenario fails rather than routing successfully: ProxyTool.run() creates a fresh backend client and calls client.session.call_tool() without first listing tools, so the SDK has not cached the advertised x-mcp-header annotation and omits Mcp-Param-Tenant; the modern backend then rejects the call with HEADER_MISMATCH because the body contains tenant without its required header. The added proxy test uses an in-process backend and only checks the re-advertised schema, so it never exercises the validating HTTP hop; add an HTTP round-trip test and ensure the backend session absorbs or lists the tool schema before calling it.

AGENTS.md reference: AGENTS.md:L146-L152

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 5be0c22.

You're right — this reproduces over a real HTTP hop. A modern Streamable-HTTP backend rejects the proxied call with HEADER_MISMATCH ("Mcp-Param-Tenant header is missing but the request body's 'tenant' argument is present"), because ProxyTool.run() calls client.session.call_tool() directly and the SDK only emits Mcp-Param-* for tools it has cached via list_tools. My earlier proxy test used an in-process backend and only checked the re-advertised schema, so it never exercised the validating hop.

Note the failure is gated on a modern backend: ProxyClient defaults to mode="legacy", which neither emits nor validates these headers, so the default path was unaffected — but mode="auto" against a modern backend fails.

The fix seeds the backend session's header map from the tool's already-mirrored input schema before the call, using the SDK's own x_mcp_header_map, so no extra list_tools round-trip is added and an unannotated tool produces no headers:

header_map = x_mcp_header_map(self.parameters)
if header_map:
    client.session._x_mcp_header_maps[backend_name] = header_map

New test test_proxy_forwards_mcp_param_header_to_modern_http_backend puts a FastMCP proxy (mode="auto") in front of a real modern Streamable-HTTP backend with an x-mcp-header-annotated param and asserts the call routes through successfully — it reproduces the failure without the fix and passes with it.

</Tip>

### Health Checks

Health check endpoints are essential for monitoring your deployed server and ensuring it's responding correctly. FastMCP allows you to add custom routes alongside your MCP endpoints, making it easy to implement health checks that work with both deployment approaches.
Expand Down
89 changes: 89 additions & 0 deletions tests/server/http/test_routable_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Routable transport headers (SEP-2243) survive a FastMCP HTTP round trip.

The MCP Python SDK emits the routing headers on the client (`ClientSession`) and
validates them on the modern streamable-HTTP server transport. These tests are
FastMCP's regression guard: they prove FastMCP's HTTP layer neither strips nor
blocks the headers, so a gateway sitting in front of a FastMCP server can route
on them. The tool echoes back the raw request headers it received, letting the
test assert on exactly what reached the server.
"""

from typing import Annotated

from pydantic import Field

from fastmcp.server.dependencies import get_http_request
from fastmcp.server.server import FastMCP
from fastmcp.utilities.tests import asgi_server


def _echo_server() -> FastMCP:
server = FastMCP()

@server.tool
def echo_headers(
tenant: Annotated[
str, Field(json_schema_extra={"x-mcp-header": "Tenant"})
] = "acme",
) -> dict[str, str]:
"""Return the raw HTTP headers the server received for this request."""
return dict(get_http_request().headers)

return server


async def test_mcp_method_and_name_headers_reach_server():
"""`Mcp-Method` and `Mcp-Name` set by the SDK client arrive at the server."""
async with asgi_server(_echo_server(), transport="http") as running_server:
async with running_server.client() as client:
result = await client.call_tool("echo_headers")

headers = result.data
assert headers["mcp-method"] == "tools/call"
assert headers["mcp-name"] == "echo_headers"


async def test_mcp_param_header_reaches_server():
"""An `x-mcp-header` annotated parameter is mirrored into `Mcp-Param-*`.

The SDK client only emits `Mcp-Param-*` once it has seen the tool's input
schema, so the test lists tools before calling.
"""
async with asgi_server(_echo_server(), transport="http") as running_server:
async with running_server.client() as client:
await client.list_tools()
result = await client.call_tool("echo_headers", {"tenant": "beta-corp"})

headers = result.data
assert headers["mcp-param-tenant"] == "beta-corp"


async def test_routing_headers_survive_host_origin_protection():
"""The Host/Origin request guard does not strip the routing headers."""
async with asgi_server(
_echo_server(),
transport="http",
host_origin_protection=True,
allowed_hosts=["*"],
allowed_origins=["*"],
) as running_server:
async with running_server.client() as client:
await client.list_tools()
result = await client.call_tool("echo_headers", {"tenant": "gamma"})

headers = result.data
assert headers["mcp-method"] == "tools/call"
assert headers["mcp-name"] == "echo_headers"
assert headers["mcp-param-tenant"] == "gamma"


async def test_x_mcp_header_annotation_survives_schema_generation():
"""FastMCP preserves `x-mcp-header` in a tool's advertised input schema.

This is the annotation the SDK client reads to decide which arguments to
mirror into `Mcp-Param-*` headers, so it must reach the wire unchanged.
"""
server = _echo_server()
tools = await server._list_tools()
(tool,) = [t for t in tools if t.name == "echo_headers"]
assert tool.parameters["properties"]["tenant"]["x-mcp-header"] == "Tenant"
28 changes: 28 additions & 0 deletions tests/server/providers/proxy/test_proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1554,3 +1554,31 @@ async def test_connection_error_reaches_client_on_both_eras(self, mode: str):
with pytest.raises(MCPError, match="Client failed to connect"):
async with Client(proxy, mode=mode) as client:
await client.list_tools()


async def test_proxy_preserves_x_mcp_header_annotation():
"""A proxy re-advertises a backend tool's `x-mcp-header` annotation (SEP-2243).

The routing headers are per-hop: the SDK client regenerates them on each
HTTP request. For `Mcp-Param-*` to be emitted on the proxy->backend hop (and
on the caller->proxy hop), the proxy must carry the backend's `x-mcp-header`
schema annotation through to its own advertised tool schema.
"""
from typing import Annotated

from pydantic import Field

backend = FastMCP("Backend")

@backend.tool
def route(
tenant: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Tenant"})],
) -> str:
return tenant

proxy = create_proxy(backend)
async with Client(proxy) as client:
tools = await client.list_tools()

(tool,) = [t for t in tools if t.name == "route"]
assert tool.input_schema["properties"]["tenant"]["x-mcp-header"] == "Tenant"
Loading