# MCP Server Observability with OpenTelemetry: Spans, Metrics, and Trace Correlation

> Instrumenting an MCP server past the pillar's baseline: what to put on a tool-call span beyond gen_ai.tool.name, what replaces the deprecated Logging primitive in practice, per-tool-name latency and error-rate metrics, and how a trace ID actually survives the agent-to-upstream-API hop.

Guide: MCP in practice — part 6
Published: 2026-08-11 · Updated: 2026-08-11 · 1683 words
Canonical: https://changegamer.ai/articles/mcp-server-observability-opentelemetry
JSON: https://changegamer.ai/api/articles/mcp-server-observability-opentelemetry.json
Pillar: https://changegamer.ai/articles/mcp-server-in-production.md

## In short

- A tool-call span on an MCP server should carry MCP-specific identifiers on top of the generic `gen_ai.tool.name` attribute — server name/version, transport (stdio vs. Streamable HTTP), the negotiated protocolVersion, and the request's JSON-RPC id — but as of August 2026 no confirmed joint MCP-OpenTelemetry semantic convention defines those attribute names; treat this as a practitioner-level naming convention you own, not a spec you are implementing.
- SEP-2577, final on 2026-07-28, marked the Logging primitive deprecated alongside Sampling and Roots — annotation-only, still functional, not eligible for removal before 2027-07-28. That deprecation is the trigger to route structured operational logs through your own OTel span events going forward rather than build anything new on Logging; that recommendation is this article's own guidance, not something the spec mandates as Logging's replacement.
- Per-tool-name p50/p95 latency and per-tool-name error rate, aggregated across calls into a dashboard rather than logged per-call, catch a single tool degrading inside a server whose overall request rate and average latency still look healthy — this is a metrics-layer concern distinct from both generic HTTP metrics and the pillar's per-call span logging.
- The MCP spec does not mandate trace-context propagation at all. Carrying one trace ID across the fourth hop — agent, to MCP client, to your MCP server, to whatever upstream API your server calls — is standard OpenTelemetry `traceparent` propagation applied by the operator across that hop, not a protocol guarantee every client or upstream honors.

---

The pillar's [observability section](/articles/mcp-server-in-production) establishes the baseline: trace every run with a stable `trace_id`, span every tool call, redact before logging, and reach for one of the OTel-native platforms it names. That baseline is correct and, deliberately, general — it is the same guidance any agent component needs. This article is the part specific to an MCP server: what belongs on a tool-call span beyond the generic `gen_ai.*` vocabulary, what to do about the Logging primitive's 2026-07-28 deprecation, why per-tool aggregate metrics catch things per-call logs do not, and how a trace ID survives the fourth hop the pillar names but does not spell out.

## What belongs on an MCP tool-call span

`gen_ai.tool.name` and the rest of the OTel GenAI attributes tell you which tool was called and how much it cost in tokens ([agent observability and tracing](/resources/agent-observability)). They do not tell you anything about the MCP layer the call traveled through — which is a gap worth closing deliberately, because an MCP server has failure modes a generic tool call does not: a transport mismatch, a stale SDK, a client on an older protocol revision than the server expects.

Four fields are worth adding to every tool-call span, as your own convention:

- **Server name and version.** Set these as OTel *resource* attributes (`service.name`, `service.version`) rather than per-span attributes — they are static for the life of a deployment, not something that varies call to call, so they belong on the resource the spans are emitted from, not repeated on each one.
- **Transport.** stdio or Streamable HTTP. A latency spike that only shows up on one transport is a different investigation than one that shows up on both.
- **Negotiated protocolVersion.** The version your server is actually operating under for this session. If you are running a dual-version fleet during a spec migration, this is the field that tells you which cohort a given span came from without cross-referencing a deploy log.
- **JSON-RPC request id.** The `id` field on the originating request. It costs nothing to capture and is the one field that lets you match a span back to a raw captured message if you ever need to debug at the wire level.

None of this is a documented MCP-OpenTelemetry semantic convention — there is no confirmed joint spec defining these attribute names as of August 2026, and nothing in this site's corpus states that one exists. Pick a consistent prefix (something like `mcp.server.name`, `mcp.transport`, `mcp.protocol_version` — illustrative, not a name you are obligated to match) and use it uniformly across your own servers so dashboards built against one server work against the next without renaming fields. If a genuine joint convention ships later, expect to migrate onto it; treat what you build today as a working convention, not a permanent one.

