This is the documentation for v2 — looking for the v1 documentation?
Skip to content

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. 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] }));

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