fix(otel): keep an MCP tool call in one trace, anchored to its own request - #34537
Conversation
…quest
Under otel_v2 a single MCP tool call surfaced in APM as two disconnected
traces joined only by a span link: the HTTP transport transaction
POST /{mcp_server_name}/mcp and the tools/call span carrying
error.type=MCPToolResultError. resolve_mcp_span_context parented the MCP
span to the W3C trace context the client propagates in params._meta
(SEP-414) and recorded the transport as a link, so with no traceparent
propagated (the common case today, including MCP Inspector) the span
started its own root trace.
Nest the MCP span under the transport span when nothing is propagated, so
the call stays in one trace; the propagated-context path is unchanged and
still parents to the remote context and links the transport per the OTel
GenAI MCP semconv.
The transport has to be resolved per message rather than read from the
request-root ContextVar. A stateful streamable-HTTP session runs every
message on the single task the session's initialize POST spawned, so that
ContextVar is frozen at initialize inside the handler: live capture on
staging showed the tools/call span linking the initialize POST rather than
the POST that carried it, and nesting on that anchor would hang every tool
call of a session off the first request's already-ended span. The gateway
now resolves the current request's span on the ASGI task and carries it to
the handler on the authenticated-user object, the same way per-request auth
already crosses that boundary.
|
|
Greptile SummaryThis PR keeps MCP message spans attached to the HTTP request carrying each message.
Confidence Score: 5/5The PR appears safe to merge, with no concrete behavioral, security, or compatibility defects identified. The transport context is captured across the streamable HTTP paths, scoped and reset around MCP handlers, and the remote-context behavior remains covered alongside the new transport-parenting behavior.
|
| Filename | Overview |
|---|---|
| litellm/integrations/otel/model/spans.py | Updates the documented MCP span hierarchy to reflect transport parenting when no client context is propagated. |
| litellm/integrations/otel/plumbing/context.py | Adds per-message transport context plumbing and chooses remote or transport parenting without exposing a concrete regression. |
| litellm/proxy/_experimental/mcp_server/auth/litellm_auth_handler.py | Extends the mutable per-session authentication context with an immutable transport SpanContext. |
| litellm/proxy/_experimental/mcp_server/server.py | Captures each request transport context and publishes it while list-tools and tool-call handlers emit telemetry. |
| tests/test_litellm/integrations/otel/test_otel_v2_logger.py | Expands regression coverage for transport parenting, remote propagation, malformed context, root fallback, and cross-task attribution. |
Reviews (1): Last reviewed commit: "fix(otel): keep an MCP tool call in one ..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
7263aa0
into
litellm_internal_staging
…quest (BerriAI#34537) Under otel_v2 a single MCP tool call surfaced in APM as two disconnected traces joined only by a span link: the HTTP transport transaction POST /{mcp_server_name}/mcp and the tools/call span carrying error.type=MCPToolResultError. resolve_mcp_span_context parented the MCP span to the W3C trace context the client propagates in params._meta (SEP-414) and recorded the transport as a link, so with no traceparent propagated (the common case today, including MCP Inspector) the span started its own root trace. Nest the MCP span under the transport span when nothing is propagated, so the call stays in one trace; the propagated-context path is unchanged and still parents to the remote context and links the transport per the OTel GenAI MCP semconv. The transport has to be resolved per message rather than read from the request-root ContextVar. A stateful streamable-HTTP session runs every message on the single task the session's initialize POST spawned, so that ContextVar is frozen at initialize inside the handler: live capture on staging showed the tools/call span linking the initialize POST rather than the POST that carried it, and nesting on that anchor would hang every tool call of a session off the first request's already-ended span. The gateway now resolves the current request's span on the ASGI task and carries it to the handler on the authenticated-user object, the same way per-request auth already crosses that boundary.
TLDR
Problem this solves:
initialize, so the link (and, once nesting is introduced, the parent) points at whichever request opened the sessionHow it solves it:
Relevant issues
Linear ticket
Resolves LIT-4081
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Live proxy, otel_v2 on, exporting OTLP/HTTP to a local collector that prints one line per span. One upstream MCP server registered in config; the session is driven with curl exactly the way MCP Inspector drives it (
initialize, thennotifications/initialized, thentools/callfor a tool that fails upstream), so the wire stays HTTP 200 withisError: true.Before, on
litellm_internal_stagingat f6a1050. Three POSTs:9a603da971d73435isinitialize,206f5d306b192734isnotifications/initialized,d44104edd0823507is the POST that actually carriedtools/call.tools/callsits in its own trace168761bb..., sharing notrace_idwith any POST, which is the two-traces-joined-by-a-link rendering that was reported. Its link points at9a603da971d73435, theinitializePOST, not atd44104edd0823507which carried the call.After, on this branch, same rig and same curl sequence:
One trace
d06e2f1b...: thetools/callPOST1eb9977868fe260bwithauth,tools/listandtools/callnested under it, no link, and the tool failure still marked ERROR on a 200 response. It parents to1eb9977868fe260band not to08da9d15b1ee35bd, so the call is attributed to the request that carried it rather than to the one that opened the session.The semconv path is unchanged. Repeating the call with a
traceparentinparams._meta:The span still joins the client's trace
11111111...under the propagated parent22222222...and records the transport as a link, and that link is49689443715de190, this message's POST.Type
🐛 Bug Fix
Changes
resolve_mcp_span_contextnow branches on whether the client propagated a usable remote parent. With one, behavior is unchanged: parent to the remote context and link the transport, per the OTel GenAI MCP semconv, so a long-lived streamable-HTTP session multiplexing many messages does not glue them under the session's first request. With none, it nests under the transport span so the call stays in one trace and adds no link, since the transport is now the parent. With neither a remote parent nor a transport it still starts its own root trace.The transport itself is now resolved per message.
request_root_spanis a ContextVar written on the ASGI request task, and the MCP SDK runs every message of a stateful session on the single task that session'sinitializePOST spawned, so inside the message handler that ContextVar is frozen atinitializeand never sees the later POSTs; the before-capture above shows the resulting link pointing at the wrong request.handle_streamable_http_mcptherefore resolves the current request's span context on its own task and passes it into the auth context, which_update_auth_contextalready mutates in place per request precisely because a ContextVar cannot cross that boundary, and the message handler publishes it for the span emitter. It is carried as aSpanContextrather than a liveSpanso nothing can write to a finished span from the wrong request. Paths that emit an MCP span on the request task itself, such as the REST MCP endpoints, fall back to the existing anchor and are unaffected.Tests in
test_otel_v2_logger.pycover the single-trace nesting for the no-context and malformed-traceparentcases, a new no-transport-and-no-context case that still roots, and two cross-task cases that emit the span from a task spawned before the current request, pinning both the parent and the semconv link to this message's transport rather than the session opener. The propagated-context and baggage-spoofing tests are unchanged.QA runbook
config.yamlundermcp_servers, and start the proxy withLITELLM_OTEL_V2=1andcallbacks: ["otel"]pointed at any span exporterhttp://localhost:4000/<mcp_server_name>/mcp, runinitializeand thentools/callfor a tool the key may not use, so the result comes back as HTTP 200 withisError: truetools/callspan and thePOST /<mcp_server_name>/mcptransaction that carried it share onetrace_id, thattools/callis parented to that POST and not to theinitializePOST, and that it records no transport linktools/callon the same session and confirm it attaches to its own POST rather than to the first onetools/callwith atraceparentinparams._metaand confirm the span still parents to that remote context and links this message's POSTFinal Attestation