Skip to content

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

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

mattzcarey wants to merge 5 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/observability (cloudflare/agents PR coming soon) puts 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:

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

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

…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 on lines +157 to +158
"peerDependencies": {
"@opentelemetry/api": "catalog:otel"

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.

🔴 Apps installed without automatic peer installs fail on importing @ modelcontextprotocol/client or server after this minor upgrade, even if they never use OpenTelemetry. @ opentelemetry/api is now a required peer (packages/client/package.json:158) and openTelemetry.ts:2-11 imports it statically. Fix: make the changeset name every affected setup, not only Yarn PnP: Yarn classic and Berry with any linker, npm with legacy-peer-deps, and pnpm with auto-install-peers=false; or mark the peer optional and emit no telemetry when it is absent. .changeset/protocol-instrumentation.md:9 names only Yarn PnP.

Why this was flagged

The app is installed by a package manager that does not install peer dependencies, such as Yarn classic or Yarn Berry with the node-modules linker, npm install --legacy-peer-deps, or pnpm with auto-install-peers=false, and nothing else in its tree brings in @ opentelemetry/api. packages/client/package.json:157-159 and the matching block in packages/server/package.json declare the API only under peerDependencies, with no peerDependenciesMeta marking it optional. packages/core-internal/src/shared/openTelemetry.ts:2-11 imports it with a static import, and the Client and Server constructors reach that module through packages/client/src/client/openTelemetry.ts:2. Loading the package then fails with a module-not-found error for @ opentelemetry/api before any user code runs. On the base branch the packages have no such import. .changeset/protocol-instrumentation.md:9 says "npm, pnpm and Bun install it automatically; with Yarn PnP add it to your dependencies", which leaves out the other setups.

Verification: packages/client/package.json:157-159 and packages/server/package.json:165-167 declare @ opentelemetry/api only under peerDependencies. packages/core-internal/src/shared/openTelemetry.ts:2-11 is a static value import from @ opentelemetry/api. On the base there is no such import, so this is a regression. .changeset/protocol-instrumentation.md:9 names only Yarn PnP.

Finding ID: f-33f90d69

Comment on lines +249 to +250
function recognisedTarget(outcome: OperationOutcome): boolean {
return outcome.type === 'result' || (outcome.type === 'error' && outcome.error.code !== INVALID_PARAMS);

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.

🔴 Servers with no tools/call or prompts/get handler now record every client-chosen tool or prompt name in span names and mcp.server.operation.duration. Such a request now counts as a known spec method (protocol.ts:995), so receive takes the name. It then ends with -32601, which recognisedTarget (openTelemetry.ts:250) treats as recognised. Fix: keep the name only when a handler actually answered, so every error sent before dispatch drops it: -32601 at protocol.ts:1054 and 1061, and -32022 at protocol.ts:1027. Before this push a missing handler made the method _OTHER with no name. [also at: packages/core-internal/src/shared/openTelemetry.ts:251 - A peer can again add unlimited gen_ai.tool.name series to mcp.server.operation.duration on any instance with no tools/call handler, such as a resources-only McpServer or a Client.]

Why this was flagged

A low-level Server that serves only resources, or an McpServer with no tool registered and no tools capability (packages/server/src/server/mcp.ts:161 installs the handlers only then), has no tools/call handler, and the app has a meter provider. A peer sends tools/call with a fresh random params.name each time. _onrequest passes this._requestHandlers.has(request.method) || isSpecRequestMethod(request.method) (packages/core-internal/src/shared/protocol.ts:995), so knownMethod is true and receive sets target (openTelemetry.ts:176) and gen_ai.tool.name (openTelemetry.ts:230). recognisedTarget returns true for any error code other than -32602, so the scrub at openTelemetry.ts:142-146 is skipped. The span is named tools/call <random> and each name is a new histogram series. Before this push handled was false here, giving _OTHER and no target; the base branch records nothing. test/integration/test/otel/cardinality.test.ts covers only the fallback and cancel paths.

Verification: packages/core-internal/src/shared/openTelemetry.ts:249-251: recognisedTarget returns outcome.type === 'error' && outcome.error.code !== INVALID_PARAMS, so -32601 counts as recognised. The scrub at :142-146 is skipped and the histogram records with the name at :148. The base branch emits no telemetry.

Finding ID: f-ed8031d6

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant