-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Support routable transport headers for gateways (SEP-2243) #4622
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
| - `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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With a Streamable HTTP backend, this documented proxy scenario fails rather than routing successfully: AGENTS.md reference: AGENTS.md:L146-L152 Useful? React with 👍 / 👎.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Note the failure is gated on a modern backend: 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 header_map = x_mcp_header_map(self.parameters)
if header_map:
client.session._x_mcp_header_maps[backend_name] = header_mapNew test |
||
| </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. | ||
|
|
||
| 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" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 emitMcp-Method, andMcp-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 👍 / 👎.
There was a problem hiding this comment.
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-28protocol, 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.