Skip to content

feat: built-in OpenTelemetry tracing and metrics - #3115

Open
mattzcarey wants to merge 7 commits into
mainfrom
feat/protocol-instrumentation
Open

mattzcarey wants to merge 7 commits into
mainfrom
feat/protocol-instrumentation

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Client, Server and McpServer now emit OpenTelemetry spans and duration metrics for every MCP request and notification, following the MCP semantic conventions, and carry trace context in params._meta (SEP-414). There is nothing to configure: whatever OpenTelemetry setup the app already has picks MCP up. Implements #1264.

// Node
new NodeSDK({ traceExporter: new OTLPTraceExporter() }).start();

// Sentry 11
Sentry.init({ dsn, tracesSampleRate: 1.0, enableOpenTelemetrySetup: true });

// Logfire
logfire.configure({ token, serviceName: 'weather' });

On Cloudflare Workers, registerOpenTelemetry() from agents/observabilityputs the OpenTelemetry API on Workers tracing, so MCP spans land in Workers traces next to the platform's own spans. Register once at module scope as above:

import { registerOpenTelemetry } from "agents/observability";

registerOpenTelemetry();

and turn traces on in wrangler.jsonc:

"observability": { "enabled": true, "traces": { "enabled": true } }
  • @opentelemetry/api is a no-op until an app registers an SDK on globalThis, so the SDK calls it unconditionally. Without one nothing is recorded and messages are unchanged.
  • @opentelemetry/api (^1.9.0) is a new peer dependency of client and server, so the SDK shares the app's copy; a second copy would silently ignore the app's SDK. There is no new public API.
  • No tool arguments, results or prompt arguments are recorded, and metric attributes stay low-cardinality.

Each setup above ran against packed builds of this branch and delivered the MCP spans to its backend; with Sentry, a client and server in separate processes joined one trace. Tests ran (pnpm check:all, package, integration and e2e suites), including e2e propagation on every transport.

Client, Server and McpServer take instrumentation: { send, receive }. The SDK
calls it for every request and notification it sends or receives, merges
trace-context keys the hook writes into params._meta, runs the transport send
or the handler inside the returned scope, and ends the scope exactly once
with how the message settled. A throwing hook is reported via onerror and
never changes the outcome. Without the option nothing changes on the wire.

Refs #1264.
@changeset-bot

changeset-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cfb8190

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core-internal Minor
@modelcontextprotocol/client Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/codemod Minor
@modelcontextprotocol/core Minor
@modelcontextprotocol/server-legacy Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@3115

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@3115

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@3115

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@3115

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@3115

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@3115

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@3115

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@3115

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@3115

commit: cfb8190

…ion option

openTelemetry() turns every MCP request and notification into spans and
mcp.{client,server}.operation.duration histograms following the OpenTelemetry
semantic conventions for MCP, and propagates W3C trace context and baggage
through params._meta. Content capture is opt-in. Adds the docs page
(docs/advanced/opentelemetry.md) with a runnable companion, the
examples/opentelemetry story over stdio and Streamable HTTP, and the design spec.

Refs #1264.
@mattzcarey mattzcarey changed the title feat: add an experimental instrumentation option for tracing and metrics feat: OpenTelemetry tracing and metrics (instrumentation option + @modelcontextprotocol/otel) Oct 27, 2026
Client, Server and McpServer call the OpenTelemetry API directly: register an
SDK and MCP traffic shows up; without one the API is a no-op and messages are
unchanged. @opentelemetry/api is a dependency of client and server, the MCP
mapping moves into core-internal, and the instrumentation hook becomes
package-internal (attached via setProtocolInstrumentation). Drops the
@modelcontextprotocol/otel package, the public instrumentation option and
captureContent. Instrumentation scopes are @modelcontextprotocol/client and
@modelcontextprotocol/server. Tests and examples use OpenTelemetry SDK 3.

Refs #1264.
@mattzcarey mattzcarey changed the title feat: OpenTelemetry tracing and metrics (instrumentation option + @modelcontextprotocol/otel) feat: built-in OpenTelemetry tracing and metrics Oct 27, 2026
Sentry's propagator only injects the active context, so injecting before the
span was made active left params._meta empty and broke cross-process traces
under Sentry. Inject inside context.with(spanContext).
@mattzcarey
mattzcarey marked this pull request as ready for review October 27, 2026 20:15
@mattzcarey
mattzcarey requested a review from a team as a code owner October 27, 2026 20:15

@claude claude Bot left a comment

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.

3 optional notes from this repository's REVIEW.md or CLAUDE.md checks were not posted as comments, over this review's limit for such notes; they are on this commit's check card.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread packages/core-internal/src/shared/openTelemetry.ts Outdated
Comment thread packages/core-internal/src/shared/openTelemetry.ts Outdated
Comment thread packages/core-internal/src/shared/protocol.ts
Comment thread packages/core-internal/src/shared/openTelemetry.ts Outdated
Comment thread packages/client/package.json Outdated
Comment thread packages/core-internal/src/shared/protocol.ts
Comment thread packages/core-internal/src/shared/openTelemetry.ts Outdated
Comment thread packages/core-internal/src/shared/openTelemetry.ts
Comment thread packages/core-internal/src/shared/instrumentation.ts Outdated
Comment thread packages/core-internal/src/shared/instrumentation.ts Outdated
- Record a received method name only for spec methods or methods with their own
  handler; fallback-handled methods are client-chosen and become _OTHER.
