Skip to content

feat(mcp-instrumentation): suppress HTTP and initialization spans - #695

Merged
liustve merged 2 commits into
mainfrom
mcp-enhancements
Mar 30, 2026
Merged

liustve merged 2 commits into
mainfrom
mcp-enhancements

Conversation

@liustve

@liustve liustve commented Mar 30, 2026 •

Copy link
Copy Markdown
Contributor

Description of changes:

image

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@liustve
liustve requested a review from a team as a code owner March 30, 2026 04:51
@liustve
liustve force-pushed the mcp-enhancements branch 2 times, most recently from 1020f83 to 81dc242 Compare March 30, 2026 05:21
@liustve liustve changed the title feat: MCP instrumentation with HTTP propagation and ASGI span suppres… feat(mcp-instrumentation): suppress HTTP and initialization spans Mar 30, 2026
- Suppress redundant HTTP/ASGI spans on MCP client and server to reduce trace noise
- Client: wrap streamable_http and sse transports with suppress_http_instrumentation()
- Server: inject ASGI middleware into FastMCP.streamable_http_app() and FastMCP.sse_app()
- Patch OpenTelemetryMiddleware.__call__ to respect suppress_http_instrumentation context
- Skip creating MCP spans for initialize and notifications/initialized handshake messages
- Controlled by OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION env var, defaults to true
wangzlei
wangzlei previously approved these changes Mar 30, 2026
@liustve
liustve merged commit 3f25a81 into main Mar 30, 2026
17 checks passed
@liustve
liustve deleted the mcp-enhancements branch March 30, 2026 20:01
jaredcnance added a commit to jaredcnance/aws-otel-python-instrumentation that referenced this pull request Jul 19, 2026
… 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
liustve added a commit that referenced this pull request Jul 22, 2026
… headers (#827)

## 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](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 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

- #683 — first added HTTP context propagation for MCP requests (via
`_meta` + `_handle_post_request` restoration)
- #695 — introduced `OTEL_MCP_SUPPRESS_HTTP_INSTRUMENTATION` defaulting
to `true` (this is the PR whose default silently broke non-ADOT-server
topologies)

---------

Co-authored-by: Steve Liu <liustve@amazon.com>
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.

2 participants