|
| 1 | +# OpenTelemetry |
| 2 | + |
| 3 | +Your server is already traced. You don't have to add anything. |
| 4 | + |
| 5 | +Every server you create emits an [OpenTelemetry](https://opentelemetry.io/) span for every |
| 6 | +message it handles. You didn't write that, and you don't import it. It is there the moment you |
| 7 | +call `MCPServer(...)`. |
| 8 | + |
| 9 | +```python title="server.py" |
| 10 | +--8<-- "docs_src/opentelemetry/tutorial001.py" |
| 11 | +``` |
| 12 | + |
| 13 | +That is a complete, traced server. Call `search_books` and a span is created for it. The same is |
| 14 | +true for the low-level `Server`: the tracing lives on both. |
| 15 | + |
| 16 | +## What you get |
| 17 | + |
| 18 | +Every inbound message becomes a `SERVER` span named after the method and its target. So a |
| 19 | +`tools/call` for `search_books` is the span `tools/call search_books`, and a bare `tools/list` |
| 20 | +is just `tools/list`. |
| 21 | + |
| 22 | +Each span carries a few attributes: |
| 23 | + |
| 24 | +* `mcp.method.name` and `mcp.protocol.version`, on every span. |
| 25 | +* `jsonrpc.request.id`, on a request (a notification has none). |
| 26 | +* A handler that raises sets the span status to error. So does a tool result with `is_error=True`. |
| 27 | + |
| 28 | +And because tracing a tool call is such a common thing to want, `tools/call` spans speak |
| 29 | +OpenTelemetry's [GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/): |
| 30 | + |
| 31 | +* `gen_ai.operation.name`, set to `"execute_tool"`. |
| 32 | +* `gen_ai.tool.name`, set to the tool being called. |
| 33 | + |
| 34 | +A `prompts/get` span gets `gen_ai.prompt.name` in the same spirit. The list methods carry no |
| 35 | +`gen_ai.*` keys, because there is nothing to name. |
| 36 | + |
| 37 | +!!! tip |
| 38 | + Those GenAI attributes are the reason a tracing UI groups your tool calls the way it groups |
| 39 | + any other agent's. You get that grouping for free, with no extra code. |
| 40 | + |
| 41 | +## It costs nothing until you want it |
| 42 | + |
| 43 | +Here is the part that makes "on by default" a comfortable default. |
| 44 | + |
| 45 | +The SDK depends only on `opentelemetry-api`, the lightweight half of OpenTelemetry. With no SDK |
| 46 | +and no exporter installed, creating a span is a no-op. So the spans your server is emitting right |
| 47 | +now cost you almost nothing, and nobody is collecting them. |
| 48 | + |
| 49 | +The day you want to *see* them, you install the other half and point it somewhere: |
| 50 | + |
| 51 | +```console |
| 52 | +uv add opentelemetry-sdk opentelemetry-exporter-otlp |
| 53 | +``` |
| 54 | + |
| 55 | +Configure an exporter the usual OpenTelemetry way, and every span the SDK has been quietly |
| 56 | +creating lights up. Your server code does not change. Not one line. |
| 57 | + |
| 58 | +!!! info |
| 59 | + [Pydantic Logfire](https://logfire.pydantic.dev/) is one such backend, and it does the |
| 60 | + configuration for you: `pip install logfire`, `logfire.configure()`, and your MCP spans show |
| 61 | + up in the live view. It is built on OpenTelemetry, so anything below applies to it too. |
| 62 | + |
| 63 | +## Traces that cross the wire |
| 64 | + |
| 65 | +A trace is most useful when it follows a request from the client into the server, in one |
| 66 | +connected picture. |
| 67 | + |
| 68 | +When the client and the server both run the SDK, that connection is automatic. The client injects |
| 69 | +the [W3C trace context](https://www.w3.org/TR/trace-context/) into the request, and the server |
| 70 | +reads it back out, so the server span nests under the client span in the same trace. This is |
| 71 | +[SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol), and you get it without |
| 72 | +asking. |
| 73 | + |
| 74 | +If the inbound message carries no trace context, for example a request from a client that is not |
| 75 | +the SDK, the server span simply parents to whatever span is already current on the server, rather |
| 76 | +than starting a brand-new orphan trace. |
| 77 | + |
| 78 | +## Turning it off |
| 79 | + |
| 80 | +Tracing is a middleware, the first one on your server's list. If you really want a server that |
| 81 | +emits no spans, take it off: |
| 82 | + |
| 83 | +```python |
| 84 | +from mcp.server._otel import OpenTelemetryMiddleware |
| 85 | + |
| 86 | +mcp._lowlevel_server.middleware[:] = [ |
| 87 | + m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware) |
| 88 | +] |
| 89 | +``` |
| 90 | + |
| 91 | +!!! warning |
| 92 | + That import has a leading underscore, and that is on purpose. The class is provisional, the |
| 93 | + same way [`Server.middleware`](middleware.md) is provisional, so the import path is something |
| 94 | + you should expect to change. You almost never need this: with no exporter installed the spans |
| 95 | + are free, so the usual answer is to leave them on and not install an exporter. |
| 96 | + |
| 97 | +## Recap |
| 98 | + |
| 99 | +* Every `MCPServer` and every low-level `Server` emits one `SERVER` span per inbound message, out |
| 100 | + of the box. You write nothing. |
| 101 | +* Spans carry `mcp.method.name` and `mcp.protocol.version`; `tools/call` and `prompts/get` also |
| 102 | + carry GenAI attributes so your tool calls group like any other agent's. |
| 103 | +* It costs nothing until you install an OpenTelemetry SDK and an exporter, and then it lights up |
| 104 | + with no change to your server. |
| 105 | +* Client-to-server trace context propagates automatically when both sides run the SDK. |
| 106 | + |
| 107 | +Next, the thing that decides whether a request runs at all: **Authorization**. |
0 commit comments