Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,7 @@ ctx: ClientRequestContext
server_ctx: ServerRequestContext[LifespanContextT, RequestT]
```

`ServerRequestContext` is now a standalone dataclass — it no longer subclasses `RequestContext[ServerSession]`. It carries the same fields (`session`, `request_id`, `meta`, `lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) plus a new `protocol_version: str` field, so handler code is unaffected, but `isinstance(ctx, RequestContext)` checks and `RequestContext[ServerSession]` annotations need updating to `ServerRequestContext`.
`ServerRequestContext` is now a standalone dataclass — it no longer subclasses `RequestContext[ServerSession]`. It carries the same fields (`session`, `request_id`, `meta`, `lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) plus new `protocol_version: str`, `method: str`, and raw `params: Mapping[str, Any] | None` fields (the last two let middleware read and rewrite the inbound message), so handler code is unaffected, but `isinstance(ctx, RequestContext)` checks and `RequestContext[ServerSession]` annotations need updating to `ServerRequestContext`.

The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient:

Expand Down Expand Up @@ -935,27 +935,24 @@ server.add_notification_handler("notifications/custom", MyNotifyParams, my_notif
These were private, but some users subclassed `Server` and overrode them to intercept requests. Use middleware instead:

```python
from collections.abc import Mapping
from typing import Any

from mcp.server import Server, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult


async def logging_middleware(
ctx: ServerRequestContext[Any, Any], method: str, params: Mapping[str, Any] | None, call_next: CallNext
) -> HandlerResult:
print(f"handling {method}")
result = await call_next()
print(f"done {method}")
async def logging_middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
print(f"handling {ctx.method}")
result = await call_next(ctx)
print(f"done {ctx.method}")
return result


server = Server("my-server", on_call_tool=...)
server.middleware.append(logging_middleware)
```

Middleware runs before params validation, so `params` is the raw inbound mapping (or `None`), and it also wraps unknown methods.
The method and the raw inbound params are `ctx.method` and `ctx.params` (`params` is `None` when the message carries none). Middleware runs before params validation and also wraps unknown methods. To rewrite the method or params before the handler runs, pass an adjusted context through: `await call_next(replace(ctx, params=...))`.

### Lowlevel `Server.run(raise_exceptions=True)`: transport errors no longer re-raised

Expand Down
51 changes: 51 additions & 0 deletions src/mcp/server/_otel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from __future__ import annotations

from typing import Any

from opentelemetry.trace import SpanKind, StatusCode
from pydantic import ValidationError

from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
from mcp.shared._otel import extract_trace_context, otel_span
from mcp.shared.exceptions import MCPError


class OpenTelemetryMiddleware(ServerMiddleware[Any]):
"""Context-tier middleware that wraps each inbound message in an OpenTelemetry span.

Span name `"MCP handle <method> [<target>]"`, `mcp.method.name` attribute, W3C
trace context extracted from `params._meta` (SEP-414), and an ERROR status if
the handler raises. Requests and notifications both get a span;
`jsonrpc.request.id` is set only when `ctx.request_id` is present (notifications
have none).
"""

async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
name = ctx.params.get("name") if ctx.params else None
target = name if isinstance(name, str) else None

attributes: dict[str, Any] = {"mcp.method.name": ctx.method}
if ctx.request_id is not None:
attributes["jsonrpc.request.id"] = str(ctx.request_id)

with otel_span(
name=f"MCP handle {ctx.method}{f' {target}' if target else ''}",
kind=SpanKind.SERVER,
attributes=attributes,
context=extract_trace_context(ctx.meta or {}),
record_exception=False,
set_status_on_exception=False,
) as span:

Check failure on line 38 in src/mcp/server/_otel.py

View check run for this annotation

Claude / Claude Code Review

OpenTelemetryMiddleware passes empty Context, detaching spans from ambient trace

When the inbound message has no `_meta`/traceparent, `extract_trace_context(ctx.meta or {})` returns a fresh empty `Context`, and passing that explicit context to `otel_span` overrides the ambient current context — so the span becomes an orphaned trace root instead of parenting to the already-current span (e.g. the dispatch-tier `otel_middleware` span that `Server.run()` installs by default). This diverges from the dispatch-tier middleware this class mirrors, which passes `parent=None` when para
Comment on lines +31 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 When the inbound message has no _meta/traceparent, extract_trace_context(ctx.meta or {}) returns a fresh empty Context, and passing that explicit context to otel_span overrides the ambient current context — so the span becomes an orphaned trace root instead of parenting to the already-current span (e.g. the dispatch-tier otel_middleware span that Server.run() installs by default). This diverges from the dispatch-tier middleware this class mirrors, which passes parent=None when params has no _meta. Pass context=None (or only pass the extracted context when ctx.meta actually carries trace headers) so the no-traceparent case attaches to the ambient context.

Extended reasoning...

The bug. OpenTelemetryMiddleware.__call__ always calls otel_span(..., context=extract_trace_context(ctx.meta or {})). extract_trace_context calls opentelemetry.propagate.extract(carrier); the W3C TraceContextTextMapPropagator creates a fresh empty Context() when no context argument is supplied and returns it unchanged when the carrier has no traceparent. So whenever the client does not propagate trace context in _meta (i.e. every non-OTel-instrumented client), the middleware starts its span with an explicit empty Context rather than context=None. In the OTel SDK, Tracer.start_span resolves the parent via trace.get_current_span(context): with an explicit context the ambient/current context is ignored, the lookup yields INVALID_SPAN, and the new span becomes a brand-new trace root. Only context=None falls back to the ambient current span.\n\nThe code path that triggers it. Server.run() (src/mcp/server/lowlevel/server.py:431) unconditionally installs the dispatch-tier otel_middleware in dispatch_middleware. That middleware wraps _on_request with start_as_current_span in the same task, so its span is the current ambient span around the entire context-tier middleware chain. A user who appends the new OpenTelemetryMiddleware to server.middleware and is hit by a client that sends no _meta therefore gets a context-tier span that is an orphaned root in a separate trace, instead of a child of the dispatch-tier (or any other ambient transport/ASGI) span.\n\nStep-by-step proof.\n1. A non-OTel client sends tools/call with no _meta.\n2. The composed dispatch chain runs otel_middleware: params has no _meta, so it passes parent=None, and start_as_current_span makes span A ("MCP handle tools/call ...") the current span.\n3. Inside span A, _on_request builds ctx with meta=None and runs the context-tier chain. OpenTelemetryMiddleware computes extract_trace_context(ctx.meta or {}) = extract({}) → a fresh empty Context().\n4. otel_span forwards that explicit empty context to start_as_current_span. The SDK resolves the parent from that context → INVALID_SPAN → span B is created as a new trace root with a new trace_id.\n5. Result: the same request produces two disjoint traces (A and B) instead of B nesting under A. With context=None in step 4, B would have parented to A.\n\nWhy existing code/tests don't catch it. test_extracts_trace_context_from_meta always injects a traceparent (the in-SDK test client path), and the notification test never asserts on span.parent, so the no-traceparent parenting behavior is unpinned.\n\nOn the counter-argument that this is the conventional server-instrumentation pattern. ASGI/WSGI instrumentations do attach the extracted (possibly empty) context, but they sit at the outermost edge of the process where there is no meaningful ambient span to detach from — that trade-off doesn't apply here, where the SDK itself installs an enclosing dispatch-tier span by default. It's also true that the dispatch-tier middleware extracts an explicit context whenever _meta is present even without a traceparent; but the divergence at issue is the no-_meta case (the common case for external clients), where the dispatch-tier middleware deliberately passes parent=None and this class — whose docstring says it mirrors that span shape — does not. The duplicate "MCP handle" spans when both tiers are enabled are documented and fine; what's broken is that they land in two unrelated traces rather than nesting, which is a telemetry-correctness defect in the very feature this PR adds, not merely a span-shape preference.\n\nFix. Pass context=None when ctx.meta carries no trace headers — e.g. only call extract_trace_context when ctx.meta contains traceparent (mirroring the dispatch-tier match on _meta), or change extract_trace_context to return None for an empty/header-less carrier. Impact is telemetry-only (no request-handling breakage), but it should be fixed in this PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense

try:
return await call_next(ctx)
except MCPError as e:
span.set_status(StatusCode.ERROR, e.error.message)
raise
except ValidationError:
# Mirror the sanitized wire response; pydantic messages carry client input.
span.set_status(StatusCode.ERROR, "Invalid request parameters")
raise
except Exception as e:
span.record_exception(e)
span.set_status(StatusCode.ERROR, str(e))
raise
34 changes: 18 additions & 16 deletions src/mcp/server/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from mcp.shared.transport_context import TransportContext
from mcp.types import LoggingLevel, RequestId, RequestParamsMeta

# Invariant: parameterizes a mutable dataclass field; dict default matches the default lifespan.
# Invariant: parametrizes a mutable dataclass field; dict default matches the default lifespan.
LifespanContextT = TypeVar("LifespanContextT", default=dict[str, Any])
RequestT = TypeVar("RequestT", default=Any)

Expand All @@ -33,6 +33,8 @@ class ServerRequestContext(Generic[LifespanContextT, RequestT]):
session: ServerSession
lifespan_context: LifespanContextT
protocol_version: str
method: str
params: Mapping[str, Any] | None = None
request_id: RequestId | None = None
meta: RequestParamsMeta | None = None
request: RequestT | None = None
Expand Down Expand Up @@ -113,39 +115,41 @@ async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *
"""What a request handler (or middleware) may return. `ServerRunner` serializes
all three to a result dict."""

CallNext = Callable[[], Awaitable[HandlerResult]]
CallNext = Callable[["ServerRequestContext[Any, Any]"], Awaitable[HandlerResult]]
"""Invokes the rest of the chain. Pass the `ctx` through; rewrite `method` or
`params` with `dataclasses.replace(ctx, ...)` to alter what the handler sees."""

_MwLifespanT = TypeVar("_MwLifespanT")


class ServerMiddleware(Protocol[_MwLifespanT]):
"""Context-tier middleware: `(ctx, method, params, call_next) -> result`.
"""Context-tier middleware: `(ctx, call_next) -> result`.

Runs at the top of `ServerRunner._on_request` / `_on_notify` after `ctx`
is built but before any validation, lookup, or handshake. Wraps every
inbound request and notification: `initialize`, the pre-init gate,
`METHOD_NOT_FOUND`, params validation, the handler call, and
`notifications/initialized` all run inside `call_next()`.
`notifications/initialized` all run inside `call_next(ctx)`.
`notifications/cancelled` is observed too; the dispatcher applies the
cancellation itself, then forwards the notification. A request-side
failure reaches the middleware as a raised `MCPError` (or
`ValidationError` for malformed params) so observation/logging middleware
can record it. Listed outermost-first on `Server.middleware`.

The method and the raw inbound params are `ctx.method` and `ctx.params` (no
model validation has happened yet). To rewrite either before the handler
runs, pass an adjusted context: `await call_next(replace(ctx, params=...))`.
`ctx.request_id is None` distinguishes a notification from a request. For
notifications `call_next()` returns `None` (a dropped or unhandled
notifications `call_next(ctx)` returns `None` (a dropped or unhandled
notification also returns `None`) and the middleware's own return value is
discarded.

`params` is the raw inbound mapping (no model validation has happened
yet). For typed inspection, validate against the model the middleware
expects.

Warning: `initialize` is handled inline - the dispatcher does not read
further inbound messages until the middleware chain returns. Awaiting a
server-to-client request (`ctx.session.send_request`, `send_ping`, ...)
while handling `initialize` therefore deadlocks the connection: the
response can never be dequeued. Send-and-forget notifications are safe.
!!! warning
`initialize` is handled inline - the dispatcher does not read
further inbound messages until the middleware chain returns. Awaiting a
server-to-client request (`ctx.session.send_request`, `send_ping`, ...)
while handling `initialize` therefore deadlocks the connection: the
response can never be dequeued. Send-and-forget notifications are safe.

`Server[L].middleware` holds `ServerMiddleware[L]`, so an app-specific
middleware sees `ctx.lifespan_context: L`. While the context is the
Expand All @@ -162,7 +166,5 @@ class ServerMiddleware(Protocol[_MwLifespanT]):
async def __call__(
self,
ctx: ServerRequestContext[_MwLifespanT, Any],
method: str,
params: Mapping[str, Any] | None,
call_next: CallNext,
) -> HandlerResult: ...
2 changes: 1 addition & 1 deletion src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ def __init__(
self._session_manager: StreamableHTTPSessionManager | None = None
# Context-tier middleware: wraps every inbound request (including
# `initialize`, lookup, validation, handler) with
# `(ctx, method, params, call_next)`. Applied in `ServerRunner._on_request`.
# `(ctx, call_next)`. Applied in `ServerRunner._on_request`.
# TODO(maxisbey): provisional - signature and semantics change with the
# Context/middleware rework (covariant `Context[L]`, outbound seam) before
# v2 final.
Expand Down
60 changes: 37 additions & 23 deletions src/mcp/server/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from __future__ import annotations

import logging
from collections.abc import Mapping
from collections.abc import Awaitable, Mapping
from dataclasses import dataclass, field
from functools import partial, reduce
from typing import TYPE_CHECKING, Any, Generic, cast
Expand Down Expand Up @@ -103,7 +103,7 @@
return "2025-11-25"


def otel_middleware(next_on_request: OnRequest) -> OnRequest:
def otel_middleware(call_next: OnRequest) -> OnRequest:
"""Dispatch-tier middleware that wraps each request in an OpenTelemetry span.

Mirrors the span shape of the existing `Server._handle_request`: span name
Expand Down Expand Up @@ -139,7 +139,7 @@
set_status_on_exception=False,
) as span:
try:
return await next_on_request(dctx, method, params)
return await call_next(dctx, method, params)
except MCPError as e:
span.set_status(StatusCode.ERROR, e.error.message)
raise
Expand Down Expand Up @@ -169,6 +169,14 @@
raise TypeError(f"handler returned {type(result).__name__}; expected BaseModel, dict, or None")


def _apply_middleware(
mw: ServerMiddleware[Any], call_next: CallNext, ctx: ServerRequestContext[Any, Any]
) -> Awaitable[HandlerResult]:
"""Adapt one middleware to the `CallNext` shape: bind `call_next`, take
`ctx` at call time so a rewritten context flows down the chain."""
return mw(ctx, call_next)
Comment on lines +172 to +177

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary?

Also, we should stop using "mw" in this code source!!! I don't need to guess variable names!



@dataclass
class ServerRunner(Generic[LifespanT]):
"""Per-connection orchestrator. One instance per client connection."""
Expand Down Expand Up @@ -244,15 +252,18 @@
) -> dict[str, Any]:
meta = _extract_meta(params)
version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata)
ctx = self._make_context(dctx, meta, version)
ctx = self._make_context(dctx, method, params, meta, version)
is_spec_method = method in _methods.SPEC_CLIENT_METHODS

async def _inner() -> HandlerResult:
async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult:
# Read method/params off `ctx` so a middleware that rewrote them via
# `call_next(replace(ctx, ...))` reaches lookup and the handler.
method, params = ctx.method, ctx.params
# Pinned compat: spec methods are surface-validated before lookup,
# so malformed params are INVALID_PARAMS even with no handler
# registered. Custom methods miss the monolith map and fall through
# to `entry.params_type` exactly as before.
if is_spec_method:
if method in _methods.SPEC_CLIENT_METHODS:
try:
_methods.validate_client_request(method, version, params)
except KeyError:
Expand All @@ -279,14 +290,14 @@
result = await entry.handler(ctx, typed_params)
if isinstance(result, ErrorData):
# Raise inside the chain so middleware observes the failure.
raise MCPError.from_error_data(result)
return result

call = self._compose_server_middleware(ctx, method, params, _inner)
result = _dump_result(await call())
call = self._compose_server_middleware(_inner)
result = _dump_result(await call(ctx))
# TODO: reject resultType values outside {"complete", "input_required"} unless the
# corresponding extension is in this request's _meta clientCapabilities.extensions; the
# explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively.

Check failure on line 300 in src/mcp/server/runner.py

View check run for this annotation

Claude / Claude Code Review

initialize state commit ignores middleware-rewritten ctx.params/method

The post-chain `initialize` state commit (`if method == "initialize": ... self._negotiate_initialize(params)`) reads the `method`/`params` locals captured before the middleware chain ran, while `_inner` and `_handle_initialize` use the possibly-rewritten `ctx.method`/`ctx.params`. A middleware that rewrites the initialize message via `call_next(replace(ctx, params=...))` — the rewrite capability this PR introduces — gets an `InitializeResult` negotiated from the rewritten params while `connectio
Comment on lines 293 to 300

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The post-chain initialize state commit (if method == "initialize": ... self._negotiate_initialize(params)) reads the method/params locals captured before the middleware chain ran, while _inner and _handle_initialize use the possibly-rewritten ctx.method/ctx.params. A middleware that rewrites the initialize message via call_next(replace(ctx, params=...)) — the rewrite capability this PR introduces — gets an InitializeResult negotiated from the rewritten params while connection.protocol_version/client_params are committed from the originals, leaving connection state and the wire response disagreeing for the rest of the connection. The commit (and the is_spec_method/serialization keying) should use the final ctx, not the pre-chain locals.

Extended reasoning...

The bug

Inside _inner, the PR deliberately re-reads the message off the context — method, params = ctx.method, ctx.params — so a middleware that rewrites the inbound message via await call_next(replace(ctx, params=...)) is honored: self._handle_initialize(params) builds the InitializeResult (including the negotiated protocolVersion) from the rewritten params. But after the chain returns, the state commit at the bottom of _on_request runs:

if method == "initialize":
    self.connection.client_params, self.connection.protocol_version = self._negotiate_initialize(params)

Here method and params are the outer locals captured before call = self._compose_server_middleware(_inner) ran. The rewritten context is a new object created by dataclasses.replace() inside the middleware and never escapes the chain — the composed CallNext returns only the HandlerResult — so the commit cannot see the rewritten values.

Why nothing else prevents it

Rewriting initialize is squarely within the contract this PR advertises: the migration doc and the ServerMiddleware docstring both say middleware wraps initialize and may rewrite method/params with replace(ctx, ...). There is no guard or documentation saying initialize must not be rewritten, and the new tests only exercise rewriting tools/call arguments (test_passes_rewritten_context_through), so this seam is untested. The in-chain comment ("Read method/params off ctx so a middleware that rewrote them ... reaches lookup and the handler") shows rewrites are intended to be effective; the post-chain commit was simply left on the pre-rewrite locals.

Impact

The wire response and the committed connection state disagree: every subsequent _resolve_protocol_version call and the outbound serialize_server_result sieve key off the stale connection.protocol_version, and connection.client_params (e.g. clientInfo, capabilities) reflects values the handler never saw — silently, for the lifetime of the connection. The same staleness applies to the if method == "initialize" guard itself (a middleware rewriting ctx.method to or away from initialize makes the commit run, or not, inconsistently with what the handler actually did) and to the is_spec_method/method used for result serialization.

Step-by-step proof

  1. Register a middleware that clamps the protocol version: return await call_next(replace(ctx, params={**(ctx.params or {}), "protocolVersion": "2025-03-26"})).
  2. Client sends initialize with protocolVersion: "2025-11-25".
  3. _inner reads the rewritten ctx.params_handle_initialize negotiates and returns InitializeResult(protocol_version="2025-03-26") to the client.
  4. Back in _on_request, the commit calls self._negotiate_initialize(params) with the original params → connection.protocol_version = "2025-11-25".
  5. The client now believes the session is at 2025-03-26 while the server validates requests, sieves results, and gates spec methods at 2025-11-25 for every later message — connection state and the handshake the client received permanently disagree.

Fix

Key the post-chain commit off the final context rather than the pre-chain locals — e.g. perform the commit inside _inner (where the rewritten ctx is in scope; the chain-success-only semantics can be preserved by committing just before returning the handler result, or by surfacing the final ctx out of the chain), or have _inner stash the final ctx.method/ctx.params for the post-chain block to use.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

honestly not super sure what to do about this one @Kludex do you have any ideas?

if is_spec_method:
try:
result = _methods.serialize_server_result(method, version, result)
Expand All @@ -313,9 +324,10 @@
) -> None:
meta = _extract_meta(params)
version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata)
ctx = self._make_context(dctx, meta, version)
ctx = self._make_context(dctx, method, params, meta, version)

