Make OpenTelemetry tracing the single default middleware (#2995)

This commit is contained in:
Marcelo Trylesinski 2026-06-26 15:47:37 +02:00 • committed by GitHub
commit b31d95a429
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 260 additions and 238 deletions

View file

@ -79,33 +79,12 @@ In increasing order of how much you should hesitate:
an elicitation) while handling `initialize` therefore **deadlocks the connection**: the an elicitation) while handling `initialize` therefore **deadlocks the connection**: the
response you are waiting for can never be read. Fire-and-forget notifications are fine. response you are waiting for can never be read. Fire-and-forget notifications are fine.
## `OpenTelemetryMiddleware` ## The one middleware that ships on by default
The SDK ships one middleware: `OpenTelemetryMiddleware`. Construct it and append it The SDK ships exactly one middleware, and it is already on your server's list: the one that
(`server.middleware.append(OpenTelemetryMiddleware())`), exactly the line you already wrote emits an OpenTelemetry span for every message. You don't append it, and most of the time you
for `log_timing`. don't think about it. It is a no-op until you install an exporter, and it has its own page:
**OpenTelemetry**.
Every inbound message becomes a `SERVER` span named after the method and its target, so a
`tools/call` for `search_books` is the span `tools/call search_books`.
* Every span carries `mcp.method.name` and `mcp.protocol.version`; a request's span also
carries its JSON-RPC request id (a notification has none).
* A `tools/call` span gets OpenTelemetry's GenAI semantic conventions,
`gen_ai.operation.name` (`"execute_tool"`) and `gen_ai.tool.name`, so a tracing UI groups
your tool calls the way it groups any other agent's. A `prompts/get` span gets
`gen_ai.prompt.name`. The list methods carry no `gen_ai.*` keys.
* A handler that raises sets the span's status to error. So does a tool result with
`is_error=True`.
!!! tip
The SDK depends only on `opentelemetry-api`. With no exporter installed those spans are
no-ops, so appending this middleware costs you nothing. Install `opentelemetry-sdk` plus an
exporter and everything lights up, with no server change.
The import is the catch. The class lives at `from mcp.server._otel import OpenTelemetryMiddleware`
today, and the leading underscore is not an accident: it is the same provisional flag this whole
page opened with. The SDK has not given it a public spelling yet, so the import path is the one
line here you should expect to change.
!!! info !!! info
If you have written ASGI middleware, you already know this shape. Starlette's If you have written ASGI middleware, you already know this shape. Starlette's
@ -121,9 +100,8 @@ line here you should expect to change.
unknown methods) and runs outermost-first. unknown methods) and runs outermost-first.
* `ctx.request_id is None` is how you tell a notification from a request. * `ctx.request_id is None` is how you tell a notification from a request.
* Raise instead of calling `call_next` to refuse one message; the connection survives. * Raise instead of calling `call_next` to refuse one message; the connection survives.
* `OpenTelemetryMiddleware` turns each message into a span (with GenAI attributes on tool * The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See
calls and prompt gets) for the price of one `append`, and costs nothing until you install **OpenTelemetry**.
an exporter.
* The whole surface is provisional. Observe with it; don't build on it. * The whole surface is provisional. Observe with it; don't build on it.
That is everything that wraps a request. **Authorization** is what decides whether the request That is everything that wraps a request. **Authorization** is what decides whether the request

View file

