Repository navigation
feat: built-in OpenTelemetry tracing and metrics - #3115
mattzcarey wants to merge 5 commits into
Conversation
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 detectedLatest commit: 28b2264 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
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 |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
…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.
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.
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).
There was a problem hiding this comment.
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.
- 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.
| "peerDependencies": { | ||
| "@opentelemetry/api": "catalog:otel" |
There was a problem hiding this comment.
🔴 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
| function recognisedTarget(outcome: OperationOutcome): boolean { | ||
| return outcome.type === 'result' || (outcome.type === 'error' && outcome.error.code !== INVALID_PARAMS); |
There was a problem hiding this comment.
🔴 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
Client,ServerandMcpServernow emit OpenTelemetry spans and duration metrics for every MCP request and notification, following the MCP semantic conventions, and carry trace context inparams._meta(SEP-414). There is nothing to configure: whatever OpenTelemetry setup the app already has picks MCP up. Implements #1264.On Cloudflare Workers,
registerOpenTelemetry()fromagents/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:and turn traces on in
wrangler.jsonc:@opentelemetry/apiis a no-op until an app registers an SDK onglobalThis, 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 ofclientandserver, so the SDK shares the app's copy; a second copy would silently ignore the app's SDK. There is no new public API.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.