Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/protocol-instrumentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@modelcontextprotocol/core-internal': minor
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
---

`Client`, `Server` and `McpServer` now emit OpenTelemetry spans and `mcp.client.operation.duration` / `mcp.server.operation.duration` histograms for every MCP request and notification except `notifications/message`, following the OpenTelemetry semantic conventions for MCP, and propagate W3C trace context through `params._meta` (SEP-414). There is nothing to configure: register an OpenTelemetry SDK and MCP traffic shows up. Without one, the OpenTelemetry API is a no-op, nothing is recorded and messages are unchanged.

`@opentelemetry/api` (`^1.9.0`) is a new peer dependency of `@modelcontextprotocol/client` and `@modelcontextprotocol/server`, so the SDK uses the same copy as the application's OpenTelemetry SDK. npm, pnpm and Bun install it automatically; with Yarn PnP add it to your dependencies.

What changes for applications that already run an OpenTelemetry SDK:

- MCP spans and metrics appear under the `@modelcontextprotocol/client` and `@modelcontextprotocol/server` instrumentation scopes. To drop them (for example when Sentry's MCP integration already traces MCP), filter those scopes in your SDK.
- Outgoing requests and notifications carry the keys your propagator writes in `params._meta`: `traceparent` and `tracestate`, and `baggage` if your propagator includes W3C Baggage (the `NodeSDK` default), in which case baggage entries reach MCP peers, including third-party servers. Register `W3CTraceContextPropagator` alone to send trace context only.
- Servers that validate a custom method's params with a strict schema need an optional `_meta` member, as they already do for callers that pass `onprogress`.

Spans do not record tool arguments, tool results or prompt arguments. They do record JSON-RPC error messages, handler exception messages and resource URIs. The span and attribute names are experimental while the MCP semantic conventions are in development.
3 changes: 2 additions & 1 deletion docs/.vitepress/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Schema libraries', link: '/advanced/schema-libraries' },
{ text: 'Custom transports', link: '/advanced/custom-transports' },
{ text: 'Wire schemas', link: '/advanced/wire-schemas' },
{ text: 'Gateway', link: '/advanced/gateway' }
{ text: 'Gateway', link: '/advanced/gateway' },
{ text: 'OpenTelemetry', link: '/advanced/opentelemetry' }
]
},
{ text: 'Testing', link: '/testing' },
Expand Down
138 changes: 138 additions & 0 deletions docs/advanced/opentelemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
---
shape: how-to
---

# 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 source="../../examples/guides/advanced/opentelemetry.examples.ts#sdk_setup"
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 source="../../examples/guides/advanced/opentelemetry.examples.ts#server_traced"
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 source="../../examples/guides/advanced/opentelemetry.examples.ts#callTool_traced"
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
If your propagator includes W3C Baggage (the `NodeSDK` default), baggage entries travel in `params._meta` to every MCP server you call, including third-party ones. Register `W3CTraceContextPropagator` on its own to send trace context only.
:::

::: 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 with another tool, such as Sentry's MCP integration? Drop one of the two to avoid duplicate spans: filter the `@modelcontextprotocol/client` and `@modelcontextprotocol/server` instrumentation scopes out in your SDK's span processor, or turn off the other integration.
:::

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.
- 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.
2 changes: 1 addition & 1 deletion docs/servers/logging-progress-cancellation.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Only the result comes back:
## Log to the client

::: warning Deprecated — SEP-2577
Log to `stderr` (stdio servers) or use OpenTelemetry instead. **MCP logging** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional through the deprecation window (at least twelve months) — see the [deprecated features registry](https://modelcontextprotocol.io/specification/2026-07-28/deprecated).
Log to `stderr` (stdio servers) or use [OpenTelemetry](../advanced/opentelemetry.md) instead. **MCP logging** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional through the deprecation window (at least twelve months) — see the [deprecated features registry](https://modelcontextprotocol.io/specification/2026-07-28/deprecated).
:::

Declare the `logging` capability when you construct the server.
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ The one exception to the generic commands is the reference pair: [`cli-client/`]
| [`todos-server/`](./todos-server/README.md) | **Reference server** (pairs with cli-client): every server feature with a real job — CRUD tools, sampling, multi-round elicitation, subscriptions, progress | stdio + http | dual |
| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern |
| [`gateway/`](./gateway/README.md) | `connect({ prior })` — probe once, zero-round-trip connect for every worker (gateway pattern) | http | modern |
| [`opentelemetry/`](./opentelemetry/README.md) | OpenTelemetry traces and duration metrics with no MCP-side configuration; trace context crosses the wire in `params._meta` | stdio + http | dual |
| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual |
| [`extension-capabilities/`](./extension-capabilities/README.md) | Declaring `capabilities.extensions` and reading the negotiated map | stdio + http | dual |
| [`skills/`](./skills/README.md) | Skills over MCP (SEP-2640): `loadSkills` a folder, `SkillsClient` lists, loads and verifies | stdio + http | dual |
Expand Down
109 changes: 109 additions & 0 deletions examples/guides/advanced/opentelemetry.examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Companion example for `docs/advanced/opentelemetry.md`.
*
* Every `ts` fence on that page is synced from a `//#region` in this file
* (`pnpm sync:snippets --check`). The file also runs: the harness below the
* regions connects an in-memory client and server and prints the spans and
* metrics the page quotes verbatim.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/advanced/opentelemetry.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
import type { DataPoint, Histogram } from '@opentelemetry/sdk-metrics';
import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics';
import type { ReadableSpan } from '@opentelemetry/sdk-trace';

class CollectingMetricReader extends MetricReader {
protected async onShutdown(): Promise<void> {}
protected async onForceFlush(): Promise<void> {}
}
const metricReader = new CollectingMetricReader();

// "Register an OpenTelemetry SDK" — the application's job, once, before any MCP traffic.
//#region sdk_setup
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] }));
//#endregion sdk_setup