- Keep a server-side tool or prompt name only once its handler recognised it,
  so a cancelled unknown-tool call no longer records the name.
- Record a requested protocolVersion on the receive side only when supported.
- Bucket application-defined error codes from peers as _OTHER on client metrics.
- Inject trace context from the span's own context, so propagation works
  without a context manager while Sentry's propagator still sees it active.
- Recreate the duration histograms when the global meter provider changes.
- @opentelemetry/api is now a peer dependency of client and server, so the SDK
  shares the application's copy (a second copy silently ignored the app's SDK).
- Changeset and docs: baggage reaches peers, strict custom schemas need an
  optional _meta, notifications/message is skipped, what spans do record.
- Trim internal JSDoc; drop the design spec from this PR.
Comment thread packages/client/package.json
Comment thread packages/core-internal/src/shared/openTelemetry.ts Outdated
@claude claude Bot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Oct 28, 2026
Errors the protocol sends before dispatch (-32601, -32022, the envelope's
-32602) carry no thrown cause, so a tools/call to an instance with no tool
handler no longer records the client-chosen name. Changeset lists every
install setup that doesn't add the new peer dependency automatically.

@claude claude Bot left a comment

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.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

@KKonstantinov

Copy link
Copy Markdown
Contributor

@mattzcarey a suggestion for us to consider before this lands: keep the zero-config default, and add one optional override for the cases the app's global OpenTelemetry setup can't express. It brings back part of OpenTelemetryOptions from the earlier design doc, but only as overrides, so registering an SDK stays the whole setup.

// On ProtocolOptions, so Client, Server and McpServer all accept it
openTelemetry?: false | {
    propagator?: TextMapPropagator; // _meta inject (send) and extract (receive); default: the global propagator
    tracerProvider?: TracerProvider; // default: the global tracer provider
    meterProvider?: MeterProvider; // default: the global meter provider
};

false: turn it off for one instance

  • An app that already traces MCP another way, such as Sentry's MCP integration, which wraps every v2 McpServer. Today it gets two spans per message.
  • The documented workaround, filtering the @modelcontextprotocol/* scopes in a span processor, drops the MCP span but not the spans under it. Handler spans keep it as their parent, so backends show broken trees, and the peer's server span parents on a client span that is never exported.
  • Precedent: Python lets you remove its OpenTelemetryMiddleware (provisional), and C# records nothing unless something listens to its ActivitySource.

propagator: control what crosses the wire, per instance

  • A client that calls third-party servers. NodeSDK's default propagator includes W3C Baggage, so baggage entries reach every server in _meta. { propagator: new W3CTraceContextPropagator() } on that client sends trace context only, while first-party clients keep baggage.
  • A peer whose custom methods validate params with a strict schema, which answers -32602 once _meta appears. A no-op propagator on that client leaves its requests as they are today.
  • A public server that doesn't want untrusted clients choosing its trace ID and parent. A no-op propagator there makes server spans parent on the ambient context, such as the HTTP span. The conventions say servers SHOULD use _meta as the parent "by default", so this stays within them.
  • Today the only lever is the global propagator, which changes HTTP propagation too. This turns the baggage and strict-schema caveats in the changeset into something users can act on.

tracerProvider / meterProvider: route one instance's telemetry

  • A multi-tenant gateway that exports each tenant's MCP traffic to that tenant's backend.
  • A library that embeds an MCP client or server and doesn't own the app's globals.
  • Tests that assert on spans without registering globals. Our own e2e OpenTelemetry scenario lives in a separate file for that reason.

Later, in its own PR: captureContent

The conventions list gen_ai.tool.call.arguments, gen_ai.tool.call.result and gen_ai.prompt.variable.* as opt-in. OpenTelemetry configuration can't add them: processors and Views only see the attributes we set. A server author can set them by hand in each handler, but a client can't, because its span ends before callTool returns. Agent observability UIs show tool inputs and outputs, so this will be asked for. It needs a serialization and size-limit design and should stay off by default. It can join the same object without a break.

What I'd leave to OpenTelemetry and document instead

  • Noisy methods (ping, tools/list, floods of progress notifications): a Sampler, which sees the span name and mcp.method.name at start.
  • Metric cost: Views (drop the meter, keep some attributes, set a cardinality limit).
  • Extra attributes such as tenant or user: trace.getActiveSpan() in a handler, or a span processor's onStart.
  • Clients forcing sampling: ParentBasedSampler({ remoteParentSampled }).
  • Redaction: attribute processors in the Collector.

If we want the smallest step, false and propagator are the two the review findings point at, and the providers and captureContent can follow without a break. Open for us: the option name (openTelemetry or telemetry), and whether this lands here or in a follow-up.

…kers caveats

Filtering the MCP scopes orphans handler spans, so point Sentry users at
removing its McpServer integration instead. Say _meta follows the global
propagator like HTTP headers, and that Workers trace flags don't reflect
sampling.

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants