cd /news/ai-agents/tracing-mcp-calls-with-opentelemetry · home › topics › ai-agents › article
[ARTICLE · art-156205] src=pournasserian.com ↗ pub= topic=ai-agents verified=true sentiment=· neutral

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.

by read8 min views1 publishedOct 23, 2026
Tracing MCP calls with OpenTelemetry
Image: Pournasserian (auto-discovered)

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. The facts are as I read them in October 2026.

In brief #

  1. Trace context travels in _meta under the keys traceparent ,tracestate andbaggage , 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) reserves three keys for OpenTelemetry trace context in _meta, the per-request envelope from part 5: traceparent, tracestate and baggage. When they are present, the specification’s overview 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. Extracttraceparent ,tracestate andbaggage 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 ).
  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 ), and eachtasks/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 isdeprecated , and the suggested replacements are stderr on stdio, or OpenTelemetry. Until it’s removed, the 2026-07-28 rule applies: sendnotifications/message only for requests that setio.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 noted that “the official Python SDK now traces automatically”. In mcp 2.3.0, an OpenTelemetry middleware 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:

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:

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

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 andazure-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.
── more in #ai-agents 4 stories · sorted by recency
── more on @model context protocol 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/tracing-mcp-calls-wi…] indexed:0 read:8min 2026-10-23 · —