From 5745323ecd992c920096dcf7934c05c8830afba7 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:15:30 -0400 Subject: [PATCH] Cover trace propagation through the modern proxy relay --- .../fastmcp/server/providers/proxy.py | 4 +- .../server/telemetry/test_provider_tracing.py | 54 ++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py index c368af077..b3965446b 100644 --- a/fastmcp_slim/fastmcp/server/providers/proxy.py +++ b/fastmcp_slim/fastmcp/server/providers/proxy.py @@ -135,7 +135,9 @@ 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`. + 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. """ if client.protocol_version not in MODERN_PROTOCOL_VERSIONS: return await client.read_resource(uri) diff --git a/tests/server/telemetry/test_provider_tracing.py b/tests/server/telemetry/test_provider_tracing.py index 7118044a2..50c33f8fe 100644 --- a/tests/server/telemetry/test_provider_tracing.py +++ b/tests/server/telemetry/test_provider_tracing.py @@ -4,7 +4,9 @@ from __future__ import annotations from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from fastmcp import FastMCP +from fastmcp import Client, Context, FastMCP +from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient +from fastmcp.telemetry import TRACE_PARENT_KEY class TestFastMCPProviderTracing: @@ -131,3 +133,53 @@ class TestProviderSpanHierarchy: assert child_span.parent is not None assert delegate_span.parent.span_id == parent_span.context.span_id assert child_span.parent.span_id == delegate_span.context.span_id + + +class TestModernProxyTracePropagation: + """A modern proxy relays resources and prompts through the low-level + session so a backend guard's ask can surface (SEP-2322). That path skips + the high-level client's trace injection, so the relay must stamp the + outgoing `_meta` itself — otherwise every modern proxy read breaks the + distributed trace, not only the guard rounds.""" + + @staticmethod + def _backend(seen: dict[str, dict]) -> FastMCP: + backend = FastMCP("trace-backend") + + def record(kind: str, ctx: Context) -> None: + rc = ctx.request_context + seen[kind] = dict(rc.meta) if rc is not None and rc.meta else {} + + @backend.resource("data://x") + async def concrete(ctx: Context) -> str: + record("resource", ctx) + return "ok" + + @backend.resource("data://{part}/y") + async def templated(part: str, ctx: Context) -> str: + record("template", ctx) + return "ok" + + @backend.prompt + async def greet(ctx: Context) -> str: + record("prompt", ctx) + return "ok" + + return backend + + async def test_traceparent_reaches_backend( + self, trace_exporter: InMemorySpanExporter + ): + seen: dict[str, dict] = {} + proxy = FastMCPProxy( + client_factory=lambda: ProxyClient(self._backend(seen), mode="auto") + ) + + async with Client(proxy, mode="auto") as client: + await client.read_resource("data://x") + await client.read_resource("data://p/y") + await client.get_prompt("greet") + + assert TRACE_PARENT_KEY in seen["resource"] + assert TRACE_PARENT_KEY in seen["template"] + assert TRACE_PARENT_KEY in seen["prompt"]