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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jake Kaplan 2026-08-05 15:39:38 -04:00
commit a6cd3fdff1
2 changed files with 79 additions and 12 deletions

View file

@ -161,6 +161,19 @@ def _forwardable_request_meta(ctx: Context | None) -> dict[str, Any] | None:
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
) -> (
@ -174,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,
@ -357,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
@ -731,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.
@ -738,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,
@ -747,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

@ -46,14 +46,32 @@ class _FrontendExtension(ClientExtension):
def _recording_backend(seen: dict[str, _RecordedRequest]) -> FastMCP:
backend = FastMCP("metadata-backend")
@backend.tool
def inspect_tool(ctx: Context) -> str:
def record(operation: str, ctx: Context) -> None:
request_context = ctx.request_context
assert request_context is not None
seen["tool"] = _RecordedRequest(
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
@ -134,3 +152,40 @@ async def test_forwarded_tool_meta_stays_hop_safe(
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)