From 26823b5e94cc070afae673845c1d18ddcd72f87c Mon Sep 17 00:00:00 2001 From: shivam Date: Fri, 24 Jul 2026 18:46:46 +0000 Subject: [PATCH] fix(otel): nest MCP span under transport when client sends no trace context Under otel_v2, an MCP tool call surfaced as two separate traces joined only by a span link: the HTTP transport POST and the tools/call span. 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 span as a link. When the client sends no traceparent (the common case today, incl. MCP Inspector), the extracted parent carried no span, so the MCP span started its own disconnected root trace. Fall back to nesting the MCP span under the per-request transport span (the FastAPI SERVER span of this message's POST) when no remote trace context is propagated, so the call stays in one unified trace. The propagated-context path is unchanged: it still parents to the remote context and links the transport per the OTel GenAI MCP semconv. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/otel/model/spans.py | 25 +++++---- litellm/integrations/otel/plumbing/context.py | 41 ++++++++------ .../proxy/_experimental/mcp_server/server.py | 10 ++-- .../integrations/otel/test_otel_v2_logger.py | 54 ++++++++++++------- 4 files changed, 82 insertions(+), 48 deletions(-) diff --git a/litellm/integrations/otel/model/spans.py b/litellm/integrations/otel/model/spans.py index c93f95ec97d..78cf6a2f889 100644 --- a/litellm/integrations/otel/model/spans.py +++ b/litellm/integrations/otel/model/spans.py @@ -19,11 +19,13 @@ not a child of it. The emitter parents every span to the ambient OTel context (the active server span), which matches this. MCP spans (``MCP_TOOL_CALL``, ``MCP_LIST_TOOLS``) are intentionally NOT in this -tree. Per the OTel GenAI MCP semconv, MCP and the HTTP transport are independent -contexts, so an MCP span parents to the trace context the client propagated in -``params._meta`` (or starts its own root when none is propagated) and records the -``PROXY_REQUEST`` transport span as a span *link*, never a parent. The registry -encodes this as ``parent=None, links=PROXY_REQUEST``. +tree. Per the OTel GenAI MCP semconv, when the client propagates trace context in +``params._meta`` MCP and the HTTP transport are independent contexts, so the MCP +span parents to that propagated context and records the ``PROXY_REQUEST`` transport +span as a span *link*, never a parent. When no context is propagated (the common +case) it instead nests under the ``PROXY_REQUEST`` transport span so the call stays +in one trace; see :func:`resolve_mcp_span_context`. The registry's +``parent=None, links=PROXY_REQUEST`` encodes the propagated-context shape. Not every service call becomes a span — :func:`span_role_for_service` decides: @@ -89,12 +91,13 @@ class SpanSpec: SPAN_REGISTRY: dict[SpanRole, SpanSpec] = { SpanRole.PROXY_REQUEST: SpanSpec(SpanRole.PROXY_REQUEST, LiteLLMSpanKind.SERVER, parent=None), SpanRole.LLM_CALL: SpanSpec(SpanRole.LLM_CALL, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST), - # MCP and the HTTP transport are independent contexts (OTel GenAI MCP semconv), - # so an MCP span does not nest under the transport span. The proxy is an MCP - # client to the upstream server, so it's a CLIENT span; it parents to the trace - # context the client propagated in ``params._meta`` (or starts its own root when - # none is propagated) and records the PROXY_REQUEST transport span as a span - # *link*, never a parent — hence ``parent=None, links=PROXY_REQUEST``. + # The proxy is an MCP client to the upstream server, so MCP spans are CLIENT + # spans. When the client propagates trace context in ``params._meta`` (OTel + # GenAI MCP semconv), the span parents to that propagated context and records + # the PROXY_REQUEST transport span as a *link*, never a parent — the shape this + # ``parent=None, links=PROXY_REQUEST`` entry encodes. When none is propagated + # (the common case) ``resolve_mcp_span_context`` nests the span under the + # transport span instead, keeping the call in one trace. SpanRole.MCP_TOOL_CALL: SpanSpec( SpanRole.MCP_TOOL_CALL, LiteLLMSpanKind.CLIENT, parent=None, links=SpanRole.PROXY_REQUEST ), diff --git a/litellm/integrations/otel/plumbing/context.py b/litellm/integrations/otel/plumbing/context.py index 8acac112c3d..a5d1bc57640 100644 --- a/litellm/integrations/otel/plumbing/context.py +++ b/litellm/integrations/otel/plumbing/context.py @@ -134,31 +134,42 @@ def resolve_mcp_span_context( ) -> "tuple[Context, tuple[Link, ...]]": """Parent context + links for an MCP message span, per the OTel GenAI MCP semconv. - MCP and the underlying transport (HTTP) are independent lifecycles — one - streamable-HTTP session multiplexes many messages, so nesting the message span - under the HTTP/session span is wrong (it renders the message at the session's - start, skewed by however long the session has been open). Instead: + When the client propagates W3C trace context in the request's ``params._meta`` + (SEP-414), MCP and the underlying transport (HTTP) are treated as independent + lifecycles — one streamable-HTTP session can multiplex many messages, so nesting + the message span under the transport span would render it skewed at the session's + start. So in that case: - * parent to the trace context the client propagated in the request's - ``params._meta`` (a *remote* parent), and - * record the transport/session span as a *link*, never the parent. + * parent to the trace context the client propagated (a *remote* parent), and + * record the transport span as a *link*, never the parent. + + When the client propagates no trace context (the common case today, since almost + no MCP client implements SEP-414 yet), there is no remote parent to honor and the + span would otherwise start its own disconnected root trace, surfacing as two + separate traces joined only by a link. Instead, fall back to nesting under the + per-request transport span (``request_root_span()`` — the FastAPI SERVER span of + this message's POST, not a long-lived session span), so the tool call stays in + one unified trace. No link is added there since the transport is now the parent. + With neither a remote parent nor a transport span the returned context carries no + span and the span legitimately starts its own root trace. Only trace context (``traceparent``/``tracestate``) is extracted, never the client's W3C Baggage: ``params._meta`` is caller-controlled, and the otel baggage processor stamps allowlisted baggage keys (``litellm.team.id``, ``litellm.metadata.*``, ...) onto the span as attributes, so honoring remote - baggage would let a client spoof a span's identity attribution. - - With no propagated context the returned context carries no span, so the span - starts its own root trace (still linked to the transport). The base context is - explicitly empty so an absent ``traceparent`` can never fall through to the - ambient (stale session) span. + baggage would let a client spoof a span's identity attribution. The base context + for extraction is explicitly empty so an absent/malformed ``traceparent`` can + never fall through to the ambient (stale session) span. """ source = carrier if carrier is not None else _mcp_message_trace_carrier.get() parent = _PROPAGATOR.extract(dict(source or {}), context=Context()) transport = request_root_span() - links = (Link(transport.get_span_context()),) if transport is not None else () - return parent, links + if is_recordable_span(get_current_span(parent)): + links = (Link(transport.get_span_context()),) if transport is not None else () + return parent, links + if transport is not None: + return context_from_span(transport), () + return parent, () def is_recordable_span(obj: object) -> bool: diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 4fca4406a6f..fd7a7c9ccb5 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -242,10 +242,12 @@ def _mcp_meta_trace_carrier(req_ctx: object) -> Optional[dict[str, str]]: """The W3C trace context (``traceparent``/``tracestate``) the MCP client propagated in the request's ``params._meta`` (SEP-414), or ``None``. - Per the OTel MCP semconv the MCP span parents to this propagated context rather - than to the HTTP/session transport (which is recorded as a link instead), so a - streamable-HTTP session that multiplexes many messages does not glue every - message under the session's first request. The client's W3C Baggage is + When present, per the OTel MCP semconv the MCP span parents to this propagated + context rather than to the HTTP/session transport (which is recorded as a link + instead), so a streamable-HTTP session that multiplexes many messages does not + glue every message under the session's first request. When absent (the common + case) the span falls back to nesting under the transport span so the call stays + in one trace; see ``resolve_mcp_span_context``. The client's W3C Baggage is deliberately excluded: it is caller-controlled, and the otel baggage processor stamps allowlisted baggage keys (``litellm.team.id``, ``litellm.metadata.*``, ...) onto the span, so honoring remote baggage would let a client spoof a diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py index 5f6002f4cdf..ebdf473e354 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py @@ -528,15 +528,15 @@ _MCP_SPAN_CASES = [ @pytest.mark.parametrize("make_payload, span_name", _MCP_SPAN_CASES) -def test_mcp_span_roots_and_links_transport_without_propagated_context( +def test_mcp_span_nests_under_transport_without_propagated_context( make_payload, span_name ): - """MCP and the HTTP transport are independent lifecycles (one streamable-HTTP - session multiplexes many messages), so per the MCP semconv the message span - must NOT nest under the session/transport span — that is what made it render - skewed at the session's start. With no propagated ``params._meta`` context it - starts its own root trace and records the transport span as a *link*, never - the parent.""" + """With no propagated ``params._meta`` trace context (the common case, since + almost no MCP client implements SEP-414 yet) the message span nests under the + per-request transport span so the tool call stays in ONE unified trace, instead + of starting a disconnected root trace joined only by a link (which surfaced as + two separate traces in APM). The transport is the real parent, so no link is + recorded.""" logger, exporter = _logger() transport = logger._emitter.start_span( SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME @@ -549,11 +549,28 @@ def test_mcp_span_roots_and_links_transport_without_propagated_context( ) transport.end() span = next(s for s in exporter.get_finished_spans() if s.name == span_name) + assert span.parent is not None + assert span.parent.span_id == transport.get_span_context().span_id + assert span.context.trace_id == transport.get_span_context().trace_id + assert span.links == () + + +@pytest.mark.parametrize("make_payload, span_name", _MCP_SPAN_CASES) +def test_mcp_span_roots_without_transport_or_propagated_context( + make_payload, span_name +): + """With neither a remote parent nor an anchored transport span there is nothing + to nest under, so the span legitimately starts its own root trace with no + links.""" + logger, exporter = _logger() + asyncio.run( + logger.async_log_success_event( + {"standard_logging_object": make_payload()}, None, None, None + ) + ) + span = next(s for s in exporter.get_finished_spans() if s.name == span_name) assert span.parent is None - assert span.context.trace_id != transport.get_span_context().trace_id - assert [link.context.span_id for link in span.links] == [ - transport.get_span_context().span_id - ] + assert span.links == () @pytest.mark.parametrize("make_payload, span_name", _MCP_SPAN_CASES) @@ -641,10 +658,11 @@ def test_mcp_span_carries_authenticated_identity(make_payload, span_name): assert span.attributes[LiteLLM.TEAM_ID] == "t1" -def test_mcp_span_malformed_traceparent_starts_root(): +def test_mcp_span_malformed_traceparent_nests_under_transport(): """A malformed traceparent in ``params._meta`` must not crash or parent to a - bogus span: the propagator ignores it, so the span starts its own root trace and - still links the transport span.""" + bogus span: the propagator ignores it, leaving no remote parent, so the span + falls back to nesting under the transport span (one unified trace) rather than + starting a disconnected root trace.""" logger, exporter = _logger() transport = logger._emitter.start_span( SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME @@ -661,10 +679,10 @@ def test_mcp_span_malformed_traceparent_starts_root(): reset_mcp_message_trace_carrier(token) transport.end() span = next(s for s in exporter.get_finished_spans() if s.name == "tools/list") - assert span.parent is None - assert [link.context.span_id for link in span.links] == [ - transport.get_span_context().span_id - ] + assert span.parent is not None + assert span.parent.span_id == transport.get_span_context().span_id + assert span.context.trace_id == transport.get_span_context().trace_id + assert span.links == () def test_pre_call_idempotent_keeps_first_span():