diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py index 80e43c31f..a304eacc3 100644 --- a/fastmcp_slim/fastmcp/server/providers/proxy.py +++ b/fastmcp_slim/fastmcp/server/providers/proxy.py @@ -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 diff --git a/tests/server/providers/proxy/test_proxy_request_meta.py b/tests/server/providers/proxy/test_proxy_request_meta.py index bf64ca342..4776eedb5 100644 --- a/tests/server/providers/proxy/test_proxy_request_meta.py +++ b/tests/server/providers/proxy/test_proxy_request_meta.py @@ -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)