Skip to content

fix(otel): keep an MCP tool call in one trace, anchored to its own request - #34537

Merged
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_mcp_span_per_message_transport
Jul 24, 2026
Merged

fix(otel): keep an MCP tool call in one trace, anchored to its own request#34537
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_mcp_span_per_message_transport

Conversation

@yassin-berriai

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • under otel_v2 one MCP tool call surfaces in APM as two disconnected traces joined only by a span link, instead of one trace
  • the transport a message span attaches to is read from a ContextVar that a stateful streamable-HTTP session cannot see past its own initialize, so the link (and, once nesting is introduced, the parent) points at whichever request opened the session

How it solves it:

  • nest the MCP span under the transport span when the client propagates no trace context; the propagated-context path keeps the semconv shape
  • resolve the transport per message on the ASGI request task and carry it to the message handler the way per-request auth already crosses that boundary

Relevant issues

Linear ticket

Resolves LIT-4081

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to 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, then notifications/initialized, then tools/call for a tool that fails upstream), so the wire stays HTTP 200 with isError: true.

$ curl -sS -X POST http://127.0.0.1:4081/probe/mcp \
    -H 'Authorization: Bearer sk-lit4081' \
    -H 'Accept: application/json, text/event-stream' \
    -H 'Content-Type: application/json' \
    -H "mcp-session-id: $SID" \
    -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"speech_to_text","arguments":{"audio":"hello.wav"}}}'
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Error executing tool speech_to_text: Authorization failed for tool 'speech_to_text': insufficient permissions"}],"isError":true}}

http=200

Before, on litellm_internal_staging at f6a1050. Three POSTs: 9a603da971d73435 is initialize, 206f5d306b192734 is notifications/initialized, d44104edd0823507 is the POST that actually carried tools/call.

SPAN name='auth /mcp/probe'                trace_id=78cc9ed7...57ea span_id=9341f355abf42b6b parent=9a603da971d73435 links=[-]                status=UNSET
SPAN name='auth /mcp/probe'                trace_id=f6af8c22...f80b span_id=ef3bf3e301bbda1b parent=206f5d306b192734 links=[-]                status=UNSET
SPAN name='auth /mcp/probe'                trace_id=56683d3b...0021 span_id=baaf4356ef20e566 parent=d44104edd0823507 links=[-]                status=UNSET
SPAN name='tools/list'                     trace_id=9969a474...444d span_id=a632df5d315e9391 parent=-                links=[9a603da971d73435] status=UNSET
SPAN name='tools/call speech_to_text'      trace_id=168761bb...b33d span_id=8ba666a5fb331247 parent=-                links=[9a603da971d73435] status=ERROR
SPAN name='POST /{mcp_server_name}/mcp'    trace_id=78cc9ed7...57ea span_id=9a603da971d73435 parent=-                links=[-]                status=UNSET
SPAN name='POST /{mcp_server_name}/mcp'    trace_id=f6af8c22...f80b span_id=206f5d306b192734 parent=-                links=[-]                status=UNSET
SPAN name='POST /{mcp_server_name}/mcp'    trace_id=56683d3b...0021 span_id=d44104edd0823507 parent=-                links=[-]                status=UNSET

tools/call sits in its own trace 168761bb..., sharing no trace_id with any POST, which is the two-traces-joined-by-a-link rendering that was reported. Its link points at 9a603da971d73435, the initialize POST, not at d44104edd0823507 which carried the call.

After, on this branch, same rig and same curl sequence:

SPAN name='auth /mcp/probe'                trace_id=f9f3779b...ca6e span_id=f08bbfebac3b6298 parent=08da9d15b1ee35bd links=[-] status=UNSET
SPAN name='auth /mcp/probe'                trace_id=40d50edd...710e span_id=28879f788be05c15 parent=1f455230d1a26cc5 links=[-] status=UNSET
SPAN name='auth /mcp/probe'                trace_id=d06e2f1b...4d99 span_id=687c53bae7efd901 parent=1eb9977868fe260b links=[-] status=UNSET
SPAN name='tools/list'                     trace_id=d06e2f1b...4d99 span_id=6eff80d496303f01 parent=1eb9977868fe260b links=[-] status=UNSET
SPAN name='tools/call speech_to_text'      trace_id=d06e2f1b...4d99 span_id=eba565d95a7b91fb parent=1eb9977868fe260b links=[-] status=ERROR
SPAN name='POST /{mcp_server_name}/mcp'    trace_id=f9f3779b...ca6e span_id=08da9d15b1ee35bd parent=-                links=[-] status=UNSET
SPAN name='POST /{mcp_server_name}/mcp'    trace_id=40d50edd...710e span_id=1f455230d1a26cc5 parent=-                links=[-] status=UNSET
SPAN name='POST /{mcp_server_name}/mcp'    trace_id=d06e2f1b...4d99 span_id=1eb9977868fe260b parent=-                links=[-] status=UNSET

One trace d06e2f1b...: the tools/call POST 1eb9977868fe260b with auth, tools/list and tools/call nested under it, no link, and the tool failure still marked ERROR on a 200 response. It parents to 1eb9977868fe260b and not to 08da9d15b1ee35bd, 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 traceparent in params._meta:

$ ... -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"},"_meta":{"traceparent":"00-11111111111111111111111111111111-2222222222222222-01"}}}'

SPAN name='auth /mcp/probe'             trace_id=6ccafb84...d548 span_id=2245671b15ccd637 parent=49689443715de190 links=[-]                status=UNSET
SPAN name='tools/call echo'             trace_id=11111111111111111111111111111111 span_id=c982da715f4a1c42 parent=2222222222222222 links=[49689443715de190] status=UNSET
SPAN name='POST /{mcp_server_name}/mcp' trace_id=6ccafb84...d548 span_id=49689443715de190 parent=-                links=[-]                status=UNSET

The span still joins the client's trace 11111111... under the propagated parent 22222222... and records the transport as a link, and that link is 49689443715de190, this message's POST.

Type

🐛 Bug Fix

Changes

resolve_mcp_span_context now 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_span is 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's initialize POST spawned, so inside the message handler that ContextVar is frozen at initialize and never sees the later POSTs; the before-capture above shows the resulting link pointing at the wrong request. handle_streamable_http_mcp therefore resolves the current request's span context on its own task and passes it into the auth context, which _update_auth_context already 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 a SpanContext rather than a live Span so 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.py cover the single-trace nesting for the no-context and malformed-traceparent cases, 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

  1. Start an MCP server the proxy can reach, register it in config.yaml under mcp_servers, and start the proxy with LITELLM_OTEL_V2=1 and callbacks: ["otel"] pointed at any span exporter
  2. From a client that does not propagate trace context, such as MCP Inspector over Streamable HTTP at http://localhost:4000/<mcp_server_name>/mcp, run initialize and then tools/call for a tool the key may not use, so the result comes back as HTTP 200 with isError: true
  3. In the exported spans, confirm the tools/call span and the POST /<mcp_server_name>/mcp transaction that carried it share one trace_id, that tools/call is parented to that POST and not to the initialize POST, and that it records no transport link
  4. Run a second tools/call on the same session and confirm it attaches to its own POST rather than to the first one
  5. Send a tools/call with a traceparent in params._meta and confirm the span still parents to that remote context and links this message's POST

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

…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.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR keeps MCP message spans attached to the HTTP request carrying each message.

  • Selects either the propagated client context or the current transport span as the MCP span parent.
  • Carries immutable per-request transport span context across stateful MCP session task boundaries.
  • Adds coverage for missing, malformed, propagated, and cross-task trace contexts.

Confidence Score: 5/5

The 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.

Important Files Changed

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

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.57143% with 12 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/_experimental/mcp_server/server.py 64.70% 12 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_mcp_span_per_message_transport (93f1470) with litellm_internal_staging (f6a1050)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (35dc982) during the generation of this report, so f6a1050 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@yassin-berriai
yassin-berriai merged commit 7263aa0 into litellm_internal_staging Jul 24, 2026
80 of 81 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_mcp_span_per_message_transport branch July 24, 2026 22:11
Ericcwang23 pushed a commit to Ericcwang23/litellm that referenced this pull request Jul 27, 2026
…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.
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.

3 participants