## The deprecated Logging primitive, and what to do instead

SEP-2577, final as of 2026-07-28, marked the Logging primitive deprecated alongside Sampling and Roots — an annotation-only change, so it still works, with removal barred for at least twelve months from that date ([MCP primitives](/resources/mcp-primitives)). The deprecation calendar mechanics — the RC lock date, the twelve-month floor arithmetic — are already covered in [MCP server versioning and spec migration](/articles/mcp-server-versioning-and-spec-migration); this article does not repeat them.

What matters here is narrower: the deprecation is a signal to stop building new operational logging on Logging, not a spec-mandated migration to a named successor. The spec does not designate a replacement primitive. The practitioner recommendation — this article's own, not a documented spec decision — is to treat your own server's structured logs as span events on the OTel trace you are already emitting for tool calls, rather than as a separate stream keyed to the Logging primitive's client-facing notification mechanism. Concretely: when a tool handler needs to record something beyond its inputs, outputs and latency — a retry, a fallback path taken, a partial failure inside a multi-step handler — attach it as an event on the active span rather than reaching for a parallel logging channel. That keeps every observable fact about a given call attached to the same trace, queryable by the same trace ID, instead of split across two systems you have to correlate by timestamp.

This does not mean stop supporting the Logging primitive if your server already implements it — annotation-only deprecation means existing clients that rely on it keep working, and nothing forces removal before the twelve-month floor. It means: do not build new functionality on it, and route the observability work you are doing anyway through the span-based path you already need for tracing.

## Metrics an MCP server needs that per-call logging does not surface

A trace tells you what happened on one call. It does not tell you that one specific tool has quietly gotten slower or started failing more often across the last thousand calls while your server's overall error rate looks fine. That is a metrics-layer question, and the metric that answers it is one neither the pillar's per-call span guidance nor a generic HTTP dashboard aggregates by default: **latency and error rate, bucketed by tool name.**

Two dashboards worth building, distinct from each other and from anything generic:

- **Per-tool-name latency, p50 and p95.** Aggregate span duration grouped by `gen_ai.tool.name` (or your own tool-name attribute), not by endpoint or by server as a whole. A server exposing ten tools can have nine fast ones and one slow one; a server-wide p95 averages that slow tool's impact away until it is bad enough to move the aggregate, which is later than you want to know. Bucketing by tool name surfaces the one degrading tool immediately, before it drags the aggregate.
- **Per-tool-name error rate.** The fraction of calls to a specific tool that return an error response or throw, tracked over a rolling window. A tool that starts failing 15% of the time because an upstream API it depends on degraded is invisible in an overall error-rate metric if your server has thirty other healthy tools diluting it — but it is exactly the signal a caller of that one tool is experiencing on every failed attempt.

Both are aggregations over the same spans you are already emitting for tracing — no new instrumentation is required beyond making sure the tool-name attribute is set consistently, which most of the generic `gen_ai.*` guidance already assumes. The gap is not instrumentation, it is dashboarding: build the per-tool breakdown deliberately rather than relying on whatever default grouping your observability platform ships with, because most default dashboards group by service or by endpoint, not by the specific tool a JSON-RPC `tools/call` invoked.

## Trace correlation across the fourth hop

The pillar states that a trace ID should propagate into your server from the calling client, and back out again in your logs. What it does not spell out is the mechanics once your server itself calls something else — the actual chain in a production deployment is four hops deep: **agent, to MCP client, to your MCP server, to whatever upstream API your server calls on the model's behalf.** Losing the trace ID at any one of those hops breaks correlation for everything downstream of it.

State this plainly, because it matters for what you can and cannot rely on: **the MCP spec does not mandate trace-context propagation.** Nothing in the corpus states that MCP clients are required to forward a W3C Trace Context header, and the spec's JSON-RPC message format has no reserved field for one. Whatever propagation happens across the agent-to-client and client-to-server hops is a property of the specific client implementation, not a protocol guarantee — some clients may forward a trace context faithfully, others may not carry one at all.

What you can build, and what this article recommends as standard OpenTelemetry practice applied across the hop your server controls — not part of the MCP spec, but ordinary OTel context propagation:

