Files
Max Isbey 98bd125ddd Serve the 2026-07-28 protocol over stdio by deciding the era from the opening message
The 2026-07-28 protocol did not work over stdio: subscriptions/listen
was hard-refused, a legacy initialize arriving during an in-flight
modern request was accepted and re-locked the connection, and a peer
cancel produced a trailing "Request cancelled" frame. All three share
one root cause: the connection's era was derived from which requests
had completed instead of being decided once, in wire order, from how
the client opened the connection.

Replace serve_dual_era_loop and serve_loop with a single serve_stream
driver that decides the era synchronously in the dispatcher's read
loop, before the request body is spawned. initialize (or any
envelope-less request) opens the legacy era, an enveloped request opens
the modern era, server/discover is answered without pinning, and a
stray leading notification opens nothing. A conflicting era claim on a
committed connection is refused (-32022 or -32600) rather than
silently switching.

Cancel silence is structural: each request answers through a one-shot
channel whose write target becomes a powerless void on a peer cancel,
so there is no cancelled-check at any write site. The generic JSON-RPC
dispatcher loses the code-0 cancel frame, the code-0 str(exc)
catch-all, and the inline_methods knob, and documents that handlers
are invoked synchronously in receive order with the returned awaitable
as the body.

Add a Posture enum (DUAL default, LEGACY_ONLY, MODERN_ONLY) on the
Server and MCPServer constructors, honoured by the stream driver and
the streamable-HTTP manager alike. Server.run(read, write) now stands
alone, Server.lifespan() is a bound context manager, and
serve_listener / newline_json_transport / close_subscriptions() give a
straightforward path for custom transports. See docs/migration.md for
the full list of observable changes.
2026-07-23 13:18:49 +00:00

4.6 KiB

OpenTelemetry

Your server is already traced. You don't have to add anything.

Every server you create emits an OpenTelemetry 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(...).

--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:

  • 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:

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 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 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, 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:

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 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.

The thing that decides whether a request runs at all is Authorization.