// "Register an OpenTelemetry SDK" — the server needs no telemetry configuration.
//#region server_traced
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}` }] };
});
//#endregion server_traced

// ---------------------------------------------------------------------------
// Harness (not shown on the page). Connects a client in memory, makes one tool
// call inside an agent span, and prints what was recorded.
// ---------------------------------------------------------------------------

const { SpanKind } = await import('@opentelemetry/api');
const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client');
const client = new Client({ name: 'agent', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
exporter.reset();

// "See the spans" — the call the page's span tree comes from.
//#region callTool_traced
await trace.getTracer('agent').startActiveSpan('invoke_agent', async agentSpan => {
await client.callTool({ name: 'get-weather', arguments: { city: 'Lisbon' } });
agentSpan.end();
});
//#endregion callTool_traced

const spans = exporter.getFinishedSpans();
const kinds: Record<number, string> = { [SpanKind.INTERNAL]: 'internal', [SpanKind.SERVER]: 'server', [SpanKind.CLIENT]: 'client' };
const printTree = (parent: ReadableSpan | undefined, depth: number) => {
const children = spans
.filter(span => span.parentSpanContext?.spanId === parent?.spanContext().spanId)
.toSorted((a, b) => a.startTime[0] - b.startTime[0] || a.startTime[1] - b.startTime[1]);
for (const child of children) {
console.log(`${' '.repeat(depth)}${child.name} (${kinds[child.kind]})`);
printTree(child, depth + 1);
}
};
printTree(undefined, 0);

const toolServerSpan = spans.find(span => span.name === 'tools/call get-weather' && span.kind === SpanKind.SERVER);
if (toolServerSpan === undefined) throw new Error('opentelemetry.md claim failed: no tools/call server span');
const { 'jsonrpc.request.id': _requestId, ...stableAttributes } = toolServerSpan.attributes;
console.log(toolServerSpan.instrumentationScope.name, stableAttributes);

const { resourceMetrics } = await metricReader.collect();
const clientDuration = resourceMetrics.scopeMetrics
.filter(scope => scope.scope.name === '@modelcontextprotocol/client')
.flatMap(scope => scope.metrics)
.find(metric => metric.descriptor.name === 'mcp.client.operation.duration');
const toolCallPoint = (clientDuration?.dataPoints as DataPoint<Histogram>[] | undefined)?.find(
point => point.attributes['mcp.method.name'] === 'tools/call'
);
if (toolCallPoint === undefined) throw new Error('opentelemetry.md claim failed: no tools/call duration was recorded');
console.log(`${clientDuration!.descriptor.name} (${clientDuration!.descriptor.unit})`, toolCallPoint.attributes);

await client.close();
await tracerProvider.shutdown();
18 changes: 18 additions & 0 deletions examples/opentelemetry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# opentelemetry

Trace and measure MCP traffic with OpenTelemetry. Both processes register an OpenTelemetry SDK and configure nothing on the MCP side; the client's `tools/call` span carries its trace context to the server in `params._meta`, so the server span and anything the tool does land in the same trace. The client asserts the trace crossed the wire, the span parenting on both sides, and that `mcp.client.operation.duration` was recorded.

```bash
pnpm --filter @mcp-examples/opentelemetry client
```

Sample output (ids differ per run):

```
trace 4bf92f3577b34da6a3ce929d0e0e4736
invoke_agent (client process)
tools/call trace-info (client process)
tools/call trace-info (server process)
```

The server and client keep spans in memory to stay self-contained. In an application, add a span processor with an exporter (for example `OTLPTraceExporter`) to the tracer provider. See [the OpenTelemetry guide](../../docs/advanced/opentelemetry.md).
Loading
Loading