- **On the inbound side**, if the transport is Streamable HTTP, check the incoming request for a `traceparent` header and, if present, start your server's span as a child of that context rather than a fresh root span. If it is absent — which the spec permits, since it says nothing about the header at all — start a new trace at your server; that hop upstream of you is simply not observable from where you sit.
- **On the outbound side**, when your handler calls an upstream API, inject a `traceparent` header derived from your current span onto that outbound request, the same way any OTel-instrumented HTTP client would. If the upstream API is itself OTel-instrumented and honors the header, you get a trace that spans all four hops; if it is not, your trace still ends cleanly at the boundary of what you control, which is the honest outcome rather than a silently broken one.

The practical takeaway: do not assume trace continuity into or out of your server as a spec guarantee. Instrument both directions explicitly, using standard OTel propagation libraries for your language, and treat any gap in the chain — a client that does not forward context, an upstream that does not accept it — as an expected limit of a spec that does not address tracing at all, not a defect in your own instrumentation.

## Where this leaves you

Add MCP-specific identifiers to your tool-call spans as your own convention, not a spec you are implementing — no confirmed joint MCP-OpenTelemetry semantic convention exists as of August 2026. Stop building new logging on the deprecated Logging primitive and route structured operational events through span events on the trace you already emit. Build per-tool-name latency and error-rate dashboards deliberately, since no default grouping does this for you. And instrument trace propagation across all four hops explicitly, because the MCP spec is silent on it end to end. For the baseline this builds on — trace/span definitions, the `gen_ai.*` vocabulary, and the observability tooling landscape — see [MCP server in production](/articles/mcp-server-in-production) and [agent observability and tracing](/resources/agent-observability); for the SEP-2577 deprecation calendar in full, see [MCP server versioning and spec migration](/articles/mcp-server-versioning-and-spec-migration).

## Frequently asked questions

### What span attributes should an MCP tool call carry beyond gen_ai.tool.name?

The OTel GenAI conventions give you `gen_ai.tool.name` and the surrounding `gen_ai.*` attributes for the model side of the call, but nothing MCP-specific. Add, as your own convention: the server's name and version (as OTel resource attributes, since they are static per deployment rather than per call), the transport in use (stdio or Streamable HTTP), the protocolVersion negotiated for the session, and the JSON-RPC request id, which lets you correlate a span back to the exact wire message if you also capture raw traffic for debugging. None of these are part of a confirmed spec — they are a naming convention worth being consistent about across your own servers.

### Is there an official MCP OpenTelemetry semantic convention?

Not as of August 2026. The OTel GenAI semantic conventions cover model calls and generic agent/tool spans and still carry Development status, not final, as of July 2026. Nothing in the corpus confirms a joint MCP-specific extension to that vocabulary — no working group output, no accepted SEP defining MCP span attributes. Build your MCP-specific fields as your own prefixed convention and expect to revise them if and when such a joint spec appears.

### What replaces the Logging primitive now that it is deprecated?

Nothing the spec designates as a replacement — SEP-2577's deprecation of Logging is annotation-only, so the primitive still functions and the spec does not name a successor. In practice, route your server's own structured operational logs (not the client-facing Logging primitive) through OTel span events attached to the tool-call span that produced them, the same mechanism you would already use for redacted inputs and outputs. That is a practitioner recommendation for how to structure new logging work, not a documented spec migration path.

### How do I propagate a trace ID from the agent through my MCP server to an upstream API?

With standard OTel context propagation, applied by you across each hop, because the MCP spec itself does not mandate any of it. Concretely: extract the incoming `traceparent` header (Streamable HTTP) or an equivalent trace context your client passes (stdio), start your tool-call span as a child of it, and inject a fresh `traceparent` header onto any outbound request your handler makes to an upstream API. If the calling MCP client does not send a trace context at all — which the spec permits, since it says nothing about it — start a new trace at your server and accept that the agent-to-client hop is invisible to you; that is a real limit, not a bug in your instrumentation.


---

## The rest of this guide