async def _inner() -> None:
async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> None:
method, params = ctx.method, ctx.params
if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS:
try:
_methods.validate_client_notification(method, version, params)
Expand Down Expand Up @@ -345,33 +357,33 @@
return
await entry.handler(ctx, typed_params)

call = self._compose_server_middleware(ctx, method, params, _inner)
call = self._compose_server_middleware(_inner)
try:
await call()
await call(ctx)
except Exception:
# A crashing handler must not cancel the dispatcher's task group;
# middleware saw the raise out of call_next() first.
logger.exception("notification handler for %r raised", method)

def _compose_server_middleware(
self,
ctx: ServerRequestContext[LifespanT, Any],
method: str,
params: Mapping[str, Any] | None,
inner: CallNext,
) -> CallNext:
def _compose_server_middleware(self, inner: CallNext) -> CallNext:
"""Wrap `inner` in `Server.middleware`, outermost-first.

Shared by `_on_request` and `_on_notify` so the same middleware chain
observes every inbound message.
observes every inbound message. The composed callable takes the `ctx`
at call time, so a middleware can rewrite it for the rest of the chain.
"""
call = inner
call: CallNext = inner

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unnecessary type hint.

for mw in reversed(self.server.middleware):
call = partial(mw, ctx, method, params, call)
call = partial(_apply_middleware, mw, call)
return call

