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
|
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
|
||||||
|
|
|
||||||
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
|
!!! 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
|
||||||
|
|
||||||
|
|
|
||||||
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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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]] = [
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
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 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}})
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue