Sanitize forwarded request metadata where the proxy copies it (#4770)

* Separate proxy protocol policy from client construction

🤖 Generated with OpenAI Codex

* Strip connection-owned request metadata at the proxy backend boundary

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Sanitize forwarded request metadata where the proxy copies it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Forward hop-safe request metadata for proxied resources, templates, and prompts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jake Kaplan 2026-08-05 15:46:10 -04:00 committed by GitHub
commit 959daf2321
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 263 additions and 53 deletions

View file

@ -240,6 +240,10 @@ proxy = create_proxy(
A modern client here reaches both `weather` and `calendar` on modern sessions, so a guard tool on either one round-trips end to end. An explicit `mode` pins every backend in the configuration, the same way it pins a single one.
### Request Metadata
Request `_meta` follows the same connection boundary. Progress tokens, tracing, task state, and application or vendor metadata pass through the proxy to the backend. The connection-owned keys — protocol version, client identity, and client capabilities — never copy from the frontend connection: a modern backend session stamps its own negotiated values, and a handshake-era backend receives none. This holds even when the two connections negotiate different eras, such as a modern client reaching a handshake-only backend through an explicit `mode`.
## Configuration-Based Proxies
<VersionBadge version="2.4.0" />

View file

@ -130,6 +130,50 @@ def _proxy_upstream_error(error: Exception) -> MCPError:
)
# Request `_meta` keys that describe one negotiated MCP connection. They never
# cross the proxy: a modern backend session stamps its own negotiated values on
# every request, and a handshake-era backend must not receive them at all.
_CONNECTION_META_KEYS = frozenset(
{
mcp_types.PROTOCOL_VERSION_META_KEY,
mcp_types.CLIENT_INFO_META_KEY,
mcp_types.CLIENT_CAPABILITIES_META_KEY,
}
)
def _forwardable_request_meta(ctx: Context | None) -> dict[str, Any] | None:
"""Frontend request metadata that may cross onto the backend connection.
This is the proxy's one sanctioned read of the inbound request's `_meta`:
progress tokens, tracing, task, and application metadata pass through,
while connection-owned keys (`_CONNECTION_META_KEYS`) are dropped because
they describe the frontend connection, not the backend one.
"""
request_context = ctx.request_context if ctx is not None else None
if request_context is None or not request_context.meta:
return None
forwarded = {
key: value
for key, value in request_context.meta.items()
if key not in _CONNECTION_META_KEYS
}
return forwarded or None
def _session_request_meta(
meta: dict[str, Any] | None,
) -> mcp_types.RequestParamsMeta | None:
"""Adapt forwardable metadata for a direct backend-session call.
Direct session calls bypass the high-level client mixins, so trace context
is injected here, matching what the mixins do on the legacy client paths.
"""
return cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context(meta) or None
)
async def _relay_read_resource(
client: Client, uri: str, ctx: Context | None
) -> (
@ -143,15 +187,15 @@ async def _relay_read_resource(
to forward, instead of the high-level client trying to answer it here the
proxy has no back-channel to the real user, so driving it fails outright.
The inbound request's continuation state travels down so the backend guard
sees the client's answers on its own `ctx.input_responses`. Trace context
still propagates: the SDK's JSON-RPC dispatcher injects it on every outgoing
request (SEP-414), below whichever client layer issued the call.
sees the client's answers on its own `ctx.input_responses`.
"""
meta = _forwardable_request_meta(ctx)
if client.protocol_version not in MODERN_PROTOCOL_VERSIONS:
return await client.read_resource(uri)
return await client.read_resource(uri, meta=meta)
result = await client._await_with_session_monitoring(
client.session.read_resource(
uri,
meta=_session_request_meta(meta),
input_responses=ctx.input_responses if ctx else None,
request_state=ctx.request_state if ctx else None,
allow_input_required=True,
@ -311,15 +355,11 @@ class ProxyTool(Tool):
async with client:
ctx = context or get_context()
_stash_proxy_request_context(client, ctx)
# Forward the inbound request's `_meta` block (trace context,
# version, etc.) to the backend. In SDK v2 the request context
# exposes the lifted `_meta` dict directly; task submission is a
# first-class params field rather than context state, so there
# is no separate task-metadata injection here.
req_ctx = ctx.request_context
meta: dict[str, Any] | None = (
dict(req_ctx.meta) if req_ctx is not None and req_ctx.meta else None
)
# Forward the inbound request's hop-safe `_meta` (trace
# context, progress token, etc.) to the backend. Task
# submission is a first-class params field rather than context
# state, so there is no separate task-metadata injection here.
meta = _forwardable_request_meta(ctx)
if client.protocol_version in MODERN_PROTOCOL_VERSIONS:
# Modern backend: call the session directly (not
@ -330,10 +370,7 @@ class ProxyTool(Tool):
# round. Forward the inbound request's continuation state
# down so the backend guard tool sees the client's answers
# on its own `ctx.input_responses` / `ctx.request_state`.
request_meta = cast(
"mcp_types.RequestParamsMeta | None",
inject_trace_context(meta) or None,
)
request_meta = _session_request_meta(meta)
# 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
@ -704,6 +741,7 @@ class ProxyPrompt(Prompt):
ctx = get_context()
async with client:
_stash_proxy_request_context(client, ctx)
meta = _forwardable_request_meta(ctx)
if client.protocol_version in MODERN_PROTOCOL_VERSIONS:
# See `_relay_read_resource`: surface a backend guard's ask
# instead of trying to answer it inside the proxy.
@ -711,6 +749,7 @@ class ProxyPrompt(Prompt):
client.session.get_prompt(
backend_name,
arguments,
meta=_session_request_meta(meta),
input_responses=ctx.input_responses if ctx else None,
request_state=ctx.request_state if ctx else None,
allow_input_required=True,
@ -720,7 +759,7 @@ class ProxyPrompt(Prompt):
return InputRequiredPromptResult(raw)
result = raw
else:
result = await client.get_prompt(backend_name, arguments)
result = await client.get_prompt(backend_name, arguments, meta=meta)
# Convert GetPromptResult to PromptResult, preserving meta from result
# (not the static prompt meta which includes fastmcp tags)
# Convert PromptMessages to Messages

View file

@ -150,19 +150,11 @@ async def proxy_server(fastmcp_server: FastMCP):
`ProxyClient(fastmcp_server)` defaults to `mode="legacy"` (see
`TestProxyClientEraDefault` above a directly-constructed `ProxyClient`
always pins the handshake era, independent of `create_proxy`'s era
mirroring). Every test below that forwards a tool call through this
fixture (not just a listing) needs its front `Client` pinned to
`mode="legacy"` too, for either or both of two reasons:
- The test's subject is itself a handshake-only feature (roots / sampling
/ elicitation push, logging, progress): the modern era has no
back-channel for server-initiated requests at all, so these forwarding
paths cannot exist there.
- Even for subjects that work on both eras, a modern front's request
`_meta` carries reserved modern-envelope keys that `ProxyTool.run`'s
legacy-backend path forwards verbatim onto this legacy-locked backend
session, which the backend server then rejects as a protocol
violation.
mirroring). Tests below that exercise a handshake-only feature (roots /
sampling / elicitation push, logging, progress) pin their front `Client`
to `mode="legacy"` too: the modern era has no back-channel for
server-initiated requests at all, so these forwarding paths cannot exist
there.
"""
return create_proxy(ProxyClient(fastmcp_server))

View file

@ -0,0 +1,191 @@
"""Request `_meta` ownership at the proxy's backend connection boundary.
Protocol version, client identity, and client capabilities describe one
negotiated MCP connection. The proxy must never copy them from its frontend
connection onto its backend connection: a modern backend session stamps its
own values, and a handshake-era backend must not receive them at all.
Progress, tracing, task, and application metadata pass through untouched.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import mcp_types
import pytest
from mcp.client.extension import ClientExtension
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from fastmcp import Client, Context, FastMCP
from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient
FRONT_EXTENSION_ID = "example.com/frontend"
FRONT_INFO = mcp_types.Implementation(name="frontend-client", version="1.0")
BACKEND_INFO = mcp_types.Implementation(name="proxy-backend", version="1.0")
RESERVED_META_KEYS = {
mcp_types.PROTOCOL_VERSION_META_KEY,
mcp_types.CLIENT_INFO_META_KEY,
mcp_types.CLIENT_CAPABILITIES_META_KEY,
}
@dataclass
class _RecordedRequest:
protocol_version: str
meta: dict[str, Any]
class _FrontendExtension(ClientExtension):
identifier = FRONT_EXTENSION_ID
def settings(self) -> dict[str, Any]:
return {"frontend": True}
def _recording_backend(seen: dict[str, _RecordedRequest]) -> FastMCP:
backend = FastMCP("metadata-backend")
def record(operation: str, ctx: Context) -> None:
request_context = ctx.request_context
assert request_context is not None
seen[operation] = _RecordedRequest(
protocol_version=request_context.protocol_version,
meta=dict(request_context.meta or {}),
)
@backend.tool
def inspect_tool(ctx: Context) -> str:
record("tool", ctx)
return "ok"
@backend.resource("data://metadata")
def inspect_resource(ctx: Context) -> str:
record("resource", ctx)
return "ok"
@backend.resource("data://items/{item_id}")
def inspect_template(item_id: str, ctx: Context) -> str:
record("template", ctx)
return "ok"
@backend.prompt
def inspect_prompt(ctx: Context) -> str:
record("prompt", ctx)
return "ok"
return backend
def _proxy(
backend: FastMCP, *, backend_mode: str, client_class: type[Client]
) -> FastMCPProxy:
return FastMCPProxy(
client_factory=lambda: client_class(
backend,
mode=backend_mode,
client_info=BACKEND_INFO,
)
)
def _assert_backend_connection_meta(record: _RecordedRequest, modern: bool) -> None:
"""The backend request carries the backend connection's own envelope.
On a handshake-era backend the reserved keys are absent. On a modern
backend they hold the backend session's negotiated version and the proxy
client's identity and capabilities — never the frontend client's.
"""
meta = record.meta
if not modern:
assert RESERVED_META_KEYS.isdisjoint(meta)
return
assert meta[mcp_types.PROTOCOL_VERSION_META_KEY] == record.protocol_version
assert meta[mcp_types.CLIENT_INFO_META_KEY] == BACKEND_INFO.model_dump(
by_alias=True, mode="json", exclude_none=True
)
capabilities = meta[mcp_types.CLIENT_CAPABILITIES_META_KEY]
assert FRONT_EXTENSION_ID not in capabilities.get("extensions", {})
# Every allowed ClientFactoryT shape must be hop-safe, not just ProxyClient:
# a plain Client backend runs the SDK's stock ClientSession rather than the
# proxy's session class, so it exercises the copy-site sanitization alone.
@pytest.mark.parametrize("client_class", [ProxyClient, Client])
@pytest.mark.parametrize(
("front_mode", "backend_mode", "backend_is_modern"),
[
("auto", "auto", True),
("auto", "legacy", False),
("legacy", "auto", True),
("legacy", "legacy", False),
],
)
async def test_forwarded_tool_meta_stays_hop_safe(
front_mode: str,
backend_mode: str,
backend_is_modern: bool,
client_class: type[Client],
):
seen: dict[str, _RecordedRequest] = {}
proxy = _proxy(
_recording_backend(seen), backend_mode=backend_mode, client_class=client_class
)
async with Client(
proxy,
mode=front_mode,
client_info=FRONT_INFO,
extensions=[_FrontendExtension()],
) as client:
await client.call_tool(
"inspect_tool",
meta={
"progressToken": "front-progress",
"example.com/vendor": {"request": "kept"},
},
)
record = seen["tool"]
assert (record.protocol_version in MODERN_PROTOCOL_VERSIONS) is backend_is_modern
assert isinstance(record.meta["progressToken"], str | int)
assert record.meta["example.com/vendor"] == {"request": "kept"}
_assert_backend_connection_meta(record, backend_is_modern)
@pytest.mark.parametrize("client_class", [ProxyClient, Client])
@pytest.mark.parametrize(
("backend_mode", "backend_is_modern"),
[("auto", True), ("legacy", False)],
)
@pytest.mark.parametrize("operation", ["resource", "template", "prompt"])
async def test_non_tool_requests_forward_hop_safe_metadata(
operation: str,
backend_mode: str,
backend_is_modern: bool,
client_class: type[Client],
):
seen: dict[str, _RecordedRequest] = {}
proxy = _proxy(
_recording_backend(seen), backend_mode=backend_mode, client_class=client_class
)
meta = {"example.com/vendor": {"operation": operation}}
async with Client(
proxy,
mode="auto",
client_info=FRONT_INFO,
extensions=[_FrontendExtension()],
) as client:
if operation == "resource":
await client.read_resource("data://metadata", meta=meta)
elif operation == "template":
await client.read_resource("data://items/42", meta=meta)
else:
await client.get_prompt("inspect_prompt", meta=meta)
record = seen[operation]
assert (record.protocol_version in MODERN_PROTOCOL_VERSIONS) is backend_is_modern
assert record.meta["example.com/vendor"] == {"operation": operation}
_assert_backend_connection_meta(record, backend_is_modern)

View file

@ -172,13 +172,7 @@ async def proxy_server(fastmcp_server):
raw `FastMCP`/URL/etc.) means `create_proxy` reuses that client as-is
instead of building one through the era-mirroring factory so this
backend stays pinned to `ProxyClient`'s own default of `mode="legacy"`
regardless of what era the front client negotiates. A test that actually
forwards a tool *call* through this fixture (not just a listing) needs
its own front `Client` pinned to `mode="legacy"` too: otherwise a modern
front's request `_meta` carries the reserved modern-envelope keys, which
`ProxyTool.run`'s legacy-backend path forwards verbatim onto this
legacy-locked backend session, and the backend server rejects it as a
protocol violation.
regardless of what era the front client negotiates.
"""
return create_proxy(ProxyClient(transport=FastMCPTransport(fastmcp_server)))
@ -1250,11 +1244,7 @@ class TestProxyOutputSchemaEnforcement:
# This proxy's backend is built via `ProxyProvider(lambda: ProxyClient(...))`
# directly rather than through `create_proxy`'s era-mirroring factory, so it
# stays pinned to `ProxyClient`'s own default of `mode="legacy"` regardless
# of the front era (see the `proxy_server` fixture docstring above for the
# full explanation). Pin the end client to match: a modern front's request
# `_meta` carries reserved modern-envelope keys that `ProxyTool.run`'s
# legacy-backend path forwards verbatim, and this legacy-locked backend
# session rejects them as a protocol violation.
# of the front era (see the `proxy_server` fixture docstring above).
client = Client(server, mode="legacy")
client._transport_options = TransportOptions(
session_class=_ForwardingClientSession
@ -1428,10 +1418,7 @@ class TestProxyForwardingAppliesToEveryBackendClient:
# era. A multi-server config instead mounts a router with a
# StatefulProxyClient per configured server leg — an already-constructed
# ProxyClient subclass, same as the `proxy_server` fixture above, pinned
# to `mode="legacy"` regardless of the front. Callers with that backend
# shape must pin the end client to legacy too, for the reason explained
# there (a modern front's request `_meta` gets forwarded verbatim onto a
# legacy-locked backend session and rejected as a protocol violation).
# to `mode="legacy"` regardless of the front.
client = Client(server, mode=mode)
client._transport_options = TransportOptions(
session_class=_ForwardingClientSession

View file

@ -62,12 +62,9 @@ async def stateful_proxy_server(fastmcp_server: FastMCP):
# `mode="legacy"` default for a directly-constructed instance (see
# `TestProxyClientEraDefault` in test_proxy_client.py) — this backend isn't
# built through `create_proxy`'s era-mirroring factory, so it stays pinned
# regardless of the front era. Every test below that forwards a real tool
# call through this fixture pins its front `Client` to `mode="legacy"` too:
# otherwise a modern front's request `_meta` carries reserved
# modern-envelope keys that `ProxyTool.run`'s legacy-backend path forwards
# verbatim, and this legacy-locked backend session rejects them as a
# protocol violation.
# regardless of the front era. Tests of handshake-only forwarding pin their
# front `Client` to `mode="legacy"` too: those server-initiated
# interactions do not exist on modern connections.
client = StatefulProxyClient(transport=FastMCPTransport(fastmcp_server))
return FastMCPProxy(client_factory=client.new_stateful)