def _make_context(
self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None, protocol_version: str
self,
dctx: DispatchContext[TransportContext],
method: str,
params: Mapping[str, Any] | None,
meta: RequestParamsMeta | None,
protocol_version: str,
) -> ServerRequestContext[LifespanT, Any]:
# TODO(maxisbey): remove for Context rework. Reads the SHTTP per-request
# data off the raw `dctx.message_metadata` carrier; replace with the
Expand All @@ -386,6 +398,8 @@
return ServerRequestContext(
session=self.session,
lifespan_context=self.lifespan_state,
method=method,
params=params,
request_id=dctx.request_id,
meta=meta,
protocol_version=protocol_version,
Expand Down
18 changes: 8 additions & 10 deletions src/mcp/shared/_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

from __future__ import annotations

from collections.abc import Iterator
from collections.abc import Generator, Mapping
from contextlib import contextmanager
from typing import Any

from opentelemetry.context import Context
from opentelemetry.propagate import extract, inject
from opentelemetry.trace import SpanKind, get_tracer
from opentelemetry.trace.span import Span

_tracer = get_tracer("mcp-python-sdk")

Expand All @@ -22,7 +23,7 @@ def otel_span(
context: Context | None = None,
record_exception: bool = True,
set_status_on_exception: bool = True,
) -> Iterator[Any]:
) -> Generator[Span]:
"""Create an OTel span."""
with _tracer.start_as_current_span(
name,
Expand All @@ -40,13 +41,10 @@ def inject_trace_context(meta: dict[str, Any]) -> None:
inject(meta)


def extract_trace_context(meta: dict[str, Any]) -> Context | None:
"""Extract W3C trace context from a `_meta` dict.

Returns `None` when the carrier is malformed; telemetry parsing must
never fail the request it annotates.
"""
def extract_trace_context(meta: Mapping[str, Any]) -> Context:
"""Extract W3C trace context from a `_meta` dict."""
try:
return extract(meta)
except (TypeError, ValueError):
return None
except (ValueError, TypeError):
# If the traceparent is malformed, degrade to no parent rather than failing the request.
return Context()
1 change: 1 addition & 0 deletions tests/issues/test_176_progress_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ async def test_progress_token_zero_first_call():
request_context = ServerRequestContext(
request_id="test-request",
session=mock_session,
method="tools/call",
meta={"progress_token": 0},
lifespan_context=None,
protocol_version="2025-11-25",
Expand Down
1 change: 1 addition & 0 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1528,6 +1528,7 @@ async def test_report_progress_passes_related_request_id():
request_context = ServerRequestContext(
request_id="req-abc-123",
session=mock_session,
method="tools/call",
meta={"progress_token": "tok-1"},
lifespan_context=None,
protocol_version="2025-11-25",
Expand Down
Loading
Loading