diff --git a/docs/servers/providers/proxy.mdx b/docs/servers/providers/proxy.mdx index 2be18cefd..df09b1c80 100644 --- a/docs/servers/providers/proxy.mdx +++ b/docs/servers/providers/proxy.mdx @@ -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 diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py index 9b1cd4a69..a304eacc3 100644 --- a/fastmcp_slim/fastmcp/server/providers/proxy.py +++ b/fastmcp_slim/fastmcp/server/providers/proxy.py @@ -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 diff --git a/tests/server/providers/proxy/test_proxy_client.py b/tests/server/providers/proxy/test_proxy_client.py index caa4b9075..53b37129a 100644 --- a/tests/server/providers/proxy/test_proxy_client.py +++ b/tests/server/providers/proxy/test_proxy_client.py @@ -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)) diff --git a/tests/server/providers/proxy/test_proxy_request_meta.py b/tests/server/providers/proxy/test_proxy_request_meta.py new file mode 100644 index 000000000..4776eedb5 --- /dev/null +++ b/tests/server/providers/proxy/test_proxy_request_meta.py @@ -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) diff --git a/tests/server/providers/proxy/test_proxy_server.py b/tests/server/providers/proxy/test_proxy_server.py index 8971b5ba0..fe489d56f 100644 --- a/tests/server/providers/proxy/test_proxy_server.py +++ b/tests/server/providers/proxy/test_proxy_server.py @@ -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 diff --git a/tests/server/providers/proxy/test_stateful_proxy_client.py b/tests/server/providers/proxy/test_stateful_proxy_client.py index 1b64afd2d..0bf832440 100644 --- a/tests/server/providers/proxy/test_stateful_proxy_client.py +++ b/tests/server/providers/proxy/test_stateful_proxy_client.py @@ -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)