Tracing MCP calls with OpenTelemetry The 2026-07-28 Model Context Protocol specification revision, via Specification Enhancement Proposal 414 (SEP-414), reserves the unprefixed `_meta` keys `traceparent`, `tracestate` and `baggage` for W3C-format OpenTelemetry trace context, letting one trace span a host agent turn, an LLM, an MCP server and its backend calls. The official Python SDK, `mcp` 2.3.0, emits one server span per request named like `tools/call get_sales` as a child of the context in `_meta`, but recorded no metrics in the author's runs. The author advises treating incoming context, especially baggage, as untrusted, and notes MCP's Logging feature is deprecated in favor of OpenTelemetry. Tracing MCP calls with OpenTelemetry 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: "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: 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: 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.