Skip to content

feat(runtime-mcp): propagate W3C traceparent on outbound MCP tool calls (#6128)#6153

Merged
houko merged 2 commits into
mainfrom
feat-mcp-traceparent
Jun 17, 2026
Merged

feat(runtime-mcp): propagate W3C traceparent on outbound MCP tool calls (#6128)#6153
houko merged 2 commits into
mainfrom
feat-mcp-traceparent

Conversation

@houko

@houko houko commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

The MCP client did not propagate W3C trace context to the servers it calls, so spans from an OTel-instrumented MCP server started their own disconnected trace instead of joining the LibreFang agent turn that triggered the tool call. LibreFang already does this on LLM HTTP egress (librefang-llm-drivers::drivers::trace_headers::inject_w3c_trace_context); this mirrors it on the MCP transport.

New trace_context module reads the active trace from opentelemetry::Context::current() (not tracing::Span::current().context(), which is silently invalid behind the reload layer — see the trace_headers.rs root-cause notes) and injects via the globally-registered text-map propagator, exposing one context as two views: an http::HeaderMap for the per-request HttpCompat path, and a (name, value) list for the MCP _meta path.

Wired into all three transports of call_tool_with_caller, alongside (never replacing) the existing io.librefang/caller entry:

  • HttpCompat builds the reqwest request per call, so the context goes on as real per-request traceparent/tracestate headers that standard opentelemetry-http middleware extracts directly.
  • Rmcp/streamable-HTTP sets custom_headers once at connect time and rmcp 1.7 exposes no per-request header hook, so the context rides in the request _meta under io.librefang/trace.
  • SSE goes through a shared sse_send_request helper that takes no per-request header argument, so it uses the same _meta carrier (merged with any caller _meta).

Unconditional and self-disabling, matching the LLM-egress precedent: when telemetry is off the global propagator is the Noop propagator (writes nothing); when no recording span is active the context is invalid and the propagator writes nothing. Either way the carrier is empty and the call is unchanged.

Known limitation: for the Rmcp and SSE transports the context is in _meta, not wire headers, so a server that only auto-extracts traceparent from HTTP headers will not stitch on those transports until rmcp exposes a per-request header hook (the upstream gap noted in the issue). HttpCompat is unaffected. Acceptance criterion #1 (outbound call carries a traceparent matching the active span) holds on all three.

Adds opentelemetry + opentelemetry-http deps (already in the workspace via telemetry) and otel/tracing dev-deps for the test.

Verification:

  • cargo test -p librefang-runtime-mcp — 169 lib + 3 integration tests pass, including 3 new trace_context tests (recording span yields a non-all-zero traceparent matching the active span; no active span yields nothing; _meta and header views are consistent), reusing the reload-layer test harness from trace_headers.rs.
  • cargo clippy -p librefang-runtime-mcp --all-targets — zero warnings.
  • cargo fmt clean.

…ls (#6128)

The MCP client did not propagate W3C trace context to the servers it calls, so spans from an OTel-instrumented MCP server started their own disconnected trace instead of joining the LibreFang agent turn that triggered the tool call. LibreFang already does this on LLM HTTP egress (`librefang-llm-drivers::drivers::trace_headers::inject_w3c_trace_context`); this mirrors it on the MCP transport.

New `trace_context` module reads the active trace from `opentelemetry::Context::current()` (not `tracing::Span::current().context()`, which is silently invalid behind the reload layer — see the trace_headers.rs root-cause notes) and injects via the globally-registered text-map propagator, exposing one context as two views: an `http::HeaderMap` for the per-request HttpCompat path, and a `(name, value)` list for the MCP `_meta` path.

Wired into all three transports of `call_tool_with_caller`, alongside (never replacing) the existing `io.librefang/caller` entry:
- HttpCompat builds the reqwest request per call, so the context goes on as real per-request `traceparent`/`tracestate` headers that standard `opentelemetry-http` middleware extracts directly.
- Rmcp/streamable-HTTP sets `custom_headers` once at connect time and rmcp 1.7 exposes no per-request header hook, so the context rides in the request `_meta` under `io.librefang/trace`.
- SSE goes through a shared `sse_send_request` helper that takes no per-request header argument, so it uses the same `_meta` carrier (merged with any caller `_meta`).

Unconditional and self-disabling, matching the LLM-egress precedent: when telemetry is off the global propagator is the Noop propagator (writes nothing); when no recording span is active the context is invalid and the propagator writes nothing. Either way the carrier is empty and the call is unchanged.

Known limitation: for the Rmcp and SSE transports the context is in `_meta`, not wire headers, so a server that only auto-extracts `traceparent` from HTTP headers will not stitch on those transports until rmcp exposes a per-request header hook (the upstream gap noted in the issue). HttpCompat is unaffected. Acceptance criterion #1 (outbound call carries a traceparent matching the active span) holds on all three.

Adds `opentelemetry` + `opentelemetry-http` deps (already in the workspace via telemetry) and otel/tracing dev-deps for the test.

Verification:
- cargo test -p librefang-runtime-mcp — 169 lib + 3 integration tests pass, including 3 new trace_context tests (recording span yields a non-all-zero traceparent matching the active span; no active span yields nothing; _meta and header views are consistent), reusing the reload-layer test harness from trace_headers.rs.
- cargo clippy -p librefang-runtime-mcp --all-targets — zero warnings.
- cargo fmt clean.
@github-actions github-actions Bot added area/runtime Agent loop, LLM drivers, WASM sandbox size/L 250-999 lines changed labels Jun 17, 2026
CLAUDE.md: "Never write multi-paragraph docstrings or multi-line comment blocks — one short line max."

Trimmed three inline comment blocks in lib.rs and the module doc, const doc, function docs, and test helper doc in trace_context.rs. No logic changes; cargo clippy -p librefang-runtime-mcp --all-targets passes clean.
@github-actions github-actions Bot added size/M 50-249 lines changed and removed size/L 250-999 lines changed labels Jun 17, 2026
@houko
houko merged commit 640e1cf into main Jun 17, 2026
33 checks passed
@houko
houko deleted the feat-mcp-traceparent branch June 17, 2026 08:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/runtime Agent loop, LLM drivers, WASM sandbox size/M 50-249 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants