mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
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:
parent
8661193411
commit
959daf2321
6 changed files with 263 additions and 53 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
191
tests/server/providers/proxy/test_proxy_request_meta.py
Normal file
191
tests/server/providers/proxy/test_proxy_request_meta.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue