Support routable transport headers for gateways (SEP-2243) (#4622)

This commit is contained in:
Jeremiah Lowin 2026-07-26 13:40:09 -04:00 committed by GitHub
commit 2f992f71ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 203 additions and 0 deletions

View file

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

View file

@ -21,6 +21,7 @@ from mcp import ClientSession
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,
@ -289,6 +290,18 @@ class ProxyTool(Tool):
"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,

View file

@ -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"

View file

@ -1554,3 +1554,64 @@ class TestProxyProviderTransportErrors:
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"