feat(otel): emit a tools/list CLIENT span for MCP discovery under otel_v2 - #31525
Conversation
Greptile SummaryThis PR adds otel_v2 spans for MCP tool discovery. The main changes are:
Confidence Score: 5/5The MCP OpenTelemetry changes are well-scoped and covered by focused regression tests for span emission, trace topology, baggage filtering, authenticated identity attribution, and malformed context handling. The changed code paths are isolated to otel_v2 MCP logging and tracing behavior, with tests covering both the new discovery span and the related context/identity edge cases.
What T-Rex did
Reviews (3): Last reviewed commit: "refactor(otel): model MCP spans as roots..." | Re-trigger Greptile |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…l_v2
Under otel_v2 an MCP tools/call already produced a dedicated CLIENT span, but tools/list produced none. The discovery call surfaced only as the bare POST /{mcp_server_name}/mcp server span with no MCP attributes, indistinguishable from initialize and impossible to query by method
The list success event already reaches the v2 logger with call_type list_mcp_tools, but _emit_mcp_tool_call only matched call_mcp_tool, so listing fell through to the LLM-call path and emitted nothing. This adds a dedicated MCP_LIST_TOOLS span role with its own MCPListToolsSpanData, emitted from a sibling _emit_mcp_list_tools branch that mirrors the tools/call path
Per the OTel GenAI MCP semantic conventions the span is named tools/list (the method name alone, since there is no low-cardinality target), is a CLIENT span parented to the request span, and carries mcp.method.name plus the call id. It deliberately omits gen_ai.operation.name and gen_ai.tool.name, which the convention reserves for tool executions, since listing runs no tool
d3b81a7 to
36784ca
Compare
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 2 · PR risk: 0/10 |
…ansport span MCP streamable-HTTP multiplexes many JSON-RPC messages over one session, so the request-root anchor captured on initialize persisted and every later message's span (tools/call, tools/list) nested under it. A tools/list run 44s after the initialize rendered 44s to the right of its parent with a clock-skew warning, because the MCP message and the HTTP transport are independent lifecycles Following the OTel GenAI MCP semantic conventions, an MCP span now parents to the W3C trace context the client propagated in the request's params._meta (a remote parent, per SEP-414), records the transport/session span as a span link rather than the parent, and starts its own root trace when nothing was propagated. The MCP gateway captures traceparent/tracestate/baggage from each message's params._meta into a per-message contextvar that the otel_v2 emitter reads; opentelemetry stays an optional dependency via guarded lazy imports This applies to tools/call as well as the new tools/list span, since both shared the same transport-anchoring bug
36784ca to
c226986
Compare
…ity spoofing The MCP trace propagation added a W3CBaggagePropagator, so resolve_mcp_span_context extracted the client's W3C Baggage from params._meta into the span's parent context. The LiteLLMBaggageSpanProcessor then stamps allowlisted baggage keys onto the span, and the list-tools/tool-call mappers don't set those identity keys, so nothing overwrites them. A malicious MCP client could send params._meta.baggage: litellm.team.id=...,litellm.metadata.user_api_key_user_id=... and have those identity attributes attributed to its spans. Extract trace context only (traceparent/tracestate) in the propagator, and stop collecting the baggage key at the source in _mcp_meta_trace_carrier. Parenting to the client's trace context, the actual goal, needs only trace context; remote baggage had no legitimate consumer here. Regression tests at both layers assert a spoofed params._meta.baggage never lands as a span identity attribute.
…pers The otel MCP trace-carrier helpers added in this branch pushed the BLE001 and UP006 strict-rule totals past their ceilings. Use PEP 585 `dict[str, str]` instead of `Dict`, and narrow the optional-import guards to `except ImportError` (the only failure these can hit, matching the "when otel_v2 is unavailable" intent) instead of a blind `except Exception`.
8c6c2dd to
4ba406b
Compare
…itellm_lit_3810_mcp_otel_toollist
Parenting MCP spans to the client's params._meta trace context over an empty Context() meant the tool-call and tools/list spans carried no team/key/metadata identity at all, so they couldn't be attributed or filtered by team in a traces backend. The LLM-call span already re-seeds identity from the parsed, authenticated StandardLoggingPayload rather than trusting ambient/remote context; extract that into a shared _seed_identity_baggage helper and run both MCP emitters through it. Identity comes only from the authenticated payload, never the client carrier, so this keeps the earlier spoofing fix intact while restoring attribution. Regression tests assert the authenticated team lands on both MCP spans and that a spoofed params._meta.baggage value can't override it.
|
@greptileai re review |
|
@greptileai re review |
468d11f
into
litellm_internal_staging
…ned_destinations Resolve conflicts from staging advancing with the MCP tools/list span work (#31525) and the key permissions admin-gate (#31810): - otel logger.py: keep the PR's multi-span (carrier.spans / emit_fanout) fan-out, adopt staging's _seed_identity_baggage helper in the deferred path - context.py: union ContextVar+Token and TYPE_CHECKING+Mapping imports - key_management_endpoints.py: hoist the regenerate team lookup to the top so both staging's object_permission gate and the PR's logging_exporters gate see it - tests: union the new imports/mocks and keep both sides' added tests
…l_v2 (BerriAI#31525) * feat(otel): emit a tools/list CLIENT span for MCP discovery under otel_v2 Under otel_v2 an MCP tools/call already produced a dedicated CLIENT span, but tools/list produced none. The discovery call surfaced only as the bare POST /{mcp_server_name}/mcp server span with no MCP attributes, indistinguishable from initialize and impossible to query by method The list success event already reaches the v2 logger with call_type list_mcp_tools, but _emit_mcp_tool_call only matched call_mcp_tool, so listing fell through to the LLM-call path and emitted nothing. This adds a dedicated MCP_LIST_TOOLS span role with its own MCPListToolsSpanData, emitted from a sibling _emit_mcp_list_tools branch that mirrors the tools/call path Per the OTel GenAI MCP semantic conventions the span is named tools/list (the method name alone, since there is no low-cardinality target), is a CLIENT span parented to the request span, and carries mcp.method.name plus the call id. It deliberately omits gen_ai.operation.name and gen_ai.tool.name, which the convention reserves for tool executions, since listing runs no tool * fix(otel): anchor MCP spans to params._meta trace context, not the transport span MCP streamable-HTTP multiplexes many JSON-RPC messages over one session, so the request-root anchor captured on initialize persisted and every later message's span (tools/call, tools/list) nested under it. A tools/list run 44s after the initialize rendered 44s to the right of its parent with a clock-skew warning, because the MCP message and the HTTP transport are independent lifecycles Following the OTel GenAI MCP semantic conventions, an MCP span now parents to the W3C trace context the client propagated in the request's params._meta (a remote parent, per SEP-414), records the transport/session span as a span link rather than the parent, and starts its own root trace when nothing was propagated. The MCP gateway captures traceparent/tracestate/baggage from each message's params._meta into a per-message contextvar that the otel_v2 emitter reads; opentelemetry stays an optional dependency via guarded lazy imports This applies to tools/call as well as the new tools/list span, since both shared the same transport-anchoring bug * fix(otel): drop client baggage from MCP params._meta to prevent identity spoofing The MCP trace propagation added a W3CBaggagePropagator, so resolve_mcp_span_context extracted the client's W3C Baggage from params._meta into the span's parent context. The LiteLLMBaggageSpanProcessor then stamps allowlisted baggage keys onto the span, and the list-tools/tool-call mappers don't set those identity keys, so nothing overwrites them. A malicious MCP client could send params._meta.baggage: litellm.team.id=...,litellm.metadata.user_api_key_user_id=... and have those identity attributes attributed to its spans. Extract trace context only (traceparent/tracestate) in the propagator, and stop collecting the baggage key at the source in _mcp_meta_trace_carrier. Parenting to the client's trace context, the actual goal, needs only trace context; remote baggage had no legitimate consumer here. Regression tests at both layers assert a spoofed params._meta.baggage never lands as a span identity attribute. * style(mcp): clear ruff strict-budget breach in otel trace-carrier helpers The otel MCP trace-carrier helpers added in this branch pushed the BLE001 and UP006 strict-rule totals past their ceilings. Use PEP 585 `dict[str, str]` instead of `Dict`, and narrow the optional-import guards to `except ImportError` (the only failure these can hit, matching the "when otel_v2 is unavailable" intent) instead of a blind `except Exception`. * fix(otel): stamp authenticated identity baggage onto MCP spans Parenting MCP spans to the client's params._meta trace context over an empty Context() meant the tool-call and tools/list spans carried no team/key/metadata identity at all, so they couldn't be attributed or filtered by team in a traces backend. The LLM-call span already re-seeds identity from the parsed, authenticated StandardLoggingPayload rather than trusting ambient/remote context; extract that into a shared _seed_identity_baggage helper and run both MCP emitters through it. Identity comes only from the authenticated payload, never the client carrier, so this keeps the earlier spoofing fix intact while restoring attribution. Regression tests assert the authenticated team lands on both MCP spans and that a spoofed params._meta.baggage value can't override it. * refactor(otel): model MCP spans as roots that link the transport in SPAN_REGISTRY
Relevant issues
Linear ticket
Resolve LIT-3810
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewDelays 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
Driven against a live proxy on otel_v2 exporting to a local Jaeger, using the in-repo stdio MCP server (
tests/mcp_tests/mcp_server.py); no auth, no LLM costProxy:
A plain MCP session (
initialize, thentools/list) now produces a dedicatedtools/listCLIENT span. Before this change the discovery call emitted no MCP span at all; it surfaced only as a barePOST /{mcp_server_name}/mcpserver span:The span is its own correctly-timed root trace and records the transport/session span as a link rather than nesting under it:
Jaeger renders an OTel span link as a
FOLLOWS_FROMreference, so the transport/session span shows up as a link, not the parent. That anchoring is also what caused the original report: because every message nested under theinitializeanchor, atools/listrun seconds later (44s in that report) rendered that far to the right of its parent with a clock-skew warning, instead of the independent trace aboveWhen a client propagates W3C trace context in
params._meta, the span joins that client trace as a child instead of starting its own root. That propagation path and the identity handling (changes 3 and 4: a spoofedparams._meta.baggagenever lands as a span attribute, and the authenticated team does) are covered by unit testsType
🆕 New Feature
🐛 Bug Fix
Changes
Under otel_v2 an MCP
tools/callproduced its own CLIENT span, buttools/listproduced none; a discovery call surfaced only as a barePOST /{mcp_server_name}/mcpserver span with no MCP attributes, indistinguishable frominitialize. This PR givestools/listits own span and, along the way, fixes how every MCP span attaches to a trace and where its identity attributes come from1. Emit a
tools/listspan. The v2 logger already received the list event, but_emit_mcp_tool_callonly matchedcall_mcp_tool, solist_mcp_toolsfell through to the LLM-call path and emitted nothing. A dedicatedMCP_LIST_TOOLSspan role (with its own typedMCPListToolsSpanData) fixes that. Per the OTel GenAI MCP semconv the span is namedtools/list, is CLIENT kind, and carriesmcp.method.name; it omitsgen_ai.operation.nameandgen_ai.tool.name, which the spec reserves for tool executions2. Attach MCP spans to the caller's trace, not the HTTP session. Streamable-HTTP multiplexes many JSON-RPC messages over one session, so the request-root anchor captured on
initializepersisted and every later message nested under it; atools/listrun a few seconds later rendered that far to the right of its parent with a clock-skew warning, because the MCP message and the HTTP transport are independent lifecycles. Per the MCP context-propagation rules, each MCP span now parents to the W3C trace context the client propagates inparams._meta(SEP-414), records the transport/session span as a span link rather than the parent, and starts its own root trace when nothing was propagated. This applies totools/calltoo, which shared the bug. This is the OTel GenAI MCP semconv topology: MCP and transport contexts are independent, so the transport span is recorded as a span link, not a parent, and every MCP operation is its own trace linked to the HTTP/session span, uniformly across the stateful and stateless paths. Heads-up for reviewers: clients that don't propagate context (the common case today) get theirtools/call/tools/listspans in their own trace linked to the transport rather than nested under the HTTP request; that is the intended semconv topology, not a regression3. Stop trusting identity from the client.
params._metais caller-controlled and also carries W3C baggage, andLiteLLMBaggageSpanProcessorstamps allowlisted baggage keys (litellm.team.id,litellm.metadata.*) onto every span. A client could sendparams._meta.baggage: litellm.team.id=...,litellm.metadata.user_api_key_user_id=...and have those identity attributes attributed to its own spans, poisoning team/user dashboards and any metric derived from span attributes. The propagator now extracts trace context only (traceparent/tracestate), and the gateway stops collecting the baggage key at the source4. Stamp the authenticated identity instead. Dropping client baggage (change 3) left the tool-call and tools/list spans with no team/key/metadata, so they couldn't be attributed or filtered by team. A shared
_seed_identity_baggagehelper now seeds identity from the parsed, authenticatedStandardLoggingPayload, the same source the LLM-call span already used, so attribution is restored without reopening the spoofing vector. Regression tests assert the authenticated team lands on both MCP spans and that a spoofedparams._meta.baggagevalue can't override it