Skip to content

fix(mcp-instrumentation): inject W3C trace context into outbound HTTP headers - #827

Merged
liustve merged 4 commits into
aws-observability:mainfrom
jaredcnance:fix/mcp-http-header-propagation
Jul 22, 2026
Merged

liustve merged 4 commits into
aws-observability:mainfrom
jaredcnance:fix/mcp-http-header-propagation

Conversation

@jaredcnance

@jaredcnance jaredcnance commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Even with OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION=true (the default from #695), inject traceparent/tracestate/baggage into the outgoing HTTP request headers so MCP servers that read trace context only from HTTP headers can join the caller's trace.

Fixes #826.

Background

The default from #695 correctly deduplicates the redundant HTTP CLIENT span, but it does so via suppress_http_instrumentation(), which also short-circuits the HTTP client instrumentation's own header injection — the only mechanism that would otherwise set traceparent on the wire. MCP servers that only participate in HTTP-header propagation (API Gateways, service meshes, non-Python MCP servers, any HTTP endpoint without ADOT MCP instrumentation) therefore receive no context and start a fresh trace, silently breaking E2E propagation for a large class of real-world topologies.

#826 has before/after trace evidence from a real AWS deployment showing the client trace dead-ending at mcp.session while every server-side hop lands on its own fresh trace ID.

Design

Compatible with the OTel MCP semantic convention:

MCP server instrumentation SHOULD, by default, use context extracted from MCP params._meta as a parent for MCP server span and SHOULD link current ambient context, if it's present.

The spec designates _meta as the MCP parent channel and HTTP context as an orthogonal transport signal (a span link on ADOT-instrumented servers). Nothing in the spec forbids injecting into HTTP headers as well; the existing ADOT server-side wrapper only extracts from _meta, so double-parenting on ADOT-instrumented servers is not a risk. This change adds HTTP-header propagation as a compatible fallback for non-ADOT servers while leaving the existing _meta propagation and the span-suppression semantics unchanged.

Implementation

ClientWrapper.wrap_http_client now wraps the transport's httpx_client_factory (or supplies its own pre-built http_client for the newer streamable_http_client(url, http_client=...) API) so every httpx.AsyncClient the MCP transport uses has a request event hook that calls self._propagators.inject(carrier=request.headers).

The hook takes the OTel context at request-send time, so per-tool-call parents (which change during a shared MCP session) are honored correctly. If httpx or MCP is not importable, or the wrapped function signature doesn't match, the patcher no-ops silently.

The change is purely additive with respect to OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION:

OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION HTTP CLIENT span traceparent HTTP header
true (default) — before this PR suppressed ✓ absent ✗ (bug)
true (default) — after this PR suppressed ✓ present ✓
false — before this PR present present
false — after this PR present present

Span suppression, ping suppression, active-parent respect, tool/prompt/resource spans, and error handling across stdio+http+sse transports are unchanged.

Testing

New tests in test_mcp_instrumentor.py:

  • test_http_headers_carry_trace_context_regardless_of_suppression — exercises OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION in {true, false, default} and asserts every outgoing MCP HTTP request carries a well-formed W3C traceparent header (validates format: 00-<32hex trace-id>-<16hex span-id>-<2hex flags>).
  • test_http_headers_prebuilt_client_gets_hook — verifies the hook is installed on a user-supplied pre-built http_client (newer streamable_http_client(url, http_client=...) API).

Existing tests (15 total including the two new ones):

$ python -m pytest test_mcp_instrumentor.py
======================== 15 passed, 1 warning in 20.89s ========================

Lint:

$ black --check aws-opentelemetry-distro/src/.../_wrappers.py .../test_mcp_instrumentor.py
All done! ✨ 🍰 ✨
2 files would be left unchanged.

$ flake8 aws-opentelemetry-distro/src/.../_wrappers.py .../test_mcp_instrumentor.py
(no output)

$ pylint --rcfile pyproject.toml aws-opentelemetry-distro/src/.../_wrappers.py
Your code has been rated at 9.01/10 (previous run: 9.01/10, +0.00)

Related PRs

@jaredcnance
jaredcnance requested a review from a team as a code owner July 17, 2026 20:53
@jaredcnance

Copy link
Copy Markdown
Contributor Author

Let’s hold on merging this until next week as I want to run a few more e2e tests. Will post test results to the PR.

jaredcnance added a commit to jaredcnance/aws-otel-python-instrumentation that referenced this pull request Jul 19, 2026
…ption guard, positional-arg safety

Fixes identified during xhigh-effort code review:

1. Resource leak (line 373): When streamable_http_client's http_client was
   None, the PR substituted a fabricated client that MCP treated as
   caller-owned (skipping lifecycle management). Fix: the enclosing
   wrap_http_client now manages our fabricated client's lifecycle
   (__aenter__ + aclose in its own try/finally).

2. Hook accumulation (line 348): _inject_trace_context was a fresh closure
   per call so identity-based dedup never matched. Fix: extracted to
   module-level _attach_propagation_hook with a sentinel attribute
   (_adot_mcp_propagation_hook_installed) for idempotent attachment.

3. Exception guard (line 342): The hook lacked try/except so a propagator
   error would break the MCP tool call. Fix: wrapped propagators.inject
   in try/except with debug log inside _inject_trace_context.

4. Positional-argument crash (line 384): httpx_client_factory is
   POSITIONAL_OR_KEYWORD in streamablehttp_client/sse_client. If passed
   positionally, kwargs.get returned None and the code double-supplied
   the argument. Fix: check both args (by parameter index) and kwargs;
   strip from args if found positionally.

5. Span-leak window (line 299): _install_trace_header_injection was called
   between _start_mcp_session_span and the try/finally that ends the
   span. Fix: moved inside the try block.

6. Dead try/except around _get_parameter_names removed.

7. Silent no-op paths now emit _LOG.debug explaining why injection was
   skipped.

8. Test typo: pretbuilt → prebuilt.

9. Test cell-var-from-loop: extracted _make_capture factory function.

10. CHANGELOG: link aws-observability/pull/827 instead of aws-observability/issues/826.

Empirically verified:
- Per-tool-call parenting: tools/call POST carries traceparent with
  parent-span-id == CLIENT TOOL SPAN (correct).
- No resource leak: httpx client is properly closed (no ResourceWarning).
- Hook accumulation: 3 calls → 1 hook (sentinel dedup works).
- All 15 MCP tests pass.
@jaredcnance
jaredcnance force-pushed the fix/mcp-http-header-propagation branch from 82924aa to 5a501dd Compare July 19, 2026 17:19
@jaredcnance

jaredcnance commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor Author

Verified end-to-end on an AWS deployment where the MCP server is a Lambda Java service fronted by API Gateway with X-Ray active tracing. Before this change: client trace dead-ended at mcp.session, and each MCP tool call produced an orphan trace on the server side (fresh root at POST /mcp). After this change:

  [SERVER] AgentCore.Runtime.Invoke                       ← agent runtime root
  └─ [INTERNAL] execute_event_loop_cycle                  (agent loop)
     ├─ [INTERNAL] chat
     │  └─ [CLIENT] chat anthropic.claude-sonnet-4        (Bedrock)
     └─ [INTERNAL] execute_tool query_metrics             (MCP tool)
        └─ [CLIENT] mcp tools/call query_metrics             (httpx → traceparent header)
           └─ [SERVER] MetricsApi/prod                    (AWS::ApiGateway::Stage)
              └─ [CLIENT] Lambda                          (API GW integration)
                 └─ [SERVER] MetricsService-Frontend/LambdaService
                    └─ [SERVER] MetricsService-Frontend/LambdaExecutionEnvironment

Sample client span:

  {
    "name": "mcp tools/call query_metrics",
    "kind": "CLIENT",
    "traceId": "5f369767e659917c65a1a4573210866a",
    "spanId": "bb63e5278111c0c5",
    "parentSpanId": "74d96fd91d70c4e8",
    "durationMs": 15.3,
    "status": { "code": "OK" },
    "attributes": {
      "mcp.method.name": "tools/call",
      "mcp.protocol.version": "2025-11-25",
      "mcp.session.id": "e9a00860bd084ee88d80e6e990f6e3fd",
      "jsonrpc.request.id": "1",
      "gen_ai.tool.name": "query_metrics",
      "gen_ai.operation.name": "execute_tool",
      "gen_ai.tool.call.arguments": "{\"namespace\": \"AWS/Lambda\", \"metric_name\": \"Errors\"}",
      "gen_ai.tool.call.result": "…{\"datapoints\": [{\"timestamp\": \"2026-07-19T00:00:00Z\", \"value\": 42.0}]}… isError=False",
      "network.transport": "tcp",
      "server.address": "127.0.0.1",
      "server.port": 54499
    },
    "instrumentationScope": {
      "name": "amazon.opentelemetry.distro.instrumentation.mcp",
      "version": "0.18.0.dev0"
    }
  }

… headers

Even with OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION=true (the default from
request headers so MCP servers that read trace context only from HTTP
headers can join the caller's trace.

The default from aws-observability#695 correctly deduplicates the redundant HTTP CLIENT
span, but it does so via `suppress_http_instrumentation()`, which also
short-circuits the HTTP client instrumentation's own header injection —
the only mechanism that would set `traceparent` on the wire. MCP servers
that only participate in HTTP-header propagation (API Gateways, service
meshes, non-Python MCP servers, any HTTP endpoint without ADOT MCP
instrumentation) therefore receive no context and start a fresh trace,
silently breaking E2E propagation for a large class of real-world
topologies. Reported in aws-observability#826 with before/after trace evidence.

This is compatible with the OTel MCP semconv
(https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md):

  > MCP server instrumentation SHOULD, by default, use context extracted
  > from MCP `params._meta` as a parent for MCP server span and SHOULD
  > link current ambient context, if it's present.

The spec designates `_meta` as the MCP parent channel and HTTP context
as an orthogonal transport signal (a span link on ADOT-instrumented
servers). Nothing in the spec forbids injecting into HTTP headers; the
existing ADOT server-side wrapper only extracts from `_meta`, so
double-parenting is not a risk. This change adds HTTP-header propagation
as a compatible fallback for non-ADOT servers while leaving the existing
`_meta` propagation unchanged.

Implementation: `ClientWrapper.wrap_http_client` now wraps the
transport's `httpx_client_factory` (or supplies its own pre-built
`http_client` for `streamable_http_client(url, http_client=...)`) so
every httpx.AsyncClient the MCP transport uses has a request event hook
that calls `self._propagators.inject(carrier=request.headers)`. The hook
takes the OTel context at request-send time, so per-tool-call parents
are honored correctly across a shared MCP session. If httpx or MCP is
not importable, or the wrapped function signature doesn't match, the
patcher no-ops silently.

Tests:
- `test_http_headers_carry_trace_context_regardless_of_suppression`
  exercises `OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION` in {true, false,
  default} and asserts every outgoing MCP HTTP request carries a
  well-formed W3C `traceparent` header.
- `test_http_headers_prebuilt_client_gets_hook` verifies the hook is
  installed on a user-supplied pre-built `http_client` (the newer
  `streamable_http_client(url, http_client=...)` API).

Existing tests (span suppression, ping suppression, active-parent
respect, tool/prompt/resource spans, error handling across stdio+http+sse
transports) continue to pass unchanged.

Fixes: aws-observability#826
…ption guard, positional-arg safety

Fixes identified during xhigh-effort code review:

1. Resource leak (line 373): When streamable_http_client's http_client was
   None, the PR substituted a fabricated client that MCP treated as
   caller-owned (skipping lifecycle management). Fix: the enclosing
   wrap_http_client now manages our fabricated client's lifecycle
   (__aenter__ + aclose in its own try/finally).

2. Hook accumulation (line 348): _inject_trace_context was a fresh closure
   per call so identity-based dedup never matched. Fix: extracted to
   module-level _attach_propagation_hook with a sentinel attribute
   (_adot_mcp_propagation_hook_installed) for idempotent attachment.

3. Exception guard (line 342): The hook lacked try/except so a propagator
   error would break the MCP tool call. Fix: wrapped propagators.inject
   in try/except with debug log inside _inject_trace_context.

4. Positional-argument crash (line 384): httpx_client_factory is
   POSITIONAL_OR_KEYWORD in streamablehttp_client/sse_client. If passed
   positionally, kwargs.get returned None and the code double-supplied
   the argument. Fix: check both args (by parameter index) and kwargs;
   strip from args if found positionally.

5. Span-leak window (line 299): _install_trace_header_injection was called
   between _start_mcp_session_span and the try/finally that ends the
   span. Fix: moved inside the try block.

6. Dead try/except around _get_parameter_names removed.

7. Silent no-op paths now emit _LOG.debug explaining why injection was
   skipped.

8. Test typo: pretbuilt → prebuilt.

9. Test cell-var-from-loop: extracted _make_capture factory function.

10. CHANGELOG: link aws-observability/pull/827 instead of aws-observability/issues/826.

Empirically verified:
- Per-tool-call parenting: tools/call POST carries traceparent with
  parent-span-id == CLIENT TOOL SPAN (correct).
- No resource leak: httpx client is properly closed (no ResourceWarning).
- Hook accumulation: 3 calls → 1 hook (sentinel dedup works).
- All 15 MCP tests pass.
@jaredcnance
jaredcnance force-pushed the fix/mcp-http-header-propagation branch from 5a501dd to 86ab436 Compare July 19, 2026 22:42
@liustve

liustve commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Thanks @jaredcnance for the changes and testing!
One suggestion: I think we should follow OTel botocore instrumentation pattern and wrap the header-prep function.

The MCP function to wrap would be is StreamableHTTPTransport._prepare_headers, which already runs under our attached _meta context.

under wrapper.py

def wrap_prepare_headers(self, wrapped, instance, args, kwargs):
    headers = wrapped(*args, **kwargs)
    if self._should_suppress_http_spans:  # else httpx injects it itself
        self._propagators.inject(carrier=headers)
    return headers

under _instrument() and _uninstrument() in:

try_wrap(
    _HTTP_MODULE,
    "StreamableHTTPTransport._prepare_headers",
    self._client_wrapper.wrap_prepare_headers,
)
if hasattr(streamable_http, "StreamableHTTPTransport"):
    try_unwrap(streamable_http.StreamableHTTPTransport, "_prepare_headers")

…ntainer review)

