# Tracing MCP calls with OpenTelemetry

October 23, 2026 · Part 20 of 35

![Tracing MCP calls with OpenTelemetry](https://pournasserian.com/_astro/lead.Cr8Vqo3b_ZbMD1u.webp)

One turn of an agent can go from a host, to a large language model (LLM), to a Model Context Protocol (MCP) server, to three backend systems and back. When that turn is slow or wrong, debugging it without a distributed trace is guesswork. I like that the 2026-07-28 revision settles how trace context crosses the MCP boundary.

This is part 20 of my series on what an MCP server does under the 2026-07-28 specification, after [authorization in practice in part 19](https://pournasserian.com/writing/mcp-2026-oauth-step-up-and-entra). The facts are as I read them in October 2026.

## In brief

1. **Trace context travels in `_meta`** under the keys `traceparent`, `tracestate` and `baggage`, in the World Wide Web Consortium (W3C) formats, and those keys carry no prefix on purpose.
2. **When the client sends it, your server extracts that context, propagates it and handles every request on its own,** with OpenTelemetry in place of MCP logging.
3. **Each request gets one server span, named like `tools/call get_sales`;** the conventions for its attributes are still evolving.
4. **The official Python software development kit (SDK), `mcp` 2.3.0, emits that span for you** and makes it a child of the context in `_meta`, but in my runs it recorded no metrics.
5. **My advice: treat incoming context as untrusted,** baggage above all.

## Where trace context travels

[Specification Enhancement Proposal 414 (SEP-414)](https://modelcontextprotocol.io/seps/414-request-meta) reserves three keys for OpenTelemetry trace context in `_meta`, the per-request envelope from [part 5](https://pournasserian.com/writing/mcp-2026-stateless-requests): `traceparent`, `tracestate` and `baggage`. When they are present, the [specification's overview](https://modelcontextprotocol.io/specification/2026-07-28/basic/index) says their values MUST follow the W3C Trace Context and W3C Baggage formats:

```json
"params": {
  "name": "get_sales",
  "arguments": {},
  "_meta": {
    "traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01",
    "tracestate": "vendor=value",
    "baggage": "tenant=contoso"
  }
}
```

These keys are an intentional exception to the rule that other `_meta` keys carry a prefix, such as `io.modelcontextprotocol/`. They stay unprefixed for compatibility with existing implementations and OpenTelemetry's semantic conventions. The SEP warns that namespacing them, as `io.modelcontextprotocol.traceparent` for example, would break traces and log correlation.

## One trace, four jobs for your server

With that context passed on, one trace can run from the host's agent turn to your server's database and customer relationship management (CRM) calls. Host and server send their spans to an OpenTelemetry (OTel) collector, which hands them to your application performance monitoring (APM) backend:

```mermaid
flowchart TB
    subgraph host["Host"]
        direction TB
        turn["Span: agent turn"]
        llm["Span: LLM call"]
        mcpc["Span: MCP client tools/call"]
        turn --> llm
        turn --> mcpc
    end
    subgraph server["MCP server"]
        direction TB
        srv["Span: tools/call get_sales<br/>child of the client span"]
        db["Span: SQL query"]
        api["Span: HTTP to CRM API"]
        srv --> db
        srv --> api
    end
    collector[["OTel collector"]]
    apm[("APM backend<br/>App Insights, Jaeger, Datadog, ...")]

    mcpc -->|_meta.traceparent| srv
    host -.-> collector
    server -.-> collector
    collector --> apm
```

When the client sends trace context, your server has four jobs in that trace. Continuing it is a convention of SEP-414, not a requirement:

1. **Extract** `traceparent`, `tracestate` and `baggage` from each request's `_meta`, and start your server span as a child of that context. This matters even more on stdio, which has no HTTP headers to carry it ([part 7](https://pournasserian.com/writing/mcp-2026-transports)).
2. **Propagate** the context to everything downstream, such as HTTP calls, database calls and queue messages, so the trace runs from the agent to your data.
3. **Handle every request independently.** There's no session to hang context on, so each request gets its own server span under whatever parent the client sent. That includes each retry of a Multi Round-Trip Request (MRTR), which is a request of its own ([part 13](https://pournasserian.com/writing/mcp-2026-multi-round-trip-requests)), and each `tasks/get` poll. Good clients reuse the same trace ID across MRTR rounds, so the rounds line up.
4. **Use OpenTelemetry instead of MCP logging.** The Logging feature is [deprecated](https://modelcontextprotocol.io/specification/2026-07-28/deprecated), and the suggested replacements are stderr on stdio, or OpenTelemetry. Until it's removed, the 2026-07-28 rule applies: send `notifications/message` only for requests that set `io.modelcontextprotocol/logLevel` in `_meta`.

### Treat incoming context as untrusted

The specification only reserves these keys and their formats. My advice goes further: `baggage` comes from the client, so never use it for authorization decisions, never let it select tenants or data partitions, and don't forward personal data or secrets in baggage to downstream systems. At trust boundaries, such as calls into a backend on a different trust tier, sanitize or drop incoming context and start a new linked trace instead.

## Spans and attributes, in Python

OpenTelemetry's semantic conventions for MCP are what made `_meta` the standard carrier in the first place. In practice they ask for:

- one server span per request, named for the method and its target, such as `tools/call get_sales`;
- attributes for the MCP method; the tool, prompt or resource name; the JSON-RPC 2.0 request ID; the protocol version; the client's name and version; and `resultType`;
- client and server duration metrics, plus error counts split between protocol errors and `isError` tool errors.

The generative AI (GenAI) and MCP conventions are still evolving, so check the current semantic-conventions version for the exact attribute names.

My [review of MCP testing tools](https://pournasserian.com/writing/mcp-server-testing-tools-2026) noted that "the official Python SDK now traces automatically". In `mcp` 2.3.0, an [OpenTelemetry middleware](https://py.sdk.modelcontextprotocol.io/run/opentelemetry/) is on by default. It emits one span of kind `SERVER` per request, from the tracer `mcp-python-sdk`, as a child of the context in `_meta`. The SDK depends only on the OpenTelemetry API, so the spans go nowhere until you install `opentelemetry-sdk` and set a tracer provider. This sample sets one with an in-memory exporter and calls `get_sales` through the in-memory `Client`:

```python
import anyio
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

from mcp import Client
from mcp.server import MCPServer

exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)

mcp = MCPServer("sales")

@mcp.tool()
def get_sales(region: str) -> str:
    """Total sales for one region."""
    return f"Sales report for {region}"

async def main():
    meta = {"traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"}
    async with Client(mcp) as client:
        await client.call_tool("get_sales", {"region": "EU"}, meta=meta)
    span = next(s for s in exporter.get_finished_spans() if s.name.startswith("tools/call"))
    print(span.name, span.kind.name)
    print("trace", format(span.context.trace_id, "032x"), "parent", format(span.parent.span_id, "016x"))
    for key in ("mcp.method.name", "gen_ai.tool.name", "mcp.protocol.version", "jsonrpc.request.id"):
        print(f"  {key} = {span.attributes[key]}")

if __name__ == "__main__":
    anyio.run(main)
```

It printed:

```text
tools/call get_sales SERVER
trace 0af7651916cd43dd8448eb211c80319c parent 00f067aa0ba902b7
  mcp.method.name = tools/call
  gen_ai.tool.name = get_sales
  mcp.protocol.version = 2026-07-28
  jsonrpc.request.id = 2
```

On an `isError` result, the span also carries `error.type` set to `tool_error`. The trace ID and the parent come from the `traceparent` I sent. The in-memory `Client` adds no trace context of its own, so the sample sends one as a host would. Over stdio, the SDK's client starts a client span and writes `traceparent` itself; in my run, the server span became that span's child.

Two things I saw about `baggage`. On the server, `baggage` from `_meta` didn't become the current OpenTelemetry baggage inside the tool; the tool can still read it from `ctx.request_context.meta`. On the client, over stdio, the SDK copied the caller's current baggage into `_meta`, so it travels to the server.

The SDK records no metrics: with a meter provider set, my runs produced spans only. The signals under What to measure, below, need a meter of your own, or metrics derived from the spans.

### Exporting to Azure Monitor

The server code stays the same in production, since the SDK uses the global tracer provider. The `azure-monitor-opentelemetry` package sets that provider up for Azure Monitor in one call:

```python
from azure.monitor.opentelemetry import configure_azure_monitor

from mcp.server import MCPServer

mcp = MCPServer("sales")

@mcp.tool()
def get_sales(region: str) -> str:
    """Total sales for one region."""
    return f"Sales report for {region}"

configure_azure_monitor()  # reads APPLICATIONINSIGHTS_CONNECTION_STRING from the environment
mcp.run()
```

It needs an Application Insights connection string in that variable; without one, it stopped with `ValueError: Instrumentation key cannot be none or empty.`

## What to measure

| Signal | Why |
|---|---|
| Latency per method and tool (p50, p95, p99) | Find slow tools before hosts' timeouts do |
| `isError` rate per tool | Execution errors are your quality signal: the model is calling tools wrongly or backends are failing |
| Protocol error rate by code | `-32602` spikes often mean a schema change broke clients |
| `input_required` rate per tool | High rates suggest the tool's description or schema isn't asking for what it needs |
| Retry-to-completion ratio for MRTR | How often users abandon confirmations |
| Cancellations and client disconnects | Streams cut by intermediaries show up here first |
| Output size (tokens or characters) per tool | Stay under host limits: as of October 2026, Claude Code warns at 10,000 tokens ([part 9](https://pournasserian.com/writing/mcp-2026-tool-errors-and-design)) |
| Calls by `clientInfo` | Which hosts use you, and where host-specific bugs live |
| Task durations and terminal states | Health of the Tasks extension |

The cancellations row catches cut streams because, on Streamable HTTP, a closed response stream is the cancellation ([part 16](https://pournasserian.com/writing/mcp-2026-progress-and-cancellation)).

## Method and caveats

- Built from my guide's chapter on observability, written against the 2026-07-28 specification; Claude Code's output warning is as of October 2026.
- Where the specification is more precise than my notes (trace context in `_meta` is a convention, not a requirement), this article follows SEP-414 and the specification's overview.
- Every Python sample was run against `mcp` 2.3.0, `opentelemetry-sdk` 1.45.1 and `azure-monitor-opentelemetry` 1.8.11 on 9 October 2026; the stdio, metrics and baggage notes come from test runs that day.
- The Azure Monitor sample ran only without a connection string, never against Azure.

I'm [Amir Pournasserian](https://pournasserian.com/about). I build AI and platform systems for a living, maintain [FluentCMS](https://pournasserian.com/open-source/fluentcms), [YeSvelte](https://pournasserian.com/open-source/yesvelte) and [mcp-my-site](https://pournasserian.com/open-source/mcp-my-site), and write here about what I find along the way.
