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
40 changes: 40 additions & 0 deletions docs/deployment/http.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,46 @@ 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. On a connection that negotiates the modern `2026-07-28` protocol, 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.
- `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.

<Warning>
These headers are a feature of the modern `2026-07-28` protocol. A client connected over an earlier protocol revision — including one running in legacy mode or one that has fallen back to a legacy server — sends no routing headers at all. Design gateway routing to require the headers rather than assume their presence: if a request arrives without them, fall back to inspecting the body or route it to a default backend, rather than dropping it.
</Warning>

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
13 changes: 13 additions & 0 deletions fastmcp_slim/fastmcp/server/providers/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from mcp.server.connection import Connection
from mcp.server.context import ServerRequestContext
from mcp.shared.exceptions import MCPError
from mcp.shared.inbound import x_mcp_header_map
from mcp_types import (
METHOD_NOT_FOUND,
BlobResourceContents,
Expand Down Expand Up @@ -289,6 +290,18 @@ async def run(
"mcp_types.RequestParamsMeta | None",
inject_trace_context(meta) or None,
)
# SEP-2243: a modern backend rejects a `tools/call` whose
# `x-mcp-header` argument is not mirrored into an `Mcp-Param-*`
# header. The SDK client emits those headers only for tools it
# has listed (it caches the annotation map on `list_tools`),
# but a proxied call goes straight to `call_tool` on a fresh
# session. Seed the session's map from the backend tool's
# advertised schema so the header is emitted and the call is
# accepted; an unannotated schema yields an empty map and no
# headers, matching the client's own behavior.
header_map = x_mcp_header_map(self.parameters)
if header_map:
client.session._x_mcp_header_maps[backend_name] = header_map
result = await client._await_with_session_monitoring(
client.session.call_tool(
name=backend_name,
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"
61 changes: 61 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,64 @@ 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"


async def test_proxy_forwards_mcp_param_header_to_modern_http_backend():
"""A proxy in front of a modern Streamable-HTTP backend routes an annotated call (SEP-2243).

A modern backend validates that an `x-mcp-header` argument is mirrored into an
`Mcp-Param-*` header and rejects the call with `HEADER_MISMATCH` when it is
missing. The SDK client caches the annotation map on `list_tools`, but a
proxied `tools/call` goes straight to `call_tool` on a fresh backend session,
so the proxy must seed the map itself. This exercises the real validating HTTP
hop end to end.
"""
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 f"routed:{tenant}"

async with run_server_async(backend, transport="http") as url:
# mode="auto" negotiates the modern protocol with the HTTP backend, so
# the proxy->backend hop is the validating one. (ProxyClient defaults to
# legacy, which neither emits nor validates these headers.)
proxy = create_proxy(ProxyClient(StreamableHttpTransport(url), mode="auto"))
async with Client(proxy) as client:
result = await client.call_tool("route", {"tenant": "acme"})

assert result.data == "routed:acme"
Loading