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.
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.
npm install @opentelemetry/api @opentelemetry/sdk-trace @opentelemetry/context-async-hooks @opentelemetry/coreThe server needs no telemetry configuration of its own.
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.
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, inerror.typeandrpc.response.status_code. On the server,-32700,-32600,-32601,-32602and-32002mean the caller sent something unservable and are not errors. - A tool result with
isError: truerecordstool_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
ProtocolErroralso 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,McpServerandServerneed no configuration.openTelemetry: falseturns one instance off. - Without a registered SDK, nothing is recorded and
params._metais 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.durationandmcp.server.operation.durationwith low-cardinality attributes. - Spans don't record tool arguments, tool results or prompt arguments.