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 required peer dependency of `@modelcontextprotocol/client` and `@modelcontextprotocol/server`, so the SDK uses the same copy as the application's OpenTelemetry SDK. npm 7+, pnpm 8+ and Bun install it automatically. Setups that don't install peers must add `@opentelemetry/api` to their dependencies, or importing either package fails: Yarn (classic, and Berry with any linker), `npm --legacy-peer-deps`, and pnpm with `auto-install-peers=false`.

What changes for applications that already run an OpenTelemetry SDK:

- MCP spans and metrics appear under the `@modelcontextprotocol/client` and `@modelcontextprotocol/server` instrumentation scopes. Sentry's `McpServer` integration also traces MCP; remove it to avoid duplicate spans. Filtering the MCP scopes instead leaves handler spans parented on spans that are never exported.
- Outgoing requests and notifications carry whatever your global propagator writes, in `params._meta`, as HTTP requests carry it in headers: `traceparent` and `tracestate`, plus `baggage` if the propagator includes W3C Baggage (the `NodeSDK` default), so 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
140 changes: 140 additions & 0 deletions docs/advanced/opentelemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
---
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
`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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): Readers who run the OpenTelemetry JS SDK on Cloudflare Workers are told their traceparent always carries flags 00, which is not true for that setup. docs/advanced/opentelemetry.md:91 says it for all of Workers. The guide never names the setup it applies to: Workers' own tracing behind registerOpenTelemetry() from agents/observability. With the SDK setup this page shows, the sampler sets the flags (test/integration/test/otel/sdkSetupOrder.test.ts:48 expects -01). Fix: scope the sentence to that bridge by name and say what a reader should do about it, outside the baggage warning.

Why this was flagged

No runtime trigger; this is a wording check on a line this push adds. The only setup the guide documents is the JS SDK: TracerProvider from @ opentelemetry/sdk-trace plus W3CTraceContextPropagator (docs/advanced/opentelemetry.md:13-21), and Cloudflare Workers appears nowhere else on the page. In that setup the span's trace flags come from the SDK sampler on any runtime, and send injects from that span context (packages/core-internal/src/shared/openTelemetry.ts:159-162), so a sampled span sends -01, as test/integration/test/otel/sdkSetupOrder.test.ts:48 asserts. Line 91 says "On Cloudflare Workers, a span's trace flags don't reflect sampling, so a W3C propagator sends traceparent with flags 00" with no qualifier. A Workers reader on the documented SDK setup is told their peers see unsampled requests when they do not, and a reader on the bridge cannot tell the caveat is theirs or what to do. The base branch has no such sentence. No safeguard applies to doc prose.

Verification: The sentence at docs/advanced/opentelemetry.md:91 is unconditional. test/integration/test/otel/sdkSetupOrder.test.ts:17-19 registers TracerProvider from @ opentelemetry/sdk-trace plus W3CTraceContextPropagator, and line 48 expects traceparent: 00-${traceId}-${spanId}-01. A reader on Workers with the JS SDK is told their requests go out unsampled when the code sends -01.

Finding ID: f-90d751b6

:::

::: 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
Sentry's `McpServer` integration also traces MCP, so with Sentry you get two spans per message. Remove the integration with `integrations: defaults => defaults.filter(integration => integration.name !== 'McpServer')`. Don't filter the `@modelcontextprotocol/*` scopes in a span processor instead: 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.
- 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