Adopt the approach suggested by @liustve in PR review: wrap
StreamableHTTPTransport._prepare_headers to inject W3C trace context
instead of hooking httpx.AsyncClient event_hooks.

This is dramatically simpler and eliminates all the complexity and edge
cases of the previous approach:
- No httpx client creation/lifecycle management
- No hook accumulation on reused clients
- No positional-argument detection
- No signature introspection
- Proper uninstrument support (try_unwrap)
- Naturally runs under the per-tool-call context (because
  wrap_handle_post_request already restores it from _meta before
  _handle_post_request calls _prepare_headers)

The wrap only injects when _should_suppress_http_spans is True (the
default). When False, the httpx client instrumentation handles injection
itself — no duplicate headers.

Removed ~100 lines of _install_trace_header_injection,
_attach_propagation_hook, _get_parameter_names, and the managed-client
lifecycle code. The net diff from upstream main is now +25 lines.
@jaredcnance
jaredcnance force-pushed the fix/mcp-http-header-propagation branch from 303d190 to 4cca406 Compare July 21, 2026 23:55
@jaredcnance

jaredcnance commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor Author

sweet -- thanks for the pointers. PR is updated, let me know if this makes sense. verified end-to-end in deployed environment.

@liustve
liustve merged commit ffa8c72 into aws-observability:main Jul 22, 2026
206 of 208 checks passed
@jaredcnance
jaredcnance deleted the fix/mcp-http-header-propagation branch July 22, 2026 00:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP instrumentation breaks trace propagation to MCP servers that read context from HTTP headers (defaults hide the problem)

3 participants