- [MCP Server in Production: How to Build, Ship and Run One](https://changegamer.ai/articles/mcp-server-in-production.md): The operator playbook for taking an MCP server past the quickstart: transport choice, OAuth 2.1 auth, tool design, versioning against a moving spec, testing across clients, distribution, observability, cost and the failure modes that show up once real clients connect.
- [stdio vs. Streamable HTTP for MCP Servers: A Decision Framework](https://changegamer.ai/articles/mcp-stdio-vs-streamable-http.md): Which MCP transport to build against and why: the single-client-vs-shared decision rule, how state works without a session handshake under the 2026-07-28 spec, the auth-model switching cost, and what actually breaks migrating off HTTP+SSE.
- [How to Implement OAuth 2.1 for an MCP Server](https://changegamer.ai/articles/mcp-oauth-implementation.md): A wire-level implementation walkthrough for OAuth 2.1 on a remote MCP server: what the discovery documents actually contain, CIMD vs. Dynamic Client Registration in your server code, per-SEP detail from the 2026-07-28 hardening set, and token-validation mechanics.
- [Defending MCP Clients Against Tool Description and Output Injection](https://changegamer.ai/articles/mcp-tool-description-injection.md): Two distinct MCP injection surfaces — a tool description at connect-time and a tool's return value at call-time — and the client-side architectural patterns (Dual LLM, Action-Selector, Context-Minimization) that contain each one.
- [How to Test an MCP Server in CI](https://changegamer.ai/articles/testing-mcp-servers-in-ci.md): The implementation mechanics below the three-layer test pyramid: what a mocked MCP transport actually replaces, what a Streamable HTTP cassette contains, a concrete CI job/trigger shape, and how to catch spec-version drift before it reaches production.
- [MCP Server Versioning and Spec Migration: An Operator Playbook](https://changegamer.ai/articles/mcp-server-versioning-and-spec-migration.md): A migration runbook for MCP server operators: feature-detecting via capabilities instead of hard protocolVersion branching, a dual-version fleet rollout with rollback triggers, a compatibility shim for legacy clients still sending initialize, and a deprecation calendar built off the 12-month SEP-2577 floor.
- [How to Publish an MCP Server to the Official Registry](https://changegamer.ai/articles/mcp-server-registry-publishing-playbook.md): A step-by-step walkthrough of the mcp-publisher CLI and the server.json manifest for publishing an MCP server to registry.modelcontextprotocol.io, how to republish after a version bump, and how the registry relates to aggregators, marketplaces, and direct distribution.
- [MCP Server Cost Optimization: Toolset Size, Caching Hints, and Fan-Out](https://changegamer.ai/articles/mcp-server-cost-optimization.md): How the token cost of an MCP server's tool list, the 2026-07-28 spec's ttlMs/cacheScope caching hints, fan-out from callers you do not control, and per-tool-name cost visibility each shape what a production MCP server actually costs to run.
- [Common MCP Server Failure Modes and How to Fix Them](https://changegamer.ai/articles/mcp-server-failure-modes.md): A runtime playbook for the two MCP server failure modes with no dedicated deep-dive elsewhere: unrecoverable state after a mid-call crash, and malformed or hallucinated tool calls that reach the handler despite upstream validation.
- [MCP Tools vs Resources vs Prompts: How to Choose the Right Primitive](https://changegamer.ai/articles/mcp-resources-and-prompts-vs-tools.md): A decision procedure for MCP's three server-side primitives — who controls each one, a worked example of what it costs to expose a Resource as a Tool by mistake, and how Sampling and Elicitation fit as the client-side counterparts.
- [The MCP Server Production Launch Checklist](https://changegamer.ai/articles/mcp-server-production-launch-checklist.md): A phase-by-phase go/no-go checklist for launching an MCP server: checkable gate conditions for transport and auth, tool design, cross-client testing, publish readiness, observability, and ongoing operation — with links to the mechanics each gate depends on.
- [Zero-Touch Enterprise Authorization for MCP Servers: ID-JAG and SEP-990](https://changegamer.ai/articles/mcp-enterprise-sso-id-jag.md): How Enterprise-Managed Authorization (SEP-990) removes the per-server OAuth consent screen for MCP servers: the ID-JAG grant mechanism, its RFC 8693/7523 building blocks, named launch adopters as of August 2026, and how it layers on top of standard OAuth 2.1 rather than replacing it.

## Reference resources

- https://changegamer.ai/resources/agent-observability.md
- https://changegamer.ai/resources/mcp-primitives.md

All guides: https://changegamer.ai/api/articles.json · Reference corpus: https://changegamer.ai/llms.txt
