Make OpenTelemetry tracing the single default middleware (#2995)

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

View file

@ -79,33 +79,12 @@ In increasing order of how much you should hesitate:
an elicitation) while handling `initialize` therefore **deadlocks the connection**: the
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

View file

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