Make OpenTelemetry tracing the single default middleware (#2995)
This commit is contained in:
parent
cc596195bb
commit
b31d95a429
12 changed files with 260 additions and 238 deletions
|
|
@ -79,33 +79,12 @@ In increasing order of how much you should hesitate:
|
|||
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.
|
||||
|
||||
## `OpenTelemetryMiddleware`
|
||||
## The one middleware that ships on by default
|
||||
|
||||
The SDK ships one middleware: `OpenTelemetryMiddleware`. Construct it and append it
|
||||
(`server.middleware.append(OpenTelemetryMiddleware())`), exactly the line you already wrote
|
||||
for `log_timing`.
|
||||
|
||||
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.
|
||||
The SDK ships exactly one middleware, and it is already on your server's list: the one that
|
||||
emits an OpenTelemetry span for every message. You don't append it, and most of the time you
|
||||
don't think about it. It is a no-op until you install an exporter, and it has its own page:
|
||||
**OpenTelemetry**.
|
||||
|
||||
!!! info
|
||||
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.
|
||||
* `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.
|
||||
* `OpenTelemetryMiddleware` turns each message into a span (with GenAI attributes on tool
|
||||
calls and prompt gets) for the price of one `append`, and costs nothing until you install
|
||||
an exporter.
|
||||
* The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See
|
||||
**OpenTelemetry**.
|
||||
* 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
|
||||
|
|
|
|||
107
docs/advanced/opentelemetry.md
Normal file
107
docs/advanced/opentelemetry.md
Normal 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**.
|
||||
|
|
@ -64,8 +64,8 @@ went to standard error: the terminal, not the wire.
|
|||
|
||||
!!! info
|
||||
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.
|
||||
See **Middleware**.
|
||||
don't want log lines, you want spans. Your server already emits them: the SDK traces every
|
||||
message with OpenTelemetry out of the box. See **OpenTelemetry**.
|
||||
|
||||
## Recap
|
||||
|
||||
|
|
|
|||
0
docs_src/opentelemetry/__init__.py
Normal file
0
docs_src/opentelemetry/__init__.py
Normal file
9
docs_src/opentelemetry/tutorial001.py
Normal file
9
docs_src/opentelemetry/tutorial001.py
Normal 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}."
|
||||
|
|
@ -42,6 +42,7 @@ nav:
|
|||
- The low-level Server: advanced/low-level-server.md
|
||||
- Pagination: advanced/pagination.md
|
||||
- Middleware: advanced/middleware.md
|
||||
- OpenTelemetry: advanced/opentelemetry.md
|
||||
- Authorization: advanced/authorization.md
|
||||
- OAuth clients: advanced/oauth-clients.md
|
||||
- Session groups: advanced/session-groups.md
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ from starlette.middleware.authentication import AuthenticationMiddleware
|
|||
from starlette.routing import Mount, Route
|
||||
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.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
|
||||
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
|
||||
|
|
@ -231,10 +232,13 @@ class Server(Generic[LifespanResultT]):
|
|||
# Context-tier middleware: wraps every inbound request (including
|
||||
# `initialize`, lookup, validation, handler) with
|
||||
# `(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
|
||||
# Context/middleware rework (covariant `Context[L]`, outbound seam) before
|
||||
# v2 final.
|
||||
self.middleware: list[ServerMiddleware[LifespanResultT]] = []
|
||||
self.middleware: list[ServerMiddleware[LifespanResultT]] = [OpenTelemetryMiddleware()]
|
||||
logger.debug("Initializing server %r", name)
|
||||
|
||||
_spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ from mcp_types import (
|
|||
)
|
||||
from mcp_types import methods as _methods
|
||||
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 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.models import InitializationOptions
|
||||
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.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnNotify, OnRequest
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
|
@ -62,7 +60,6 @@ __all__ = [
|
|||
"ServerRunner",
|
||||
"aclose_shielded",
|
||||
"modern_on_request",
|
||||
"otel_middleware",
|
||||
"serve_connection",
|
||||
"serve_loop",
|
||||
"serve_one",
|
||||
|
|
@ -91,58 +88,6 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | 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]:
|
||||
if result is None:
|
||||
return {}
|
||||
|
|
@ -196,7 +141,10 @@ class ServerRunner(Generic[LifespanT]):
|
|||
_: KW_ONLY
|
||||
init_options: InitializationOptions | None = None
|
||||
"""`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
|
||||
def on_request(self) -> OnRequest:
|
||||
|
|
@ -223,7 +171,6 @@ class ServerRunner(Generic[LifespanT]):
|
|||
meta = _extract_meta(params)
|
||||
version = self.connection.protocol_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:
|
||||
# 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
|
||||
# (read loop parked), so awaiting the peer anywhere on this path deadlocks.
|
||||
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
|
||||
# initialization state: JSON-RPC 2.0 reserves -32601 for "not
|
||||
# available on this server", and clients probing a server before
|
||||
|
|
@ -261,25 +208,14 @@ class ServerRunner(Generic[LifespanT]):
|
|||
if isinstance(result, ErrorData):
|
||||
# Raise inside the chain so middleware observes the failure.
|
||||
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)
|
||||
# `_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))
|
||||
# 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":
|
||||
# Commit only on chain success, so a middleware veto leaves no state.
|
||||
# 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,
|
||||
)
|
||||
|
||||
@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
|
||||
def _negotiate_initialize(params: Mapping[str, Any] | None) -> tuple[InitializeRequestParams, str]:
|
||||
"""Validate `initialize` params and pick the protocol version."""
|
||||
|
|
|
|||
35
tests/docs_src/test_opentelemetry.py
Normal file
35
tests/docs_src/test_opentelemetry.py
Normal 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"]
|
||||
|
|
@ -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 typing import Any
|
||||
|
|
@ -6,6 +11,7 @@ from typing import Any
|
|||
import anyio
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
|
|
@ -21,8 +27,7 @@ from opentelemetry.trace import SpanKind, StatusCode
|
|||
from mcp.server._otel import OpenTelemetryMiddleware
|
||||
from mcp.server.context import CallNext
|
||||
from mcp.server.lowlevel.server import Server
|
||||
from mcp.server.runner import otel_middleware
|
||||
from mcp.shared._otel import inject_trace_context
|
||||
from mcp.shared._otel import inject_trace_context, otel_span
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
||||
from .conftest import SpanCapture
|
||||
|
|
@ -41,10 +46,14 @@ async def _ok_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
|
|||
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
|
||||
async def test_emits_server_span_with_method_and_target(server: SrvT, spans: SpanCapture):
|
||||
server.add_request_handler("tools/call", CallToolRequestParams, _ok_tool)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
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}
|
||||
|
||||
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
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)
|
||||
|
||||
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
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}
|
||||
|
||||
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
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}
|
||||
|
||||
server.add_request_handler("custom/op", CallToolRequestParams, custom)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
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=[])
|
||||
|
||||
server.add_request_handler("prompts/get", GetPromptRequestParams, get_prompt)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
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
|
||||
|
||||
server.add_notification_handler("notifications/roots/list_changed", NotificationParams, on_roots)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
context-tier span must parent to the ambient current span (here, the
|
||||
dispatch-tier `otel_middleware` span) rather than become an orphan root.
|
||||
"""With no `_meta` on the inbound message (a non-SDK client), the span must
|
||||
parent to the ambient current span rather than become an orphan root.
|
||||
SDK-defined: SEP-414 only covers the traceparent-present case."""
|
||||
|
||||
def strip_meta(call_next: Any) -> Any:
|
||||
# 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:
|
||||
stripped = {k: v for k, v in (params or {}).items() if k != "_meta"}
|
||||
return await call_next(dctx, method, stripped or None)
|
||||
|
||||
return wrapped
|
||||
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server, dispatch_middleware=[strip_meta, otel_middleware]) as (client, _):
|
||||
async with connected_runner(server, dispatch_middleware=[_ambient_span, strip_meta]) as (client, _):
|
||||
spans.clear()
|
||||
await client.send_raw_request("tools/list", None)
|
||||
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
|
||||
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server, dispatch_middleware=[replace_meta, otel_middleware]) as (client, _):
|
||||
async with connected_runner(server, dispatch_middleware=[_ambient_span, replace_meta]) as (client, _):
|
||||
spans.clear()
|
||||
await client.send_raw_request("tools/list", None)
|
||||
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):
|
||||
meta: dict[str, Any] = {}
|
||||
inject_trace_context(meta)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
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
|
||||
async def test_records_error_status_on_mcp_error(server: SrvT, spans: SpanCapture):
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
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
|
||||
async def test_validation_failure_sets_sanitized_status(server: SrvT, spans: SpanCapture):
|
||||
server.add_request_handler("tools/call", CallToolRequestParams, _ok_tool)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
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")
|
||||
|
||||
server.add_request_handler("tools/list", PaginatedRequestParams, failing)
|
||||
server.middleware.append(OpenTelemetryMiddleware())
|
||||
async with connected_runner(server) as (client, _):
|
||||
spans.clear()
|
||||
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"
|
||||
|
||||
|
||||
@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
|
||||
async def test_passes_rewritten_context_through(server: SrvT, spans: SpanCapture):
|
||||
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}))
|
||||
|
||||
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, _):
|
||||
spans.clear()
|
||||
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {"x": 1}})
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ from mcp_types import (
|
|||
INVALID_PARAMS,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
METHOD_NOT_FOUND,
|
||||
CallToolRequestParams,
|
||||
ClientCapabilities,
|
||||
ErrorData,
|
||||
Implementation,
|
||||
|
|
@ -35,7 +34,6 @@ from mcp_types import (
|
|||
Tool,
|
||||
)
|
||||
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, OLDEST_SUPPORTED_VERSION
|
||||
from opentelemetry.trace import SpanKind, StatusCode
|
||||
|
||||
import mcp.server.runner
|
||||
from mcp.server.connection import Connection
|
||||
|
|
@ -46,7 +44,6 @@ from mcp.server.runner import (
|
|||
ServerRunner,
|
||||
_extract_meta,
|
||||
aclose_shielded,
|
||||
otel_middleware,
|
||||
serve_connection,
|
||||
serve_one,
|
||||
)
|
||||
|
|
@ -60,7 +57,6 @@ from mcp.shared.transport_context import TransportContext
|
|||
|
||||
from ..shared.conftest import jsonrpc_pair
|
||||
from ..shared.test_dispatcher import Recorder, echo_handlers
|
||||
from .conftest import SpanCapture
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
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."""
|
||||
|
|
|
|||
|
|
@ -6,10 +6,16 @@ from logfire.testing import CaptureLogfire
|
|||
|
||||
from mcp.client.client import Client
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.shared._otel import extract_trace_context
|
||||
|
||||
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):
|
||||
"""Verify that calling a tool produces client and server spans with correct attributes."""
|
||||
server = MCPServer("test")
|
||||
|
|
@ -29,10 +35,10 @@ async def test_client_and_server_spans(capfire: CaptureLogfire):
|
|||
span_names = {s["name"] for s in spans}
|
||||
|
||||
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")
|
||||
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 server_span["attributes"]["mcp.method.name"] == "tools/call"
|
||||
|
|
|
|||
Loading…
Reference in a new issue