Skip to content

feat(mcp): opt-in W3C trace-context propagation for MCP tool calls - #78965

Open
jy-cimcloud wants to merge 1 commit into
NousResearch:mainfrom
jy-cimcloud:mcp-per-call-trace-propagation
Open

feat(mcp): opt-in W3C trace-context propagation for MCP tool calls#78965
jy-cimcloud wants to merge 1 commit into
NousResearch:mainfrom
jy-cimcloud:mcp-per-call-trace-propagation

Conversation

@jy-cimcloud

Copy link
Copy Markdown

What does this PR do?

When an OpenTelemetry-instrumented agent calls a tool on an instrumented MCP server, the two sides today emit disconnected traces: the agent has a span for the tool call, the server has spans for serving it, and nothing joins them — you cannot answer "which MCP call made this turn slow" in a trace viewer. This PR injects a W3C traceparent header on the HTTP transport around each tool-call RPC, making the server's spans children of the agent's span.

Off by default, opt-in via config:

mcp:
  trace_propagation: true

Relationship to #60466

#60466 attempts the same feature and stalled on review. This PR is built around that review rather than against it — credit to #60466 for opening the problem, and to @teknium1 for pinpointing why connection-time injection can't work:

  • Per-call injection, not connection-time. One ClientSession serves many calls; a header set when the transport is established pins every later call to one stale span. Here the header exists on the shared httpx client for exactly one RPC (set and restored under the per-server RPC lock that already serializes calls on a session).
  • Cross-thread capture handled where it must be. MCP RPCs run on the daemon loop; trace.get_current_span() there observes nothing because contextvars don't cross run_coroutine_threadsafe. The tool handler captures the caller's context on the agent thread, before the boundary, and hands the formatted header across. There's a test demonstrating exactly this thread-visibility property.
  • Opt-in gating per AGENTS.md: config gate, default off, documented in config.yaml terms (no env vars). To be clear about scope: this propagates the user's own trace ids to the user's own MCP servers on requests the agent already makes — no analytics, no third-party attribution. Happy to wire a setup-wizard toggle if maintainers want one.
  • Tests — 25 unit tests covering the gate, capture (provider + standard API, including the wrong-thread case), W3C validation, and injection scoping.

If maintainers would rather see this folded into #60466, I'm glad to coordinate with its author instead — flagging per the CONTRIBUTING duplicate-PR guidance.

Attribution

The trace context is read via the standard OTel propagation API, so any instrumentation that sets ambient context works unmodified. For tracing plugins that keep their own span registry, there's a provider hook — designed so briancaffey/hermes-otel's get_current_traceparent (their issue #25) slots straight in:

from tools.mcp_trace_propagation import register_traceparent_provider
register_traceparent_provider(get_current_traceparent)

This design has been running in production at CIMcloud since July as a maintained override of tools/mcp_tool.py (rebased across v2026.7.20 → v2026.7.30 → v2026.8.3), producing joined agent→MCP-server traces for a fleet of internal MCP servers. This PR is the native, plugin-agnostic version of that patch, and would let us delete the override.

Related Issue

Fixes #52211

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

  • tools/mcp_trace_propagation.py (new) — the gate, capture (standard propagation API + validated provider hook), and injected_headers() per-RPC scoping. Stdlib-only imports; OpenTelemetry imported lazily and optionally; no path raises into a tool call.
  • tools/mcp_tool.py — minimal diff: expose the HTTP transport's shared httpx client on the server object (_http_client, cleared when the transport winds down; stdio stays None), capture traceparent in the call_tool handler on the agent thread, wrap the session.call_tool() await with injected_headers().
  • tests/test_mcp_trace_propagation.py (new) — 25 tests, stdlib + pytest + monkeypatch only, no network, passes with or without the OTel SDK installed.
  • website/docs/user-guide/features/mcp.md — "Trace propagation (OpenTelemetry)" section.

How to Test

  1. pytest tests/test_mcp_trace_propagation.py -q → 25 passed.
  2. Set mcp.trace_propagation: true, run an OTel-instrumented agent against an instrumented HTTP MCP server (e.g. FastMCP + OTLP exporter), call any tool: the server's spans now carry the agent's trace id — one joined trace in your viewer.
  3. Gate off (default): byte-identical requests to today — no header, no OTel import.

🤖 Generated with Claude Code

Joins the agent's OpenTelemetry span to the spans instrumented MCP
servers create for themselves, by injecting a traceparent header on the
HTTP transport around each tool-call RPC. Off by default; enabled with
mcp.trace_propagation: true in config.yaml.

Two constraints shape the design (both from the review of NousResearch#60466):

* Capture on the agent thread, before the thread boundary. MCP RPCs run
  on the daemon loop where the caller's context is invisible
  (contextvars do not cross run_coroutine_threadsafe), so the tool
  handler captures its own context and hands the formatted header
  across.
* Inject per call, not per connection. One ClientSession serves many
  calls; a connection-time header pins them all to one stale span. The
  header exists on the shared httpx client exactly for the duration of
  one RPC, under the per-server RPC lock that already serializes calls.

Trace context is read via the standard propagation API, so any
instrumentation that sets ambient context works unmodified; tracing
plugins that keep their own span registry can register a provider
callback (hermes-otel's get_current_traceparent slots straight in).
Provider output is validated against the W3C grammar. No path raises
into a tool call, no OpenTelemetry import is required when disabled,
and stdio transports are untouched.

Fixes NousResearch#52211

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alt-glitch alt-glitch added type/feature New feature or request comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/mcp MCP client and OAuth telemetry Touches outbound telemetry, usage attribution, or analytics — needs opt-in gating before merge P3 Low — cosmetic, nice to have sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades needs-decision Awaiting maintainer decision before any implementation labels Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades telemetry Touches outbound telemetry, usage attribution, or analytics — needs opt-in gating before merge tool/mcp MCP client and OAuth type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Propagate W3C traceparent headers to MCP servers for distributed tracing

2 participants