# OpenTelemetry

`Client`, `McpServer` and `Server` emit OpenTelemetry spans and metrics for every MCP request and notification (except `notifications/message`), following the [OpenTelemetry semantic conventions for MCP](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md). Register an OpenTelemetry SDK and they show up.

```ts
import { context, metrics, propagation, trace } from '@opentelemetry/api';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
import { W3CTraceContextPropagator } from '@opentelemetry/core';
import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace';

// Swap the in-memory exporter for an OTLP exporter to send spans to a backend.
const exporter = new InMemorySpanExporter();
const tracerProvider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] });

context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable());
propagation.setGlobalPropagator(new W3CTraceContextPropagator());
trace.setGlobalTracerProvider(tracerProvider);
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [metricReader] }));
```

::: warning Experimental
The MCP semantic conventions are still in development. Span and attribute names can change in a minor release.
:::

## Register an OpenTelemetry SDK

The SDK packages talk to the OpenTelemetry API, which does nothing until an application registers an SDK. Without one, nothing is recorded and nothing is added to `params._meta`.

```sh
npm install @opentelemetry/api @opentelemetry/sdk-trace @opentelemetry/context-async-hooks @opentelemetry/core
```

The server needs no telemetry configuration of its own.

```ts
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const server = new McpServer({ name: 'weather', version: '1.0.0' });

server.registerTool('get-weather', { inputSchema: z.object({ city: z.string() }) }, async ({ city }) => {
    // Spans started here are children of the "tools/call get-weather" server span.
    const forecast = await trace.getTracer('weather').startActiveSpan('fetch forecast', async span => {
        span.end();
        return 'sunny';
    });
    return { content: [{ type: 'text', text: `${city}: ${forecast}` }] };
});
```

The context manager is what makes the MCP span active inside your handlers, so spans you start there nest under it.

## See the spans

Make a call inside a span of your own, here an agent run.

```ts
await trace.getTracer('agent').startActiveSpan('invoke_agent', async agentSpan => {
    await client.callTool({ name: 'get-weather', arguments: { city: 'Lisbon' } });
    agentSpan.end();
});
```

The client's span carries its trace context to the server in `params._meta`, so both sides land in one trace:

```
invoke_agent (internal)
  tools/call get-weather (client)
    tools/call get-weather (server)
      fetch forecast (internal)
```

The server span comes from the `@modelcontextprotocol/server` instrumentation scope (client spans from `@modelcontextprotocol/client`) and carries the conventions' attributes, plus `jsonrpc.request.id`, left out here:

```
@modelcontextprotocol/server {
  'mcp.method.name': 'tools/call',
  'mcp.protocol.version': '2025-11-25',
  'gen_ai.operation.name': 'execute_tool',
  'gen_ai.tool.name': 'get-weather'
}
```

::: warning
`params._meta` gets whatever your global propagator writes, as HTTP headers do. If it includes W3C Baggage (the `NodeSDK` default), baggage entries reach every MCP server you call, including third-party ones. Register `W3CTraceContextPropagator` on its own to send trace context only.

On Cloudflare Workers, a span's trace flags don't reflect sampling, so a W3C propagator sends `traceparent` with flags `00` and peers may treat the request as unsampled.
:::

::: info
Any SDK that follows [SEP-414](https://modelcontextprotocol.io/community/seps/414-request-meta) reads the same `traceparent`, `tracestate` and `baggage` keys, so a Python or C# peer joins the trace too.
:::

## Read the duration metrics

Register a meter provider and each instance records `mcp.client.operation.duration` for what it sends and `mcp.server.operation.duration` for what it receives, in seconds, with the conventions' bucket boundaries. The tool call above recorded:

```
mcp.client.operation.duration (s) {
  'mcp.method.name': 'tools/call',
  'mcp.protocol.version': '2025-11-25',
  'gen_ai.operation.name': 'execute_tool',
  'gen_ai.tool.name': 'get-weather'
}
```

Metrics never carry session ids, request ids or resource URIs. A server records any method that is neither a spec method nor registered with its own handler as `_OTHER`, keeps a tool or prompt name only once its handler has recognised it, and only records protocol versions it supports, so a client can't grow your metric cardinality.

## Know what counts as an error

Failed operations set `error.type` and an `ERROR` status:

- A JSON-RPC error records its code, such as `-32603`, in `error.type` and `rpc.response.status_code`. On the server, `-32700`, `-32600`, `-32601`, `-32602` and `-32002` mean the caller sent something unservable and are not errors.
- A tool result with `isError: true` records `tool_error`. Its content stays out of the span.
- Local failures record `timeout`, `cancelled`, `connection_closed`, or the SDK error code (`SEND_FAILED`, `INVALID_RESULT`).
- A handler that throws something other than a `ProtocolError` also gets an exception event on the server span.

Spans don't record tool arguments, tool results or prompt arguments. They do record JSON-RPC error messages, handler exception messages and resource URIs.

## Serve over HTTP

With HTTP instrumentation active too, the MCP server span stays a child of the client's MCP span and links to the HTTP server span. `createMcpHandler` needs nothing extra.

::: tip
Already tracing MCP yourself? Pass `openTelemetry: false` to that `Client`, `McpServer` or `Server` to turn its instrumentation off. Sentry's `McpServer` integration also traces MCP; remove it with `integrations: defaults => defaults.filter(integration => integration.name !== 'McpServer')` to keep the SDK's spans instead. Don't filter the `@modelcontextprotocol/*` scopes in a span processor: handler spans and the peer's spans would point at parents that are never exported.
:::

The `examples/opentelemetry` story runs a traced client and server in separate processes over stdio and Streamable HTTP, and checks the trace crosses the wire.

## Recap

- Register an OpenTelemetry SDK; `Client`, `McpServer` and `Server` need no configuration. `openTelemetry: false` turns one instance off.
- Without a registered SDK, nothing is recorded and `params._meta` is untouched.
- Trace context travels in `params._meta`, so client and server spans join one trace and handler spans nest under the server span.
- Each instance records `mcp.client.operation.duration` and `mcp.server.operation.duration` with low-cardinality attributes.
- Spans don't record tool arguments, tool results or prompt arguments.
