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>
This commit is contained in:
shivam 2026-07-24 18:46:46 +00:00
parent c255f53bfb
commit 26823b5e94
4 changed files with 82 additions and 48 deletions

View file

@ -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
),

View file

@ -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:

View file

@ -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

View file

@ -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():