Skip to content

Commit 5767271

Browse files
committed
extract_trace_context: return None when carrier has no valid traceparent
A non-empty _meta without a traceparent key (e.g. only a progressToken) made extract() return an empty Context, which orphans the span when passed explicitly to start_as_current_span. Check the extracted context carries a valid span and return None otherwise so callers fall through to ambient parenting.
1 parent ddd79f0 commit 5767271

2 files changed

Lines changed: 37 additions & 5 deletions

File tree

src/mcp/shared/_otel.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from opentelemetry.context import Context
1010
from opentelemetry.propagate import extract, inject
11-
from opentelemetry.trace import SpanKind, get_tracer
11+
from opentelemetry.trace import SpanKind, get_current_span, get_tracer
1212
from opentelemetry.trace.span import Span
1313

1414
_tracer = get_tracer("mcp-python-sdk")
@@ -44,13 +44,17 @@ def inject_trace_context(meta: dict[str, Any]) -> None:
4444
def extract_trace_context(meta: Mapping[str, Any] | None) -> Context | None:
4545
"""Extract W3C trace context from a `_meta` dict.
4646
47-
Returns `None` when the carrier is absent or malformed so callers fall
48-
through to ambient parenting; an explicit empty `Context` would orphan
49-
the span instead of nesting under the current one.
47+
Returns `None` when the carrier is absent, malformed, or carries no
48+
valid `traceparent`, so callers fall through to ambient parenting; an
49+
explicit empty `Context` would orphan the span instead of nesting under
50+
the current one.
5051
"""
5152
if not meta:
5253
return None
5354
try:
54-
return extract(meta)
55+
ctx = extract(meta)
5556
except (ValueError, TypeError):
5657
return None
58+
if not get_current_span(ctx).get_span_context().is_valid:
59+
return None
60+
return ctx

tests/server/test_otel.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,34 @@ async def wrapped(dctx: Any, method: str, params: dict[str, Any] | None) -> Any:
9595
assert inner.parent.span_id == outer.context.span_id
9696

9797

98+
@pytest.mark.anyio
99+
async def test_nests_under_ambient_span_when_meta_lacks_traceparent(server: SrvT, spans: SpanCapture):
100+
"""`_meta` is present but carries no `traceparent` (e.g. only a
101+
`progressToken`). `extract()` would yield an empty Context here, which
102+
would orphan the span; the middleware must fall through to ambient
103+
parenting just as if `_meta` were absent."""
104+
105+
def replace_meta(call_next: Any) -> Any:
106+
async def wrapped(dctx: Any, method: str, params: dict[str, Any] | None) -> Any:
107+
rewritten = {**(params or {}), "_meta": {"progressToken": "tok"}}
108+
return await call_next(dctx, method, rewritten)
109+
110+
return wrapped
111+
112+
server.middleware.append(OpenTelemetryMiddleware())
113+
async with connected_runner(server, dispatch_middleware=[replace_meta, otel_middleware]) as (client, _):
114+
spans.clear()
115+
await client.send_raw_request("tools/list", None)
116+
server_spans = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
117+
assert len(server_spans) == 2
118+
[outer] = [s for s in server_spans if s.parent is None]
119+
[inner] = [s for s in server_spans if s.parent is not None]
120+
assert inner.context is not None and outer.context is not None
121+
assert inner.parent is not None
122+
assert inner.context.trace_id == outer.context.trace_id
123+
assert inner.parent.span_id == outer.context.span_id
124+
125+
98126
@pytest.mark.anyio
99127
async def test_extracts_trace_context_from_meta(server: SrvT, spans: SpanCapture):
100128
meta: dict[str, Any] = {}

0 commit comments

Comments
 (0)