@ -0,0 +1,107 @@
# OpenTelemetry
Your server is already traced. You don't have to add anything.
Every server you create emits an [OpenTelemetry](https://opentelemetry.io/) span for every
message it handles. You didn't write that, and you don't import it. It is there the moment you
call `MCPServer(...)`.
```python title="server.py"
--8<-- "docs_src/opentelemetry/tutorial001.py"
```
That is a complete, traced server. Call `search_books` and a span is created for it. The same is
true for the low-level `Server`: the tracing lives on both.
## What you get
Every inbound message becomes a `SERVER` span named after the method and its target. So a
`tools/call` for `search_books` is the span `tools/call search_books`, and a bare `tools/list`
is just `tools/list`.
Each span carries a few attributes:
* `mcp.method.name` and `mcp.protocol.version`, on every span.
* `jsonrpc.request.id`, on a request (a notification has none).
* A handler that raises sets the span status to error. So does a tool result with `is_error=True`.
And because tracing a tool call is such a common thing to want, `tools/call` spans speak
OpenTelemetry's [GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/):
* `gen_ai.operation.name`, set to `"execute_tool"`.
* `gen_ai.tool.name`, set to the tool being called.
A `prompts/get` span gets `gen_ai.prompt.name` in the same spirit. The list methods carry no
`gen_ai.*` keys, because there is nothing to name.
!!! tip
Those GenAI attributes are the reason a tracing UI groups your tool calls the way it groups
any other agent's. You get that grouping for free, with no extra code.
## It costs nothing until you want it
Here is the part that makes "on by default" a comfortable default.
The SDK depends only on `opentelemetry-api`, the lightweight half of OpenTelemetry. With no SDK
and no exporter installed, creating a span is a no-op. So the spans your server is emitting right
now cost you almost nothing, and nobody is collecting them.
The day you want to *see* them, you install the other half and point it somewhere:
```console
uv add opentelemetry-sdk opentelemetry-exporter-otlp
```
Configure an exporter the usual OpenTelemetry way, and every span the SDK has been quietly
creating lights up. Your server code does not change. Not one line.
!!! info
[Pydantic Logfire](https://logfire.pydantic.dev/) is one such backend, and it does the
configuration for you: `pip install logfire`, `logfire.configure()`, and your MCP spans show
up in the live view. It is built on OpenTelemetry, so anything below applies to it too.
## Traces that cross the wire
A trace is most useful when it follows a request from the client into the server, in one
connected picture.
When the client and the server both run the SDK, that connection is automatic. The client injects
the [W3C trace context](https://www.w3.org/TR/trace-context/) into the request, and the server
reads it back out, so the server span nests under the client span in the same trace. This is
[SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414), and you get it without
asking.
If the inbound message carries no trace context, for example a request from a client that is not
the SDK, the server span simply parents to whatever span is already current on the server, rather
than starting a brand-new orphan trace.
## Turning it off
Tracing is a middleware, the first one on your server's list. If you really want a server that
emits no spans, take it off:
```python
from mcp.server._otel import OpenTelemetryMiddleware
mcp._lowlevel_server.middleware[:] = [
m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware)
]
```
!!! warning
That import has a leading underscore, and that is on purpose. The class is provisional, the
same way [`Server.middleware`](middleware.md) is provisional, so the import path is something
you should expect to change. You almost never need this: with no exporter installed the spans
are free, so the usual answer is to leave them on and not install an exporter.
## Recap
* Every `MCPServer` and every low-level `Server` emits one `SERVER` span per inbound message, out
of the box. You write nothing.
* Spans carry `mcp.method.name` and `mcp.protocol.version`; `tools/call` and `prompts/get` also
carry GenAI attributes so your tool calls group like any other agent's.
* It costs nothing until you install an OpenTelemetry SDK and an exporter, and then it lights up
with no change to your server.
* Client-to-server trace context propagates automatically when both sides run the SDK.
Next, the thing that decides whether a request runs at all: **Authorization**.

View file

@ -64,8 +64,8 @@ went to standard error: the terminal, not the wire.
!!! info !!! info
If what you actually want is *tracing* (every request, how long it took, whether it failed), you If what you actually want is *tracing* (every request, how long it took, whether it failed), you
don't want log lines, you want spans. The SDK ships an `OpenTelemetryMiddleware` for exactly that. don't want log lines, you want spans. Your server already emits them: the SDK traces every
See **Middleware**. message with OpenTelemetry out of the box. See **OpenTelemetry**.
## Recap ## Recap

View file

View file

@ -0,0 +1,9 @@
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."

View file

@ -42,6 +42,7 @@ nav:
- The low-level Server: advanced/low-level-server.md - The low-level Server: advanced/low-level-server.md
- Pagination: advanced/pagination.md - Pagination: advanced/pagination.md
- Middleware: advanced/middleware.md - Middleware: advanced/middleware.md
- OpenTelemetry: advanced/opentelemetry.md
- Authorization: advanced/authorization.md - Authorization: advanced/authorization.md
- OAuth clients: advanced/oauth-clients.md - OAuth clients: advanced/oauth-clients.md
- Session groups: advanced/session-groups.md - Session groups: advanced/session-groups.md

View file

@ -52,6 +52,7 @@ from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
from typing_extensions import TypeVar from typing_extensions import TypeVar
from mcp.server._otel import OpenTelemetryMiddleware
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
@ -231,10 +232,13 @@ class Server(Generic[LifespanResultT]):
# Context-tier middleware: wraps every inbound request (including # Context-tier middleware: wraps every inbound request (including
# `initialize`, lookup, validation, handler) with # `initialize`, lookup, validation, handler) with
# `(ctx, call_next)`. Applied in `ServerRunner._on_request`. # `(ctx, call_next)`. Applied in `ServerRunner._on_request`.
# `OpenTelemetryMiddleware` ships on by default so every server emits a
# SERVER span per message; it is a no-op until an OTel exporter is
# installed. Drop it from this list to opt out.
# TODO(L54): provisional - signature and semantics change with the # TODO(L54): provisional - signature and semantics change with the
# Context/middleware rework (covariant `Context[L]`, outbound seam) before # Context/middleware rework (covariant `Context[L]`, outbound seam) before
# v2 final. # v2 final.
self.middleware: list[ServerMiddleware[LifespanResultT]] = [] self.middleware: list[ServerMiddleware[LifespanResultT]] = [OpenTelemetryMiddleware()]
logger.debug("Initializing server %r", name) logger.debug("Initializing server %r", name)
_spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [ _spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [

View file

@ -37,7 +37,6 @@ from mcp_types import (
) )
from mcp_types import methods as _methods from mcp_types import methods as _methods
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from opentelemetry.trace import SpanKind, StatusCode
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from typing_extensions import TypeVar from typing_extensions import TypeVar
@ -45,7 +44,6 @@ from mcp.server.connection import Connection
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
from mcp.server.models import InitializationOptions from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession from mcp.server.session import ServerSession
from mcp.shared._otel import extract_trace_context, otel_span
from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnNotify, OnRequest from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnNotify, OnRequest
from mcp.shared.exceptions import MCPError from mcp.shared.exceptions import MCPError
@ -62,7 +60,6 @@ __all__ = [
"ServerRunner", "ServerRunner",
"aclose_shielded", "aclose_shielded",
"modern_on_request", "modern_on_request",
"otel_middleware",
"serve_connection", "serve_connection",
"serve_loop", "serve_loop",
"serve_one", "serve_one",
@ -91,58 +88,6 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
return None return None
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
`"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.
"""
async def wrapped(
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
) -> dict[str, Any]:
target: str | None
match params:
case {"name": str() as target}:
pass
case _:
target = None
parent: Any | None
match params:
case {"_meta": {**meta}}:
parent = extract_trace_context(meta)
case _:
parent = None
span_name = f"MCP handle {method}{f' {target}' if target else ''}"
# `otel_middleware` wraps `on_request` only, so `request_id` is always set.
attributes = {"mcp.method.name": method, "jsonrpc.request.id": str(dctx.request_id)}
with otel_span(
span_name,
kind=SpanKind.SERVER,
attributes=attributes,
context=parent,
record_exception=False,
set_status_on_exception=False,
) as span:
try:
return await call_next(dctx, method, params)
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
return wrapped
def _dump_result(result: Any) -> dict[str, Any]: def _dump_result(result: Any) -> dict[str, Any]:
if result is None: if result is None:
return {} return {}
@ -196,7 +141,10 @@ class ServerRunner(Generic[LifespanT]):
_: KW_ONLY _: KW_ONLY
init_options: InitializationOptions | None = None init_options: InitializationOptions | None = None
"""`InitializeResult` payload. Defaults to `server.create_initialization_options()`.""" """`InitializeResult` payload. Defaults to `server.create_initialization_options()`."""
dispatch_middleware: Sequence[DispatchMiddleware] = (otel_middleware,) dispatch_middleware: Sequence[DispatchMiddleware] = ()
"""Raw dispatch-tier wrappers `(dctx, method, params) -> dict`, applied outermost-first
around `_on_request`. Empty by default; OpenTelemetry tracing lives at the context tier
(`OpenTelemetryMiddleware`, seeded into `Server.middleware`)."""
@cached_property @cached_property
def on_request(self) -> OnRequest: def on_request(self) -> OnRequest:
@ -223,7 +171,6 @@ class ServerRunner(Generic[LifespanT]):
meta = _extract_meta(params) meta = _extract_meta(params)
version = self.connection.protocol_version version = self.connection.protocol_version
ctx = self._make_context(dctx, method, params, meta, version) ctx = self._make_context(dctx, method, params, meta, version)
is_spec_method = method in _methods.SPEC_CLIENT_METHODS
async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult:
# Read method/params off `ctx` so a middleware that rewrote them via # Read method/params off `ctx` so a middleware that rewrote them via
@ -242,7 +189,7 @@ class ServerRunner(Generic[LifespanT]):
# the gate become a per-version legacy path then. Initialize runs inline # the gate become a per-version legacy path then. Initialize runs inline
# (read loop parked), so awaiting the peer anywhere on this path deadlocks. # (read loop parked), so awaiting the peer anywhere on this path deadlocks.
if method == "initialize": if method == "initialize":
return self._handle_initialize(params) return self._serialize(method, version, self._handle_initialize(params))
# Methods without a handler are METHOD_NOT_FOUND regardless of # Methods without a handler are METHOD_NOT_FOUND regardless of
# initialization state: JSON-RPC 2.0 reserves -32601 for "not # initialization state: JSON-RPC 2.0 reserves -32601 for "not
# available on this server", and clients probing a server before # available on this server", and clients probing a server before
@ -261,25 +208,14 @@ class ServerRunner(Generic[LifespanT]):
if isinstance(result, ErrorData): if isinstance(result, ErrorData):
# Raise inside the chain so middleware observes the failure. # Raise inside the chain so middleware observes the failure.
raise MCPError.from_error_data(result) raise MCPError.from_error_data(result)
return result # Dump and serialize inside the chain so the OpenTelemetry span (the
# outermost middleware) records a failing handler return shape too.
return self._serialize(method, version, result)
call = self._compose_server_middleware(_inner) call = self._compose_server_middleware(_inner)
# `_inner` already produced the wire dict; a middleware that short-circuited
# without `call_next` is trusted to return its own well-formed result.
result = _dump_result(await call(ctx)) result = _dump_result(await call(ctx))
# TODO(L56): 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.
if is_spec_method:
try:
result = _methods.serialize_server_result(method, version, result)
except KeyError:
# Middleware short-circuited a wrong-version spec method without
# calling `call_next`; it owns the result shape.
pass
except ValidationError:
# Server bug, not client fault. Detail stays in the server log:
# pydantic messages echo the result body.
logger.exception("handler for %r returned an invalid result", method)
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
if method == "initialize": if method == "initialize":
# Commit only on chain success, so a middleware veto leaves no state. # Commit only on chain success, so a middleware veto leaves no state.
# Race-free: the read loop is parked until this call returns. # Race-free: the read loop is parked until this call returns.
@ -387,6 +323,28 @@ class ServerRunner(Generic[LifespanT]):
close_standalone_sse_stream=close_standalone_sse_stream, close_standalone_sse_stream=close_standalone_sse_stream,
) )
@staticmethod
def _serialize(method: str, version: str, result: HandlerResult) -> dict[str, Any]:
"""Dump a handler result to the wire dict, serializing spec methods.
Runs inside the middleware chain so the OpenTelemetry span observes a
failing return shape (unsupported type, malformed spec result) as an
error rather than closing on a request that the client sees fail.
"""
dumped = _dump_result(result)
# TODO(L56): 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.
if method not in _methods.SPEC_CLIENT_METHODS:
return dumped
try:
return _methods.serialize_server_result(method, version, dumped)
except ValidationError:
# Server bug, not client fault. Detail stays in the server log:
# pydantic messages echo the result body.
logger.exception("handler for %r returned an invalid result", method)
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
@staticmethod @staticmethod
def _negotiate_initialize(params: Mapping[str, Any] | None) -> tuple[InitializeRequestParams, str]: def _negotiate_initialize(params: Mapping[str, Any] | None) -> tuple[InitializeRequestParams, str]:
"""Validate `initialize` params and pick the protocol version.""" """Validate `initialize` params and pick the protocol version."""

View file

@ -0,0 +1,35 @@
"""`docs/advanced/opentelemetry.md`: every claim the page makes, proved against the real SDK."""
import pytest
from logfire.testing import CaptureLogfire
from docs_src.opentelemetry import tutorial001
from mcp import Client
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_a_plain_server_is_traced_with_no_extra_code(capfire: CaptureLogfire) -> None:
"""tutorial001: calling a tool emits a `tools/call` SERVER span, though the example adds no middleware."""
async with Client(tutorial001.mcp) as client:
await client.call_tool("search_books", {"query": "dune"})
spans = {s["name"]: s for s in capfire.exporter.exported_spans_as_dict()}
assert "tools/call search_books" in spans
attributes = spans["tools/call search_books"]["attributes"]
assert attributes["mcp.method.name"] == "tools/call"
assert attributes["gen_ai.operation.name"] == "execute_tool"
assert attributes["gen_ai.tool.name"] == "search_books"
async def test_client_and_server_share_one_trace(capfire: CaptureLogfire) -> None:
"""When both sides run the SDK, the client and server spans land in one trace (SEP-414)."""
async with Client(tutorial001.mcp, mode="legacy") as client:
await client.call_tool("search_books", {"query": "dune"})
spans = {s["name"]: s for s in capfire.exporter.exported_spans_as_dict()}
client_span = spans["MCP send tools/call search_books"]
server_span = spans["tools/call search_books"]
assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]

View file

@ -1,4 +1,9 @@
"""Tests for `OpenTelemetryMiddleware` (the context-tier OTel span middleware).""" """Tests for `OpenTelemetryMiddleware` (the context-tier OTel span middleware).
Every `Server` ships `OpenTelemetryMiddleware` at the head of `Server.middleware`,
so these tests assert against the default-configured server rather than appending
the middleware by hand.
"""
from dataclasses import replace from dataclasses import replace
from typing import Any from typing import Any
@ -6,6 +11,7 @@ from typing import Any
import anyio import anyio
import pytest import pytest
from mcp_types import ( from mcp_types import (
INTERNAL_ERROR,
INVALID_PARAMS, INVALID_PARAMS,
CallToolRequestParams, CallToolRequestParams,
CallToolResult, CallToolResult,
@ -21,8 +27,7 @@ from opentelemetry.trace import SpanKind, StatusCode
from mcp.server._otel import OpenTelemetryMiddleware from mcp.server._otel import OpenTelemetryMiddleware
from mcp.server.context import CallNext from mcp.server.context import CallNext
from mcp.server.lowlevel.server import Server from mcp.server.lowlevel.server import Server
from mcp.server.runner import otel_middleware from mcp.shared._otel import inject_trace_context, otel_span
from mcp.shared._otel import inject_trace_context
from mcp.shared.exceptions import MCPError from mcp.shared.exceptions import MCPError
from .conftest import SpanCapture from .conftest import SpanCapture
@ -41,10 +46,14 @@ async def _ok_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
return {"content": [], "isError": False} return {"content": [], "isError": False}
def test_server_ships_opentelemetry_middleware_by_default() -> None:
server = Server(name="test-server", version="0.0.1")
assert any(isinstance(m, OpenTelemetryMiddleware) for m in server.middleware)
@pytest.mark.anyio @pytest.mark.anyio
async def test_emits_server_span_with_method_and_target(server: SrvT, spans: SpanCapture): async def test_emits_server_span_with_method_and_target(server: SrvT, spans: SpanCapture):
server.add_request_handler("tools/call", CallToolRequestParams, _ok_tool) server.add_request_handler("tools/call", CallToolRequestParams, _ok_tool)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
spans.clear() spans.clear()
result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}}) result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
@ -65,7 +74,6 @@ async def test_tool_error_dict_result_sets_error_type(server: SrvT, spans: SpanC
return {"content": [], "isError": True} return {"content": [], "isError": True}
server.add_request_handler("tools/call", CallToolRequestParams, err_tool) server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
spans.clear() spans.clear()
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}}) await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
@ -81,7 +89,6 @@ async def test_tool_error_model_result_sets_error_type(server: SrvT, spans: Span
return CallToolResult(content=[], is_error=True) return CallToolResult(content=[], is_error=True)
server.add_request_handler("tools/call", CallToolRequestParams, err_tool) server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
spans.clear() spans.clear()
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}}) await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
@ -99,7 +106,6 @@ async def test_snake_case_dict_result_is_not_a_tool_error(server: SrvT, spans: S
return {"content": [], "is_error": True} return {"content": [], "is_error": True}
server.add_request_handler("tools/call", CallToolRequestParams, err_tool) server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
spans.clear() spans.clear()
result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}}) result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
@ -116,7 +122,6 @@ async def test_named_non_tool_prompt_method_omits_gen_ai_attrs(server: SrvT, spa
return {"content": [], "isError": False} return {"content": [], "isError": False}
server.add_request_handler("custom/op", CallToolRequestParams, custom) server.add_request_handler("custom/op", CallToolRequestParams, custom)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
spans.clear() spans.clear()
await client.send_raw_request("custom/op", {"name": "thing", "arguments": {}}) await client.send_raw_request("custom/op", {"name": "thing", "arguments": {}})
@ -134,7 +139,6 @@ async def test_prompt_get_sets_prompt_name(server: SrvT, spans: SpanCapture):
return GetPromptResult(messages=[]) return GetPromptResult(messages=[])
server.add_request_handler("prompts/get", GetPromptRequestParams, get_prompt) server.add_request_handler("prompts/get", GetPromptRequestParams, get_prompt)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
spans.clear() spans.clear()
await client.send_raw_request("prompts/get", {"name": "myprompt"}) await client.send_raw_request("prompts/get", {"name": "myprompt"})
@ -151,7 +155,6 @@ async def test_notification_span_omits_request_id(server: SrvT, spans: SpanCaptu
return None return None
server.add_notification_handler("notifications/roots/list_changed", NotificationParams, on_roots) server.add_notification_handler("notifications/roots/list_changed", NotificationParams, on_roots)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
spans.clear() spans.clear()
await client.notify("notifications/roots/list_changed", None) await client.notify("notifications/roots/list_changed", None)
@ -163,24 +166,34 @@ async def test_notification_span_omits_request_id(server: SrvT, spans: SpanCaptu
assert "jsonrpc.request.id" not in span.attributes assert "jsonrpc.request.id" not in span.attributes
def _ambient_span(call_next: Any) -> Any:
"""Dispatch-tier wrapper that opens an ambient SERVER span around the whole
request, so the context-tier span has a current span to nest under when the
inbound message carries no traceparent."""
async def wrapped(dctx: Any, method: str, params: dict[str, Any] | None) -> Any:
with otel_span("ambient", kind=SpanKind.SERVER):
return await call_next(dctx, method, params)
return wrapped
@pytest.mark.anyio @pytest.mark.anyio
async def test_nests_under_ambient_span_when_no_traceparent(server: SrvT, spans: SpanCapture): async def test_nests_under_ambient_span_when_no_traceparent(server: SrvT, spans: SpanCapture):
"""With no `_meta` on the inbound message (a non-SDK client), the """With no `_meta` on the inbound message (a non-SDK client), the span must
context-tier span must parent to the ambient current span (here, the parent to the ambient current span rather than become an orphan root.
dispatch-tier `otel_middleware` span) rather than become an orphan root.
SDK-defined: SEP-414 only covers the traceparent-present case.""" SDK-defined: SEP-414 only covers the traceparent-present case."""
def strip_meta(call_next: Any) -> Any: def strip_meta(call_next: Any) -> Any:
# The in-process client always injects `_meta.traceparent`; strip it so # The in-process client always injects `_meta.traceparent`; strip it so
# both server tiers see the no-carrier path. # the span sees the no-carrier path.
async def wrapped(dctx: Any, method: str, params: dict[str, Any] | None) -> Any: async def wrapped(dctx: Any, method: str, params: dict[str, Any] | None) -> Any:
stripped = {k: v for k, v in (params or {}).items() if k != "_meta"} stripped = {k: v for k, v in (params or {}).items() if k != "_meta"}
return await call_next(dctx, method, stripped or None) return await call_next(dctx, method, stripped or None)
return wrapped return wrapped
server.middleware.append(OpenTelemetryMiddleware()) async with connected_runner(server, dispatch_middleware=[_ambient_span, strip_meta]) as (client, _):
async with connected_runner(server, dispatch_middleware=[strip_meta, otel_middleware]) as (client, _):
spans.clear() spans.clear()
await client.send_raw_request("tools/list", None) await client.send_raw_request("tools/list", None)
server_spans = [s for s in spans.finished() if s.kind == SpanKind.SERVER] server_spans = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
@ -207,8 +220,7 @@ async def test_nests_under_ambient_span_when_meta_lacks_traceparent(server: SrvT
return wrapped return wrapped
server.middleware.append(OpenTelemetryMiddleware()) async with connected_runner(server, dispatch_middleware=[_ambient_span, replace_meta]) as (client, _):
async with connected_runner(server, dispatch_middleware=[replace_meta, otel_middleware]) as (client, _):
spans.clear() spans.clear()
await client.send_raw_request("tools/list", None) await client.send_raw_request("tools/list", None)
server_spans = [s for s in spans.finished() if s.kind == SpanKind.SERVER] server_spans = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
@ -225,7 +237,6 @@ async def test_nests_under_ambient_span_when_meta_lacks_traceparent(server: SrvT
async def test_extracts_trace_context_from_meta(server: SrvT, spans: SpanCapture): async def test_extracts_trace_context_from_meta(server: SrvT, spans: SpanCapture):
meta: dict[str, Any] = {} meta: dict[str, Any] = {}
inject_trace_context(meta) inject_trace_context(meta)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
spans.clear() spans.clear()
await client.send_raw_request("tools/list", {"_meta": meta}) await client.send_raw_request("tools/list", {"_meta": meta})
@ -235,7 +246,6 @@ async def test_extracts_trace_context_from_meta(server: SrvT, spans: SpanCapture
@pytest.mark.anyio @pytest.mark.anyio
async def test_records_error_status_on_mcp_error(server: SrvT, spans: SpanCapture): async def test_records_error_status_on_mcp_error(server: SrvT, spans: SpanCapture):
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
spans.clear() spans.clear()
with pytest.raises(MCPError) as exc: with pytest.raises(MCPError) as exc:
@ -253,7 +263,6 @@ async def test_records_error_status_on_mcp_error(server: SrvT, spans: SpanCaptur
@pytest.mark.anyio @pytest.mark.anyio
async def test_validation_failure_sets_sanitized_status(server: SrvT, spans: SpanCapture): async def test_validation_failure_sets_sanitized_status(server: SrvT, spans: SpanCapture):
server.add_request_handler("tools/call", CallToolRequestParams, _ok_tool) server.add_request_handler("tools/call", CallToolRequestParams, _ok_tool)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
spans.clear() spans.clear()
with pytest.raises(MCPError): with pytest.raises(MCPError):
@ -275,7 +284,6 @@ async def test_records_error_status_on_handler_exception(server: SrvT, spans: Sp
raise ValueError("handler blew up") raise ValueError("handler blew up")
server.add_request_handler("tools/list", PaginatedRequestParams, failing) server.add_request_handler("tools/list", PaginatedRequestParams, failing)
server.middleware.append(OpenTelemetryMiddleware())
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
spans.clear() spans.clear()
with pytest.raises(MCPError): with pytest.raises(MCPError):
@ -290,6 +298,28 @@ async def test_records_error_status_on_handler_exception(server: SrvT, spans: Sp
assert event.attributes["exception.type"] == "ValueError" assert event.attributes["exception.type"] == "ValueError"
@pytest.mark.anyio
async def test_records_error_status_on_malformed_spec_result(server: SrvT, spans: SpanCapture):
"""Result serialization runs inside the span, so a handler returning a
malformed dict for a spec method (INTERNAL_ERROR on the wire) is recorded
on the span rather than closing it as a success."""
async def bad_result(ctx: Ctx, params: PaginatedRequestParams | None) -> dict[str, Any]:
return {"tools": 42}
server.add_request_handler("tools/list", PaginatedRequestParams, bad_result)
async with connected_runner(server) as (client, _):
spans.clear()
with pytest.raises(MCPError) as exc:
await client.send_raw_request("tools/list", None)
assert exc.value.error.code == INTERNAL_ERROR
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.status.status_code == StatusCode.ERROR
assert span.attributes is not None
assert span.attributes["error.type"] == str(INTERNAL_ERROR)
assert span.attributes["rpc.response.status_code"] == str(INTERNAL_ERROR)
@pytest.mark.anyio @pytest.mark.anyio
async def test_passes_rewritten_context_through(server: SrvT, spans: SpanCapture): async def test_passes_rewritten_context_through(server: SrvT, spans: SpanCapture):
seen_arguments: dict[str, Any] = {} seen_arguments: dict[str, Any] = {}
@ -304,7 +334,7 @@ async def test_passes_rewritten_context_through(server: SrvT, spans: SpanCapture
return await call_next(replace(ctx, params={**ctx.params, "arguments": arguments})) return await call_next(replace(ctx, params={**ctx.params, "arguments": arguments}))
server.add_request_handler("tools/call", CallToolRequestParams, call_tool) server.add_request_handler("tools/call", CallToolRequestParams, call_tool)
server.middleware.extend([OpenTelemetryMiddleware(), inject_arg]) server.middleware.append(inject_arg)
async with connected_runner(server) as (client, _): async with connected_runner(server) as (client, _):
spans.clear() spans.clear()
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {"x": 1}}) await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {"x": 1}})

View file

@ -21,7 +21,6 @@ from mcp_types import (
INVALID_PARAMS, INVALID_PARAMS,
LATEST_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND, METHOD_NOT_FOUND,
CallToolRequestParams,
ClientCapabilities, ClientCapabilities,
ErrorData, ErrorData,
Implementation, Implementation,
@ -35,7 +34,6 @@ from mcp_types import (
Tool, Tool,
) )
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, OLDEST_SUPPORTED_VERSION from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, OLDEST_SUPPORTED_VERSION
from opentelemetry.trace import SpanKind, StatusCode
import mcp.server.runner import mcp.server.runner
from mcp.server.connection import Connection from mcp.server.connection import Connection
@ -46,7 +44,6 @@ from mcp.server.runner import (
ServerRunner, ServerRunner,
_extract_meta, _extract_meta,
aclose_shielded, aclose_shielded,
otel_middleware,
serve_connection, serve_connection,
serve_one, serve_one,
) )
@ -60,7 +57,6 @@ from mcp.shared.transport_context import TransportContext
from ..shared.conftest import jsonrpc_pair from ..shared.conftest import jsonrpc_pair
from ..shared.test_dispatcher import Recorder, echo_handlers from ..shared.test_dispatcher import Recorder, echo_handlers
from .conftest import SpanCapture
Ctx = ServerRequestContext[dict[str, Any], Any] Ctx = ServerRequestContext[dict[str, Any], Any]
@ -1005,108 +1001,6 @@ async def test_runner_initialize_echoes_supported_version_and_falls_back_to_late
assert result["protocolVersion"] == LATEST_HANDSHAKE_VERSION assert result["protocolVersion"] == LATEST_HANDSHAKE_VERSION
@pytest.mark.anyio
async def test_otel_middleware_emits_server_span_with_method_and_target(server: SrvT, spans: SpanCapture):
async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
return {"content": [], "isError": False}
server.add_request_handler("tools/call", CallToolRequestParams, call_tool)
async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _):
spans.clear()
result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
assert result == {"content": [], "isError": False}
finished = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
[span] = finished
assert span.name == "MCP handle tools/call mytool"
assert span.attributes is not None
assert span.attributes["mcp.method.name"] == "tools/call"
assert isinstance(span.attributes["jsonrpc.request.id"], str)
assert span.status.status_code == StatusCode.UNSET
@pytest.mark.anyio
async def test_otel_trace_context_propagates_client_to_server(server: SrvT, spans: SpanCapture):
"""The client dispatcher injects traceparent into `_meta`; the server's
`otel_middleware` extracts it, so client and server spans share a trace."""
async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _):
spans.clear()
await client.send_raw_request("tools/list", None)
[client_span] = [s for s in spans.finished() if s.kind == SpanKind.CLIENT]
[server_span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert server_span.parent is not None
assert client_span.context is not None and server_span.context is not None
assert server_span.parent.span_id == client_span.context.span_id
assert server_span.context.trace_id == client_span.context.trace_id
assert client_span.attributes is not None and server_span.attributes is not None
assert client_span.attributes["jsonrpc.request.id"] == server_span.attributes["jsonrpc.request.id"]
@pytest.mark.anyio
async def test_otel_middleware_malformed_traceparent_degrades_to_no_parent(server: SrvT, spans: SpanCapture):
"""A non-string traceparent in `_meta` must not fail the request; the server span simply gets no parent."""
def break_traceparent(call_next: OnRequest) -> OnRequest:
async def wrapped(ctx: DispatchContext[Any], method: str, params: Any) -> dict[str, Any]:
mangled = {"_meta": {"traceparent": 123}} if method == "tools/list" else params
return await call_next(ctx, method, mangled)
return wrapped
async with connected_runner(server, dispatch_middleware=[break_traceparent, otel_middleware]) as (client, _):
spans.clear()
await client.send_raw_request("tools/list", None)
[server_span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert server_span.parent is None
@pytest.mark.anyio
async def test_otel_middleware_validation_failure_sets_sanitized_status(server: SrvT, spans: SpanCapture):
"""Malformed params set the sanitized wire message as span status and do
not record the pydantic exception (it carries client input)."""
async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _):
spans.clear()
with pytest.raises(MCPError) as exc:
await client.send_raw_request("tools/call", {"name": 123})
assert exc.value.error.code == INVALID_PARAMS
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.status.status_code == StatusCode.ERROR
assert span.status.description == "Invalid request parameters"
assert not span.events
@pytest.mark.anyio
async def test_otel_middleware_records_error_status_on_mcp_error(server: SrvT, spans: SpanCapture):
async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _):
spans.clear()
with pytest.raises(MCPError) as exc:
await client.send_raw_request("resources/list", None)
assert exc.value.error.code == METHOD_NOT_FOUND
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.status.status_code == StatusCode.ERROR
assert span.status.description == "Method not found"
# MCPError is a protocol-level response, not a crash - no traceback event.
assert not [e for e in span.events if e.name == "exception"]
@pytest.mark.anyio
async def test_otel_middleware_records_error_status_on_handler_exception(server: SrvT, spans: SpanCapture):
async def failing(ctx: Ctx, params: PaginatedRequestParams | None) -> Any:
raise ValueError("handler blew up")
server.add_request_handler("tools/list", PaginatedRequestParams, failing)
async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _):
spans.clear()
with pytest.raises(MCPError) as exc:
await client.send_raw_request("tools/list", None)
assert exc.value.error.code == 0
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.status.status_code == StatusCode.ERROR
assert span.status.description == "handler blew up"
[event] = [e for e in span.events if e.name == "exception"]
assert event.attributes is not None
assert event.attributes["exception.type"] == "ValueError"
@pytest.mark.anyio @pytest.mark.anyio
async def test_runner_connection_exit_stack_unwinds_after_run_returns(server: SrvT) -> None: async def test_runner_connection_exit_stack_unwinds_after_run_returns(server: SrvT) -> None:
"""`runner.connection.exit_stack` is closed when the dispatcher loop ends.""" """`runner.connection.exit_stack` is closed when the dispatcher loop ends."""

View file

@ -6,10 +6,16 @@ from logfire.testing import CaptureLogfire
from mcp.client.client import Client from mcp.client.client import Client
from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver import MCPServer
from mcp.shared._otel import extract_trace_context
pytestmark = pytest.mark.anyio pytestmark = pytest.mark.anyio
def test_extract_trace_context_degrades_to_no_parent_on_malformed_traceparent() -> None:
"""A non-string `traceparent` makes `extract()` raise; the helper must return `None`, not propagate."""
assert extract_trace_context({"traceparent": 123}) is None
async def test_client_and_server_spans(capfire: CaptureLogfire): async def test_client_and_server_spans(capfire: CaptureLogfire):
"""Verify that calling a tool produces client and server spans with correct attributes.""" """Verify that calling a tool produces client and server spans with correct attributes."""
server = MCPServer("test") server = MCPServer("test")
@ -29,10 +35,10 @@ async def test_client_and_server_spans(capfire: CaptureLogfire):
span_names = {s["name"] for s in spans} span_names = {s["name"] for s in spans}
assert "MCP send tools/call greet" in span_names assert "MCP send tools/call greet" in span_names
assert "MCP handle tools/call greet" in span_names assert "tools/call greet" in span_names
client_span = next(s for s in spans if s["name"] == "MCP send tools/call greet") client_span = next(s for s in spans if s["name"] == "MCP send tools/call greet")
server_span = next(s for s in spans if s["name"] == "MCP handle tools/call greet") server_span = next(s for s in spans if s["name"] == "tools/call greet")
assert client_span["attributes"]["mcp.method.name"] == "tools/call" assert client_span["attributes"]["mcp.method.name"] == "tools/call"
assert server_span["attributes"]["mcp.method.name"] == "tools/call" assert server_span["attributes"]["mcp.method.name"] == "tools/call"