From d317d975db73a0d0692ba8af5f2aca64c128dc25 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 18:36:42 +0000 Subject: [PATCH 1/6] feat: add an experimental instrumentation option to Protocol 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/protocol-instrumentation.md | 7 + .../core-internal/src/exports/public/index.ts | 10 + .../src/shared/instrumentation.ts | 196 ++++++++++ packages/core-internal/src/shared/protocol.ts | 210 +++++++++-- .../test/shared/instrumentation.test.ts | 350 ++++++++++++++++++ test/e2e/requirements.ts | 5 + test/e2e/scenarios/protocol.test.ts | 42 +++ test/integration/test/instrumentation.test.ts | 148 ++++++++ 8 files changed, 930 insertions(+), 38 deletions(-) create mode 100644 .changeset/protocol-instrumentation.md create mode 100644 packages/core-internal/src/shared/instrumentation.ts create mode 100644 packages/core-internal/test/shared/instrumentation.test.ts create mode 100644 test/integration/test/instrumentation.test.ts diff --git a/.changeset/protocol-instrumentation.md b/.changeset/protocol-instrumentation.md new file mode 100644 index 0000000000..a1c354a806 --- /dev/null +++ b/.changeset/protocol-instrumentation.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core-internal': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Add an experimental `instrumentation` option to `Client`, `Server` and `McpServer` for tracing and metrics. The SDK calls its `send` hook for every request and notification it sends and its `receive` hook for every one it receives, and ends the returned scope once with how the message settled. Trace-context keys the hook writes go into `params._meta`, and the handler or transport send runs inside the scope. Leaving the option out changes nothing on the wire, and a hook that throws never fails the operation. diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index bf985d4160..5087c665f2 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -45,6 +45,16 @@ export { isJsonContentType } from '../../shared/mediaType'; // Metadata utilities export { getDisplayName } from '../../shared/metadataUtils'; +// Instrumentation types (experimental) +export type { + InboundOperation, + OperationBase, + OperationOutcome, + OperationScope, + OutboundOperation, + ProtocolInstrumentation +} from '../../shared/instrumentation'; + // Protocol types and the Protocol base class export type { BaseContext, diff --git a/packages/core-internal/src/shared/instrumentation.ts b/packages/core-internal/src/shared/instrumentation.ts new file mode 100644 index 0000000000..49e8bba028 --- /dev/null +++ b/packages/core-internal/src/shared/instrumentation.ts @@ -0,0 +1,196 @@ +import { SdkError, SdkErrorCode } from '../errors/sdkErrors'; +import type { RequestId, Result } from '../types/index'; +import { BAGGAGE_META_KEY, ProtocolError, TRACEPARENT_META_KEY, TRACESTATE_META_KEY } from '../types/index'; + +/** + * Observes the requests and notifications a {@linkcode Protocol} instance sends + * and receives, for tracing and metrics. Pass it as + * {@linkcode ProtocolOptions.instrumentation}. + * + * The SDK calls `send` or `receive` once per JSON-RPC request or notification + * (never for responses) and calls `end` on the returned scope exactly once when + * that message settles. An instrumentation that throws never fails the + * operation: the error goes to `onerror` and the operation carries on. + * + * @experimental The shape may change in a minor release. + */ +export interface ProtocolInstrumentation { + /** A request or notification is about to be sent. Trace-context keys written to `operation.carrier` go into `params._meta`. */ + send?(operation: OutboundOperation): OperationScope | undefined; + /** A request or notification arrived and is about to be handled. */ + receive?(operation: InboundOperation): OperationScope | undefined; +} + +/** + * Fields shared by {@linkcode OutboundOperation} and {@linkcode InboundOperation}. + * + * @experimental + */ +export interface OperationBase { + readonly method: string; + /** The JSON-RPC request id; absent for notifications. */ + readonly id?: RequestId; + /** The params as they go on, or came off, the wire. Do not mutate. */ + readonly params?: Readonly>; + /** The transport's session id, when it has one. */ + readonly sessionId?: string; + /** The negotiated protocol version, once there is one. */ + readonly protocolVersion?: string; +} + +/** + * A request or notification this instance is sending. + * + * @experimental + */ +export interface OutboundOperation extends OperationBase { + /** + * Keys written here are copied into the outgoing `params._meta`. Prefilled + * with any `traceparent`, `tracestate` and `baggage` the caller already set. + */ + readonly carrier: Record; +} + +/** + * A request or notification this instance received. + * + * @experimental + */ +export interface InboundOperation extends OperationBase { + /** The message's `params._meta`, including `traceparent`, `tracestate` and `baggage` when present. */ + readonly meta?: Readonly>; + /** Whether a handler (or fallback handler) is registered for `method`. */ + readonly handled: boolean; + /** The HTTP request the message arrived in, when the transport supplied one. */ + readonly httpRequest?: Request; +} + +/** + * The lifetime of one observed operation. + * + * @experimental + */ +export interface OperationScope { + /** Runs the operation's work (the transport send, or the handler) in the instrumentation's context. Must call `fn` once. */ + run(fn: () => T): T; + /** Called exactly once when the operation settles. */ + end(outcome: OperationOutcome): void; +} + +/** + * How an observed operation settled. + * + * - `result`: a request answered with a result, as it went on or came off the wire. + * - `error`: a request answered with a JSON-RPC error; `cause` is what the local handler threw, if anything. + * - `delivered`: a notification was written to the transport, or its handler returned. + * - `timeout`: the request timed out locally. + * - `cancelled`: the request was cancelled, by the caller's signal or by the peer. + * - `closed`: the connection closed before the operation settled. + * - `failed`: any other local failure, such as a failed send or a result that fails validation. + * + * @experimental + */ +export type OperationOutcome = + | { type: 'result'; result: Result } + | { type: 'error'; error: { code: number; message: string }; cause?: unknown } + | { type: 'delivered' } + | { type: 'timeout' } + | { type: 'cancelled' } + | { type: 'closed' } + | { type: 'failed'; error: unknown }; + +/** An {@linkcode OperationScope} whose methods never throw and whose `end` runs at most once. */ +export interface GuardedScope { + run(fn: () => T): T; + end(outcome: OperationOutcome): void; +} + +const TRACE_CONTEXT_META_KEYS = [TRACEPARENT_META_KEY, TRACESTATE_META_KEY, BAGGAGE_META_KEY] as const; + +/** A carrier prefilled with the string trace-context keys found in `meta`. */ +export function traceContextCarrier(meta: unknown): Record { + const carrier: Record = {}; + if (meta === null || typeof meta !== 'object') return carrier; + for (const key of TRACE_CONTEXT_META_KEYS) { + const value = (meta as Record)[key]; + if (typeof value === 'string') carrier[key] = value; + } + return carrier; +} + +/** Calls `start` and guards the scope it returns; `report` receives anything the instrumentation throws. */ +export function startScope(start: () => OperationScope | undefined, report: (error: Error) => void): GuardedScope | undefined { + let scope: OperationScope | undefined; + try { + scope = start(); + } catch (error) { + report(asInstrumentationError(error)); + return undefined; + } + return scope === undefined ? undefined : guardScope(scope, report); +} + +/** Runs `fn` in `scope` when there is one. */ +export function runInScope(scope: GuardedScope | undefined, fn: () => T): T { + return scope === undefined ? fn() : scope.run(fn); +} + +/** The outcome for an error that settled an outbound request. */ +export function outcomeForError(error: unknown): OperationOutcome { + if (error instanceof ProtocolError) return { type: 'error', error: { code: error.code, message: error.message }, cause: error }; + if (error instanceof SdkError) { + if (error.code === SdkErrorCode.ConnectionClosed) return { type: 'closed' }; + if (error.code === SdkErrorCode.RequestTimeout) return { type: 'timeout' }; + } + return { type: 'failed', error }; +} + +/** The outcome for an inbound request whose handler was aborted. */ +export function outcomeForAbort(reason: unknown): OperationOutcome { + return reason instanceof SdkError && reason.code === SdkErrorCode.ConnectionClosed ? { type: 'closed' } : { type: 'cancelled' }; +} + +function guardScope(scope: OperationScope, report: (error: Error) => void): GuardedScope { + let settled = false; + return { + run(fn: () => T): T { + let state = 'pending' as 'pending' | 'ok' | 'threw'; + let value: T | undefined; + let thrown: unknown; + let runError: unknown; + try { + scope.run(() => { + try { + value = fn(); + state = 'ok'; + return value; + } catch (error) { + state = 'threw'; + thrown = error; + throw error; + } + }); + } catch (error) { + if (state !== 'threw') runError = error; + } + if (runError !== undefined) report(asInstrumentationError(runError)); + if (state === 'ok') return value as T; + if (state === 'threw') throw thrown; + if (runError === undefined) report(new Error('Instrumentation failed: scope.run() did not call fn')); + return fn(); + }, + end(outcome: OperationOutcome): void { + if (settled) return; + settled = true; + try { + scope.end(outcome); + } catch (error) { + report(asInstrumentationError(error)); + } + } + }; +} + +function asInstrumentationError(error: unknown): Error { + return new Error(`Instrumentation failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); +} diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 9d60be8efe..20d53926a0 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -59,6 +59,8 @@ import { MODERN_WIRE_REVISION } from '../wire/codec'; import { manualInputRequiredValue, partitionInputResponses } from './inputRequiredEngine'; +import type { GuardedScope, ProtocolInstrumentation } from './instrumentation'; +import { outcomeForAbort, outcomeForError, runInScope, startScope, traceContextCarrier } from './instrumentation'; import type { Transport, TransportSendOptions } from './transport'; /** @@ -95,6 +97,14 @@ export type ProtocolOptions = { * e.g., `['notifications/tools/list_changed']` */ debouncedNotificationMethods?: string[]; + + /** + * Observes every request and notification this instance sends and receives, for tracing and + * metrics. Leaving it out costs nothing and leaves the wire unchanged. + * + * @experimental The shape may change in a minor release. + */ + instrumentation?: ProtocolInstrumentation; }; /** @@ -929,10 +939,18 @@ export abstract class Protocol { return; } + const scope = this._startInbound(rawNotification, true, extra); + // Starting with Promise.resolve() puts any synchronous errors into the monad as well. - Promise.resolve() - .then(() => (handler === undefined ? fallback!(notification) : handler(notification, codec))) - .catch(error => this._onerror(new Error(`Uncaught error in notification handler: ${error}`))); + runInScope(scope, () => + Promise.resolve().then(() => (handler === undefined ? fallback!(notification) : handler(notification, codec))) + ).then( + () => scope?.end({ type: 'delivered' }), + error => { + scope?.end({ type: 'failed', error }); + this._onerror(new Error(`Uncaught error in notification handler: ${error}`)); + } + ); } private _onrequest(rawRequest: JSONRPCRequest, extra?: MessageExtraInfo): void { @@ -961,7 +979,14 @@ export abstract class Protocol { // Capture the current transport at request time to ensure responses go to the correct client const capturedTransport = this._transport; + const scope = this._startInbound( + rawRequest, + this._requestHandlers.has(request.method) || this.fallbackRequestHandler !== undefined, + extra + ); + const sendErrorResponse = (code: number, message: string, data?: unknown) => { + scope?.end({ type: 'error', error: { code, message } }); const errorResponse: JSONRPCErrorResponse = { jsonrpc: '2.0', id: request.id, @@ -1110,9 +1135,15 @@ export abstract class Protocol { }; const ctx = this.buildContext(baseCtx, extra); + if (scope !== undefined) { + abortController.signal.addEventListener('abort', () => scope.end(outcomeForAbort(abortController.signal.reason)), { + once: true + }); + } + // Starting with Promise.resolve() puts any synchronous errors into the monad as well. - Promise.resolve() - .then(() => handler(request, ctx)) + // Only the handler runs in the instrumentation's context; encoding and sending do not need it. + runInScope(scope, () => Promise.resolve().then(() => handler(request, ctx))) .then( async result => { if (abortController.signal.aborted) { @@ -1137,6 +1168,7 @@ export abstract class Protocol { return; } + scope?.end({ type: 'result', result: encoded }); const response: JSONRPCResponse = { result: encoded, jsonrpc: '2.0', @@ -1164,6 +1196,11 @@ export abstract class Protocol { ...(error['data'] !== undefined && { data: error['data'] }) } }; + scope?.end({ + type: 'error', + error: { code: errorResponse.error.code, message: errorResponse.error.message }, + cause: error + }); await capturedTransport?.send(errorResponse); } ) @@ -1458,15 +1495,16 @@ export abstract class Protocol { // both share the same `_meta`): a no-op on the legacy era — the // envelope seam returns undefined and the request goes out exactly as // built above. - const outbound = this._envelopeOutbound(jsonrpcRequest); + const { message: outbound, scope } = this._startOutbound(this._envelopeOutbound(jsonrpcRequest)); let responseReceived = false; - const cancel = (reason: unknown) => { + const cancel = (reason: unknown, outcome: 'timeout' | 'cancelled') => { if (responseReceived) { return; } this._progressHandlers.delete(messageId); + scope?.end({ type: outcome }); if (requestAbort === undefined) { // "A client MUST NOT attempt to cancel its `initialize` @@ -1478,19 +1516,17 @@ export abstract class Protocol { // legacy era can reach this — `initialize` is absent from the // modern registry, which negotiates via `server/discover`. if (request.method !== 'initialize') { - this._transport - ?.send( - this._envelopeOutbound({ - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { - requestId: messageId, - reason: String(reason) - } - }), - { relatedRequestId, resumptionToken, onresumptiontoken } - ) - .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); + this._sendNotificationMessage( + this._envelopeOutbound({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: messageId, + reason: String(reason) + } + }), + { relatedRequestId, resumptionToken, onresumptiontoken } + ).catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); } } else { // Modern-era per-request-stream transport: aborting the @@ -1513,6 +1549,7 @@ export abstract class Protocol { responseReceived = true; if (response instanceof Error) { + scope?.end(outcomeForError(response)); return reject(response); } @@ -1534,12 +1571,16 @@ export abstract class Protocol { try { decoded = codec.decodeResult(request.method, response.result); } catch (error) { - return reject(error instanceof Error ? error : new Error(String(error))); + const decodeError = error instanceof Error ? error : new Error(String(error)); + scope?.end({ type: 'failed', error: decodeError }); + return reject(decodeError); } if (decoded.kind === 'invalid') { + scope?.end({ type: 'failed', error: decoded.error }); return reject(decoded.error); } if (decoded.kind === 'input_required') { + scope?.end({ type: 'result', result: response.result }); // Manual mode (the primitive any driver layers over): // hand the input-required value back to the caller. if (options?.allowInputRequired === true) { @@ -1563,33 +1604,59 @@ export abstract class Protocol { legOptions ) }; - return resolve(this._resolveNonCompleteResult(decoded, flow) as Promise>); + // Embedded input requests are fulfilled in this leg's context, so their spans nest under it. + const inputRequired = decoded; + return resolve( + runInScope(scope, () => this._resolveNonCompleteResult(inputRequired, flow)) as Promise< + StandardSchemaV1.InferOutput + > + ); } const result = decoded.result; - validateStandardSchema(resultSchema, result).then(parseResult => { - if (parseResult.success) { - resolve(parseResult.data); - } else { - reject(new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${request.method}: ${parseResult.error}`)); + validateStandardSchema(resultSchema, result).then( + parseResult => { + if (parseResult.success) { + scope?.end({ type: 'result', result: response.result }); + resolve(parseResult.data); + } else { + const error = new SdkError( + SdkErrorCode.InvalidResult, + `Invalid result for ${request.method}: ${parseResult.error}` + ); + scope?.end({ type: 'failed', error }); + reject(error); + } + }, + error => { + scope?.end({ type: 'failed', error }); + reject(error); } - }, reject); + ); }); - onAbort = () => cancel(options?.signal?.reason); + onAbort = () => cancel(options?.signal?.reason, 'cancelled'); options?.signal?.addEventListener('abort', onAbort, { once: true }); const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; - const timeoutHandler = () => cancel(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout })); + const timeoutHandler = () => cancel(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout }), 'timeout'); this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport - .send(outbound, { relatedRequestId, resumptionToken, onresumptiontoken, headers, requestSignal: requestAbort?.signal }) - .catch(error => { - this._progressHandlers.delete(messageId); - reject(error); - }); + const transport = this._transport; + runInScope(scope, () => + transport.send(outbound, { + relatedRequestId, + resumptionToken, + onresumptiontoken, + headers, + requestSignal: requestAbort?.signal + }) + ).catch(error => { + this._progressHandlers.delete(messageId); + scope?.end({ type: 'failed', error }); + reject(error); + }); }).finally(() => { // Per-request cleanup that must run on every exit path. Consolidated // here so new exit paths added to the promise body can't forget it. @@ -1667,14 +1734,81 @@ export abstract class Protocol { // Send the notification, but don't await it here to avoid blocking. // Handle potential errors with a .catch(). - this._transport?.send(jsonrpcNotification, options).catch(error => this._onerror(error)); + this._sendNotificationMessage(jsonrpcNotification, options).catch(error => this._onerror(error)); }); // Return immediately. return; } - await this._transport.send(jsonrpcNotification, options); + await this._sendNotificationMessage(jsonrpcNotification, options); + } + + /** Writes an outbound notification to the transport, observed by the instrumentation when one is configured. */ + private async _sendNotificationMessage(message: JSONRPCNotification, options?: TransportSendOptions): Promise { + const transport = this._transport; + if (!transport) return; + const { message: outbound, scope } = this._startOutbound(message); + if (scope === undefined) return transport.send(outbound, options); + try { + await scope.run(() => transport.send(outbound, options)); + } catch (error) { + scope.end({ type: 'failed', error }); + throw error; + } + scope.end({ type: 'delivered' }); + } + + /** Starts the instrumentation scope for an outbound message and merges its trace-context carrier into `params._meta`. */ + private _startOutbound(message: T): { message: T; scope: GuardedScope | undefined } { + const instrumentation = this._options?.instrumentation; + if (instrumentation?.send === undefined) return { message, scope: undefined }; + const params = message.params; + const carrier = traceContextCarrier(params?._meta); + const scope = startScope( + () => + instrumentation.send?.({ + method: message.method, + ...('id' in message && { id: message.id }), + ...(params !== undefined && { params }), + ...this._operationSession(), + carrier + }), + error => this._onerror(error) + ); + if (Object.keys(carrier).length === 0) return { message, scope }; + return { message: { ...message, params: { ...params, _meta: { ...params?._meta, ...carrier } } }, scope }; + } + + /** Starts the instrumentation scope for an inbound request or notification. */ + private _startInbound( + message: JSONRPCRequest | JSONRPCNotification, + handled: boolean, + extra?: MessageExtraInfo + ): GuardedScope | undefined { + const instrumentation = this._options?.instrumentation; + if (instrumentation?.receive === undefined) return undefined; + const params = message.params; + const meta = params?._meta; + return startScope( + () => + instrumentation.receive?.({ + method: message.method, + ...('id' in message && { id: message.id }), + ...(params !== undefined && { params }), + ...(isPlainObject(meta) && { meta }), + ...this._operationSession(), + handled, + ...(extra?.request !== undefined && { httpRequest: extra.request }) + }), + error => this._onerror(error) + ); + } + + private _operationSession(): { sessionId?: string; protocolVersion?: string } { + const sessionId = this._transport?.sessionId; + const protocolVersion = this._negotiatedProtocolVersion; + return { ...(sessionId !== undefined && { sessionId }), ...(protocolVersion !== undefined && { protocolVersion }) }; } /** diff --git a/packages/core-internal/test/shared/instrumentation.test.ts b/packages/core-internal/test/shared/instrumentation.test.ts new file mode 100644 index 0000000000..f3ba6f570d --- /dev/null +++ b/packages/core-internal/test/shared/instrumentation.test.ts @@ -0,0 +1,350 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; + +import type { + BaseContext, + InboundOperation, + JSONRPCMessage, + OperationOutcome, + OutboundOperation, + ProtocolInstrumentation +} from '../../src/exports/public/index'; +import { isJSONRPCRequest, ProtocolError, ProtocolErrorCode, TRACEPARENT_META_KEY } from '../../src/exports/public/index'; +import type { ProtocolOptions } from '../../src/shared/protocol'; +import { Protocol } from '../../src/shared/protocol'; +import { InMemoryTransport } from '../../src/util/inMemory'; + +class TestProtocol extends Protocol { + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} +} + +type Sent = { direction: 'send'; operation: OutboundOperation; outcomes: OperationOutcome[] }; +type Received = { direction: 'receive'; operation: InboundOperation; outcomes: OperationOutcome[] }; +type Recorded = Sent | Received; + +const TRACEPARENT = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; +const INJECTED = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + +/** Records every scope, injects `INJECTED`, and runs work inside `als` so tests can see the active scope. */ +function recorder(als = new AsyncLocalStorage()) { + const scopes: Recorded[] = []; + const instrumentation: ProtocolInstrumentation = { + send(operation) { + const entry: Sent = { direction: 'send', operation, outcomes: [] }; + scopes.push(entry); + operation.carrier[TRACEPARENT_META_KEY] = INJECTED; + return { run: fn => als.run(`send ${operation.method}`, fn), end: outcome => entry.outcomes.push(outcome) }; + }, + receive(operation) { + const entry: Received = { direction: 'receive', operation, outcomes: [] }; + scopes.push(entry); + return { run: fn => als.run(`receive ${operation.method}`, fn), end: outcome => entry.outcomes.push(outcome) }; + } + }; + function find(direction: 'send', method: string): Sent | undefined; + function find(direction: 'receive', method: string): Received | undefined; + function find(direction: Recorded['direction'], method: string): Recorded | undefined { + return scopes.find(s => s.direction === direction && s.operation.method === method); + } + return { scopes, instrumentation, als, find }; +} + +async function pair(options?: { a?: ProtocolOptions; b?: ProtocolOptions }) { + const [ta, tb] = InMemoryTransport.createLinkedPair(); + const a = new TestProtocol(options?.a); + const b = new TestProtocol(options?.b); + await a.connect(ta); + await b.connect(tb); + return { a, b, ta, tb }; +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); +const Ok = z.object({ ok: z.boolean() }); + +describe('ProtocolOptions.instrumentation', () => { + it('leaves outbound messages untouched when no instrumentation is configured', async () => { + const [ta, tb] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + tb.onmessage = message => { + sent.push(message); + if (isJSONRPCRequest(message)) void tb.send({ jsonrpc: '2.0', id: message.id, result: { ok: true } }); + }; + await tb.start(); + const a = new TestProtocol(); + await a.connect(ta); + + await a.request({ method: 'acme/echo', params: { v: 1 } }, Ok); + await a.notification({ method: 'acme/event', params: { v: 2 } }); + + expect(sent).toEqual([ + { jsonrpc: '2.0', id: 0, method: 'acme/echo', params: { v: 1 } }, + { jsonrpc: '2.0', method: 'acme/event', params: { v: 2 } } + ]); + }); + + it('observes a request on both sides and carries the injected trace context to the peer', async () => { + const client = recorder(); + const server = recorder(client.als); + const { a, b } = await pair({ a: { instrumentation: client.instrumentation }, b: { instrumentation: server.instrumentation } }); + let activeInHandler: string | undefined; + let metaInHandler: unknown; + b.setRequestHandler('acme/echo', { params: z.object({ v: z.number() }) }, async (_params, ctx) => { + await tick(); + activeInHandler = server.als.getStore(); + metaInHandler = ctx.mcpReq._meta; + return { ok: true }; + }); + + await a.request({ method: 'acme/echo', params: { v: 1 } }, Ok); + + const sent = client.find('send', 'acme/echo')!; + expect(sent.operation).toMatchObject({ method: 'acme/echo', id: 0, params: { v: 1 } }); + expect(sent.outcomes).toEqual([{ type: 'result', result: { ok: true } }]); + + const received = server.find('receive', 'acme/echo')!; + expect(received.operation).toMatchObject({ method: 'acme/echo', id: 0, handled: true, meta: { traceparent: INJECTED } }); + expect(received.outcomes).toEqual([{ type: 'result', result: { ok: true } }]); + expect(metaInHandler).toEqual({ traceparent: INJECTED }); + expect(activeInHandler).toBe('receive acme/echo'); + }); + + it('prefills the carrier with caller-supplied trace context without mutating the caller params', async () => { + const client = recorder(); + const { a, b } = await pair({ a: { instrumentation: client.instrumentation } }); + let metaInHandler: unknown; + b.setRequestHandler('acme/echo', { params: z.object({}) }, (_params, ctx) => { + metaInHandler = ctx.mcpReq._meta; + return { ok: true }; + }); + const params = { _meta: { [TRACEPARENT_META_KEY]: TRACEPARENT, other: 'kept' } }; + let prefilled: Record | undefined; + const original = client.instrumentation.send!; + client.instrumentation.send = operation => { + prefilled = { ...operation.carrier }; + return original(operation); + }; + + await a.request({ method: 'acme/echo', params }, Ok); + + expect(prefilled).toEqual({ traceparent: TRACEPARENT }); + expect(metaInHandler).toEqual({ traceparent: INJECTED, other: 'kept' }); + expect(params._meta.traceparent).toBe(TRACEPARENT); + }); + + it('runs the transport send in the outbound scope', async () => { + const client = recorder(); + const { a, b, ta } = await pair({ a: { instrumentation: client.instrumentation } }); + b.setRequestHandler('acme/echo', { params: z.object({}) }, () => ({ ok: true })); + const activeAtSend: (string | undefined)[] = []; + const send = ta.send.bind(ta); + ta.send = (message, options) => { + activeAtSend.push(client.als.getStore()); + return send(message, options); + }; + + await a.request({ method: 'acme/echo', params: {} }, Ok); + await a.notification({ method: 'acme/event' }); + + expect(activeAtSend).toEqual(['send acme/echo', 'send acme/event']); + }); + + it('ends both sides with the JSON-RPC error a handler throws', async () => { + const client = recorder(); + const server = recorder(); + const { a, b } = await pair({ a: { instrumentation: client.instrumentation }, b: { instrumentation: server.instrumentation } }); + const thrown = new ProtocolError(ProtocolErrorCode.InvalidParams, 'bad input'); + b.setRequestHandler('acme/fail', { params: z.object({}) }, () => { + throw thrown; + }); + + await expect(a.request({ method: 'acme/fail', params: {} }, Ok)).rejects.toThrow('bad input'); + + expect(server.find('receive', 'acme/fail')!.outcomes).toEqual([ + { type: 'error', error: { code: ProtocolErrorCode.InvalidParams, message: 'bad input' }, cause: thrown } + ]); + expect(client.find('send', 'acme/fail')!.outcomes).toMatchObject([ + { type: 'error', error: { code: ProtocolErrorCode.InvalidParams, message: 'bad input' } } + ]); + }); + + it('reports an unhandled method with handled: false and a method-not-found error', async () => { + const server = recorder(); + const { a } = await pair({ b: { instrumentation: server.instrumentation } }); + + await expect(a.request({ method: 'acme/missing', params: {} }, Ok)).rejects.toThrow(); + + const received = server.find('receive', 'acme/missing')!; + expect(received.operation.handled).toBe(false); + expect(received.outcomes).toEqual([ + { type: 'error', error: { code: ProtocolErrorCode.MethodNotFound, message: 'Method not found' } } + ]); + }); + + it('ends a timed-out request with timeout and observes the cancellation notification', async () => { + const client = recorder(); + const server = recorder(); + const { a, b } = await pair({ a: { instrumentation: client.instrumentation }, b: { instrumentation: server.instrumentation } }); + b.setRequestHandler( + 'acme/slow', + { params: z.object({}) }, + (_params, ctx) => + new Promise<{ ok: boolean }>(resolve => ctx.mcpReq.signal.addEventListener('abort', () => resolve({ ok: true }))) + ); + + await expect(a.request({ method: 'acme/slow', params: {} }, Ok, { timeout: 10 })).rejects.toThrow(); + await tick(); + + expect(client.find('send', 'acme/slow')!.outcomes).toEqual([{ type: 'timeout' }]); + expect(client.find('send', 'notifications/cancelled')!.outcomes).toEqual([{ type: 'delivered' }]); + expect(server.find('receive', 'acme/slow')!.outcomes).toEqual([{ type: 'cancelled' }]); + }); + + it('ends a request aborted by the caller with cancelled', async () => { + const client = recorder(); + const { a, b } = await pair({ a: { instrumentation: client.instrumentation } }); + b.setRequestHandler('acme/slow', { params: z.object({}) }, () => new Promise(() => {})); + const controller = new AbortController(); + + const pending = a.request({ method: 'acme/slow', params: {} }, Ok, { signal: controller.signal }); + controller.abort('changed my mind'); + await expect(pending).rejects.toThrow(); + + expect(client.find('send', 'acme/slow')!.outcomes).toEqual([{ type: 'cancelled' }]); + }); + + it('ends in-flight operations on both sides with closed when the connection drops', async () => { + const client = recorder(); + const server = recorder(); + const { a, b } = await pair({ a: { instrumentation: client.instrumentation }, b: { instrumentation: server.instrumentation } }); + let started!: () => void; + const handlerStarted = new Promise(resolve => (started = resolve)); + b.setRequestHandler('acme/slow', { params: z.object({}) }, () => { + started(); + return new Promise(() => {}); + }); + + const pending = a.request({ method: 'acme/slow', params: {} }, Ok); + await handlerStarted; + await a.close(); + await expect(pending).rejects.toThrow('Connection closed'); + + expect(client.find('send', 'acme/slow')!.outcomes).toEqual([{ type: 'closed' }]); + expect(server.find('receive', 'acme/slow')!.outcomes).toEqual([{ type: 'closed' }]); + }); + + it('ends a result that fails validation with failed', async () => { + const client = recorder(); + const { a, b } = await pair({ a: { instrumentation: client.instrumentation } }); + b.setRequestHandler('acme/echo', { params: z.object({}) }, () => ({ ok: 'not a boolean' })); + + await expect(a.request({ method: 'acme/echo', params: {} }, Ok)).rejects.toThrow('Invalid result'); + + expect(client.find('send', 'acme/echo')!.outcomes).toMatchObject([{ type: 'failed' }]); + }); + + it('ends a failed send with failed', async () => { + const client = recorder(); + const { a, ta } = await pair({ a: { instrumentation: client.instrumentation } }); + const failure = new Error('socket gone'); + ta.send = () => Promise.reject(failure); + + await expect(a.request({ method: 'acme/echo', params: {} }, Ok)).rejects.toBe(failure); + await expect(a.notification({ method: 'acme/event' })).rejects.toBe(failure); + + expect(client.find('send', 'acme/echo')!.outcomes).toEqual([{ type: 'failed', error: failure }]); + expect(client.find('send', 'acme/event')!.outcomes).toEqual([{ type: 'failed', error: failure }]); + }); + + it('observes notifications on both sides, including a handler that throws', async () => { + const client = recorder(); + const server = recorder(); + const { a, b } = await pair({ a: { instrumentation: client.instrumentation }, b: { instrumentation: server.instrumentation } }); + const errors: Error[] = []; + b.onerror = error => errors.push(error); + let activeInHandler: string | undefined; + b.setNotificationHandler('acme/event', { params: z.object({}) }, () => { + activeInHandler = server.als.getStore(); + }); + const thrown = new Error('handler broke'); + b.setNotificationHandler('acme/broken', { params: z.object({}) }, () => { + throw thrown; + }); + + await a.notification({ method: 'acme/event', params: {} }); + await a.notification({ method: 'acme/broken', params: {} }); + await tick(); + + expect(client.find('send', 'acme/event')!.outcomes).toEqual([{ type: 'delivered' }]); + const received = server.find('receive', 'acme/event')!; + expect(received.operation).toMatchObject({ method: 'acme/event', handled: true, meta: { traceparent: INJECTED } }); + expect(received.operation.id).toBeUndefined(); + expect(received.outcomes).toEqual([{ type: 'delivered' }]); + expect(activeInHandler).toBe('receive acme/event'); + expect(server.find('receive', 'acme/broken')!.outcomes).toEqual([{ type: 'failed', error: thrown }]); + expect(errors.map(e => e.message)).toEqual(['Uncaught error in notification handler: Error: handler broke']); + }); + + it('never lets a throwing instrumentation change the outcome', async () => { + const errors: Error[] = []; + const { a, b } = await pair({ + a: { + instrumentation: { + send: () => { + throw new Error('send hook broke'); + } + } + }, + b: { + instrumentation: { + receive: () => ({ + run: () => { + throw new Error('run broke'); + }, + end: () => { + throw new Error('end broke'); + } + }) + } + } + }); + a.onerror = error => errors.push(error); + b.onerror = error => errors.push(error); + let calls = 0; + b.setRequestHandler('acme/echo', { params: z.object({}) }, () => { + calls++; + return { ok: true }; + }); + + await expect(a.request({ method: 'acme/echo', params: {} }, Ok)).resolves.toEqual({ ok: true }); + + expect(calls).toBe(1); + expect(errors.map(e => e.message)).toEqual([ + 'Instrumentation failed: send hook broke', + 'Instrumentation failed: run broke', + 'Instrumentation failed: end broke' + ]); + }); + + it('runs the work itself when scope.run never calls it', async () => { + const errors: Error[] = []; + const { a, b } = await pair({ b: { instrumentation: { receive: () => ({ run: () => undefined as never, end: () => {} }) } } }); + b.onerror = error => errors.push(error); + let calls = 0; + b.setRequestHandler('acme/echo', { params: z.object({}) }, () => { + calls++; + return { ok: true }; + }); + + await expect(a.request({ method: 'acme/echo', params: {} }, Ok)).resolves.toEqual({ ok: true }); + + expect(calls).toBe(1); + expect(errors.map(e => e.message)).toEqual(['Instrumentation failed: scope.run() did not call fn']); + }); +}); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 519d92acfa..4f0b9ed052 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1390,6 +1390,11 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta', behavior: "_meta sent in a request's params by the client is delivered intact to the server-side request handler." }, + 'protocol:meta:trace-context-instrumentation': { + source: 'https://modelcontextprotocol.io/community/seps/414-request-meta', + behavior: + "With instrumentation configured on both sides, the trace context the client's instrumentation writes for a request reaches the server's instrumentation and the request handler in params._meta, and the handler runs inside the scope the server's instrumentation opened for that request." + }, 'protocol:meta:result-to-client': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta', behavior: diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index 4abcde078d..f65265e8c5 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -6,6 +6,8 @@ * via the requirement's tier map. */ +import { AsyncLocalStorage } from 'node:async_hooks'; + import { Client } from '@modelcontextprotocol/client'; import type { CallToolRequest, @@ -16,6 +18,7 @@ import type { MessageExtraInfo, Notification, Progress, + ProtocolInstrumentation, RequestId, Result, Transport, @@ -1114,6 +1117,45 @@ verifies('protocol:meta:request-to-handler', async ({ transport }: TestArgs) => expect(handlerExtraMeta).toEqual([requestMeta]); }); +verifies('protocol:meta:trace-context-instrumentation', async ({ transport }: TestArgs) => { + const active = new AsyncLocalStorage(); + const serverSeen: { meta?: Readonly>; handlerMeta?: unknown; activeInHandler?: string }[] = []; + const serverInstrumentation: ProtocolInstrumentation = { + receive(operation) { + if (operation.method === 'tools/call') serverSeen.push({ meta: operation.meta }); + return { run: fn => active.run(`server ${operation.method}`, fn), end: () => {} }; + } + }; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }, { instrumentation: serverInstrumentation }); + s.registerTool('traced', { inputSchema: z.object({}) }, (_args, ctx) => { + serverSeen.at(-1)!.handlerMeta = ctx.mcpReq._meta?.traceparent; + serverSeen.at(-1)!.activeInHandler = active.getStore(); + return { content: [{ type: 'text', text: 'ok' }] }; + }); + return s; + }; + const traceparent = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + const client = new Client( + { name: 'c', version: '0' }, + { + instrumentation: { + send(operation) { + if (operation.method === 'tools/call') operation.carrier.traceparent = traceparent; + return { run: fn => fn(), end: () => {} }; + } + } + } + ); + await using _ = await wire(transport, makeServer, client); + + await client.callTool({ name: 'traced', arguments: {} }); + + expect(serverSeen).toEqual([ + { meta: expect.objectContaining({ traceparent }), handlerMeta: traceparent, activeInHandler: 'server tools/call' } + ]); +}); + verifies('protocol:meta:result-to-client', async ({ transport }: TestArgs) => { const resultMeta = { 'example.com/cost-tokens': 42, 'example.com/cache': { hit: true, region: 'eu-west-1' } }; const makeServer = () => { diff --git a/test/integration/test/instrumentation.test.ts b/test/integration/test/instrumentation.test.ts new file mode 100644 index 0000000000..4109cb31ec --- /dev/null +++ b/test/integration/test/instrumentation.test.ts @@ -0,0 +1,148 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import type { OperationOutcome, ProtocolInstrumentation } from '@modelcontextprotocol/client'; +import { Client, InMemoryTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +interface Observed { + side: string; + direction: 'send' | 'receive'; + method: string; + params?: Readonly>; + meta?: Readonly>; + protocolVersion?: string; + httpRequest?: Request; + /** The scope active where the operation started. */ + parent?: string; + outcomes: OperationOutcome[]; +} + +/** A minimal tracer: every operation gets an id, propagated through `traceparent` and made active with `AsyncLocalStorage`. */ +function tracer(side: string, als: AsyncLocalStorage, observed: Observed[]): ProtocolInstrumentation { + let next = 0; + return { + send(operation) { + const id = `${side}:${next++}`; + const entry: Observed = { + side, + direction: 'send', + method: operation.method, + params: operation.params, + parent: als.getStore(), + outcomes: [] + }; + observed.push(entry); + operation.carrier.traceparent = id; + return { run: fn => als.run(id, fn), end: outcome => entry.outcomes.push(outcome) }; + }, + receive(operation) { + const id = `${side}:${next++}`; + const entry: Observed = { + side, + direction: 'receive', + method: operation.method, + params: operation.params, + meta: operation.meta, + protocolVersion: operation.protocolVersion, + httpRequest: operation.httpRequest, + parent: operation.meta?.traceparent as string | undefined, + outcomes: [] + }; + observed.push(entry); + return { run: fn => als.run(id, fn), end: outcome => entry.outcomes.push(outcome) }; + } + }; +} + +describe('instrumentation on Client and McpServer', () => { + const cleanups: Array<() => Promise | void> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + it('follows a tool call from the client into the tool and back out through a nested elicitation', async () => { + const als = new AsyncLocalStorage(); + const observed: Observed[] = []; + const server = new McpServer({ name: 'traced', version: '1.0.0' }, { instrumentation: tracer('server', als, observed) }); + let activeInTool: string | undefined; + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, async ({ name }, ctx) => { + activeInTool = als.getStore(); + const answer = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Greeting?', + requestedSchema: { type: 'object', properties: { word: { type: 'string' } } } + }); + return { content: [{ type: 'text', text: `${answer.content?.word} ${name}` }] }; + }); + const client = new Client( + { name: 'traced-client', version: '1.0.0' }, + { capabilities: { elicitation: {} }, instrumentation: tracer('client', als, observed) } + ); + let activeInElicitation: string | undefined; + client.setRequestHandler('elicitation/create', () => { + activeInElicitation = als.getStore(); + return { action: 'accept', content: { word: 'hello' } }; + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + cleanups.push(() => client.close()); + + const result = await als.run('agent', () => client.callTool({ name: 'greet', arguments: { name: 'Ada' } })); + expect(result.content).toEqual([{ type: 'text', text: 'hello Ada' }]); + + const clientCall = observed.find(o => o.side === 'client' && o.direction === 'send' && o.method === 'tools/call')!; + expect(clientCall).toMatchObject({ parent: 'agent', params: { name: 'greet', arguments: { name: 'Ada' } } }); + expect(clientCall.outcomes).toEqual([{ type: 'result', result: { content: [{ type: 'text', text: 'hello Ada' }] } }]); + + const serverCall = observed.find(o => o.side === 'server' && o.direction === 'receive' && o.method === 'tools/call')!; + const clientCallId = serverCall.parent!; + expect(clientCallId).toMatch(/^client:/); + expect(serverCall.outcomes).toHaveLength(1); + + const serverElicit = observed.find(o => o.side === 'server' && o.direction === 'send' && o.method === 'elicitation/create')!; + expect(serverElicit.parent).toBe(activeInTool); + const clientElicit = observed.find(o => o.side === 'client' && o.direction === 'receive' && o.method === 'elicitation/create')!; + expect(clientElicit.parent).toMatch(/^server:/); + expect(activeInElicitation).toMatch(/^client:/); + expect(clientElicit.outcomes).toEqual([{ type: 'result', result: { action: 'accept', content: { word: 'hello' } } }]); + }); + + it('observes each request served by createMcpHandler with one shared instrumentation', async () => { + const als = new AsyncLocalStorage(); + const observed: Observed[] = []; + const instrumentation = tracer('server', als, observed); + const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'traced-http', version: '1.0.0' }, { instrumentation }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name}` }] + })); + return server; + }); + const httpServer: HttpServer = createServer(toNodeHandler(handler)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await handler.close(); + httpServer.close(); + }); + const client = new Client( + { name: 'traced-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, instrumentation: tracer('client', als, observed) } + ); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); + + const serverCall = observed.find(o => o.side === 'server' && o.direction === 'receive' && o.method === 'tools/call')!; + expect(serverCall.protocolVersion).toBe('2026-07-28'); + expect(serverCall.httpRequest).toBeInstanceOf(Request); + expect(serverCall.parent).toMatch(/^client:/); + expect(serverCall.outcomes).toMatchObject([{ type: 'result' }]); + }); +}); From 8f56525318a772a7658a14582ff18d4db03d2d9e Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 18:58:48 +0000 Subject: [PATCH 2/6] feat(otel): add @modelcontextprotocol/otel on top of the instrumentation 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. --- .changeset/otel-package.md | 5 + .github/workflows/publish.yml | 2 +- README.md | 2 + docs/.vitepress/nav.ts | 3 +- docs/_meta/specs/otel.md | 562 ++++++++++++++++++ docs/advanced/opentelemetry.md | 160 +++++ docs/get-started/packages.md | 5 +- docs/servers/logging-progress-cancellation.md | 2 +- examples/README.md | 1 + .../guides/advanced/opentelemetry.examples.ts | 131 ++++ examples/opentelemetry/README.md | 19 + examples/opentelemetry/client.ts | 75 +++ examples/opentelemetry/package.json | 24 + examples/opentelemetry/server.ts | 72 +++ examples/package.json | 4 + examples/tsconfig.json | 1 + .../test/packageTopologyPins.test.ts | 1 + packages/otel/README.md | 46 ++ packages/otel/eslint.config.mjs | 12 + packages/otel/package.json | 82 +++ packages/otel/src/index.ts | 11 + packages/otel/src/openTelemetry.ts | 341 +++++++++++ packages/otel/src/types.ts | 52 ++ packages/otel/test/helpers.ts | 67 +++ packages/otel/test/openTelemetry.test.ts | 415 +++++++++++++ packages/otel/test/types.test.ts | 17 + packages/otel/tsconfig.json | 30 + packages/otel/tsdown.config.ts | 21 + packages/otel/typedoc.json | 10 + packages/otel/vitest.config.js | 3 + pnpm-lock.yaml | 227 ++++++- pnpm-workspace.yaml | 7 + 32 files changed, 2403 insertions(+), 7 deletions(-) create mode 100644 .changeset/otel-package.md create mode 100644 docs/_meta/specs/otel.md create mode 100644 docs/advanced/opentelemetry.md create mode 100644 examples/guides/advanced/opentelemetry.examples.ts create mode 100644 examples/opentelemetry/README.md create mode 100644 examples/opentelemetry/client.ts create mode 100644 examples/opentelemetry/package.json create mode 100644 examples/opentelemetry/server.ts create mode 100644 packages/otel/README.md create mode 100644 packages/otel/eslint.config.mjs create mode 100644 packages/otel/package.json create mode 100644 packages/otel/src/index.ts create mode 100644 packages/otel/src/openTelemetry.ts create mode 100644 packages/otel/src/types.ts create mode 100644 packages/otel/test/helpers.ts create mode 100644 packages/otel/test/openTelemetry.test.ts create mode 100644 packages/otel/test/types.test.ts create mode 100644 packages/otel/tsconfig.json create mode 100644 packages/otel/tsdown.config.ts create mode 100644 packages/otel/typedoc.json create mode 100644 packages/otel/vitest.config.js diff --git a/.changeset/otel-package.md b/.changeset/otel-package.md new file mode 100644 index 0000000000..94b38548cb --- /dev/null +++ b/.changeset/otel-package.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/otel': minor +--- + +New package: `@modelcontextprotocol/otel`. `openTelemetry()` returns an `instrumentation` for `Client`, `McpServer` and `Server` that emits spans and `mcp.client.operation.duration` / `mcp.server.operation.duration` histograms following the OpenTelemetry semantic conventions for MCP, and propagates W3C trace context and baggage through `params._meta`. Tool arguments, tool results and prompt arguments are recorded only with `captureContent: true`. Experimental while the MCP semantic conventions are in development; `@opentelemetry/api` is a peer dependency. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d8db820e13..5dba15e707 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -40,4 +40,4 @@ jobs: - name: Publish preview packages run: pnpm dlx pkg-pr-new publish --packageManager=npm --pnpm './packages/core' './packages/server' './packages/server-legacy' './packages/client' - './packages/codemod' './packages/middleware/express' './packages/middleware/fastify' './packages/middleware/hono' './packages/middleware/node' + './packages/codemod' './packages/middleware/express' './packages/middleware/fastify' './packages/middleware/hono' './packages/middleware/node' './packages/otel' diff --git a/README.md b/README.md index 52d9fdac90..4be32fb4cf 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ They are intentionally thin adapters: they should not introduce new MCP function - **`@modelcontextprotocol/fastify`**: Fastify helpers (app defaults + Host header validation) - **`@modelcontextprotocol/hono`**: Hono helpers (app defaults + JSON body parsing hook + Host header validation) +The SDK also publishes **`@modelcontextprotocol/otel`**, OpenTelemetry tracing and metrics for clients and servers (see [OpenTelemetry](docs/advanced/opentelemetry.md)). + ## Installation ### Server diff --git a/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index 9c07c8c0ed..af7798e9a8 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -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' }, diff --git a/docs/_meta/specs/otel.md b/docs/_meta/specs/otel.md new file mode 100644 index 0000000000..8b55725181 --- /dev/null +++ b/docs/_meta/specs/otel.md @@ -0,0 +1,562 @@ +# OpenTelemetry support: design spec + +- **Status:** draft for maintainer review. Implemented in [#3115](https://github.com/modelcontextprotocol/typescript-sdk/pull/3115), with the differences listed under [Implementation status](#implementation-status). +- **Tracking issue:** [#1264](https://github.com/modelcontextprotocol/typescript-sdk/issues/1264) ("OpenTelemetry integration out of the box", part of v2). +- **Related:** [#2370](https://github.com/modelcontextprotocol/typescript-sdk/pull/2370) (injectable `SdkLogger`), [#2820](https://github.com/modelcontextprotocol/typescript-sdk/pull/2820) (extensions and request middleware), [#2270](https://github.com/modelcontextprotocol/typescript-sdk/pull/2270) (SEP-414 `_meta` key constants), SEP-414, SEP-2577, SEP-2028. +- **Base:** `origin/main` at `b415e60b`. File and line references below are against that commit. + +This file lives under `docs/_meta/`, which `docs/.vitepress/config.mts` excludes from the published site. It is a design document, not a user page, so the docs register in `docs/_meta/CONVENTIONS.md` does not apply to it. + +## Implementation status + +#3115 implements this spec in one PR (the hook, `@modelcontextprotocol/otel` with spans, propagation, operation metrics and `captureContent`, the docs page and the `examples/opentelemetry` story). It differs in three places: + +- **No `connect` hook and no session metrics yet.** `Protocol` doesn't know whether it is the client or the server, and open question 7 is unresolved, so `ProtocolInstrumentation` has only `send` and `receive`. `mcp.client.session.duration` and `mcp.server.session.duration` come with the session hook. +- **Hook types are copied structurally** in `packages/otel/src/types.ts`, so the package depends on neither `client` nor `server`. `packages/otel/test/types.test.ts` checks both directions of assignability. This is one answer to open question 5. +- **No stacked PRs.** Everything is in #3115 and can be split along the [rollout](#rollout) lines if reviewers prefer. + +## Summary + +1. Add one experimental, dependency-free option to `ProtocolOptions`: `instrumentation?: ProtocolInstrumentation`. `Protocol` calls it at four points (outbound message, inbound message, connect, close). Nothing runs and nothing changes on the wire when the option is absent. +2. Ship the OpenTelemetry mapping as a new package, `@modelcontextprotocol/otel`, with `@opentelemetry/api` as a peer dependency. It implements the MCP semantic conventions (spans, metrics, `_meta` propagation) on top of that option. Users opt in per instance with `{ instrumentation: openTelemetry() }`. +3. Telemetry is opt-in, unlike the Python SDK. Turning it on by default would add `_meta` keys to existing traffic and duplicate the spans Sentry already creates for every v2 `McpServer`. +4. Do not build this on `use` middleware or on extensions from #2820. Middleware only sees inbound requests for one exact method, and an extension advertises a capability on the wire. +5. #2370 is unaffected. `SdkLogger` stays the only path for SDK diagnostics. The OTel package does not ship a logs bridge in its first release. + +## Background + +### What the SDK does today + +- `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY` and `BAGGAGE_META_KEY` are public constants (`packages/core/src/constants.ts`, from #2270). The SDK passes these `_meta` keys through untouched. `packages/core-internal/test/shared/traceContextMeta.test.ts` pins the passthrough. +- The SEP-2577 deprecation JSDoc on `ServerContext.mcpReq.log`, `sendLoggingMessage` and friends already tells users to "migrate to stderr logging (STDIO servers) or OpenTelemetry". `docs/servers/logging-progress-cancellation.md` says the same. Today there is nothing in the SDK to migrate to. +- There is no span, metric or trace-context code anywhere in `packages/*/src`. + +### The semantic conventions + +The MCP conventions moved out of `open-telemetry/semantic-conventions` into [`open-telemetry/semantic-conventions-genai`](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md). I read them at commit `51a0f580` (2026-10-27). The whole document, every MCP attribute and every MCP metric is **Development** status. Names can still change in a minor semconv release, and they have: `mcp.request.id` has been dropped from the registry and `rpc.response.status_code` was added. + +The parts this spec relies on: + +- **Span name** `{mcp.method.name} {target}`, where target is `gen_ai.tool.name` or `gen_ai.prompt.name`. Plain `{mcp.method.name}` when there is no low-cardinality target. Resource URIs stay out of span names by default. +- **Span kind** `CLIENT` on the side that sends a request or notification, `SERVER` on the side that receives it. This holds in both directions, so a server sending `sampling/createMessage` emits a `CLIENT` span. +- **Attributes:** `mcp.method.name` (required); `error.type`, `rpc.response.status_code`, `gen_ai.tool.name`, `gen_ai.prompt.name`, `jsonrpc.request.id`, `mcp.resource.uri` (conditionally required); `gen_ai.operation.name` (`execute_tool` on tool calls only), `mcp.protocol.version`, `mcp.session.id`, `network.transport`, `network.protocol.name`, `network.protocol.version`, `server.address`, `server.port`, `client.address`, `client.port` (recommended); `gen_ai.tool.call.arguments`, `gen_ai.tool.call.result`, `gen_ai.prompt.variable.` (opt-in, sensitive). +- **Errors:** a JSON-RPC error sets `rpc.response.status_code` and `error.type` to the code as a string, and status `ERROR` with the JSON-RPC message as description. On the receiver, `-32700`, `-32600`, `-32601`, `-32602` and `-32002` are not errors. On the sender, every code is an error. A `CallToolResult` with `isError: true` sets `error.type` to `tool_error`. +- **Propagation:** inject with the configured OTel propagators into `params._meta` of every request and notification, using the unprefixed keys SEP-414 reserves. The receiver uses the extracted context as the parent of its `SERVER` span and links the ambient context (for example the HTTP server span). MCP and HTTP contexts are independent. +- **Metrics:** histograms `mcp.client.operation.duration`, `mcp.server.operation.duration`, `mcp.client.session.duration` and `mcp.server.session.duration`, unit `s`, explicit buckets `[0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300]`. +- **Outer GenAI span:** if the instrumentation can reliably tell that an outer `execute_tool` span already covers the tool call, it SHOULD add MCP attributes to that span instead of creating one. + +The well-known `mcp.method.name` values stop at the 2025-era methods. `server/discover`, `subscriptions/listen` and the Tasks extension methods are allowed as custom values but have no entries yet. + +### The MCP specification + +[SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/414-request-meta.md) (Final) reserves `traceparent`, `tracestate` and `baggage` in `_meta` with W3C formats. It says nothing about which messages carry them, about server-to-client traffic, or about HTTP headers. [SEP-2028](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2028), which would forward these keys to downstream HTTP headers, is still an open PR. This repo already rejected HTTP header forwarding without protocol support in [#666](https://github.com/modelcontextprotocol/typescript-sdk/pull/666). + +### Other official SDKs + +| SDK | How it ships | Default | What it emits | +| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Python (`src/mcp/shared/_otel.py`, `src/mcp/server/_otel.py`) | Hard dependency on `opentelemetry-api` | On for every server and client | `SERVER` spans from an `OpenTelemetryMiddleware` that is first in `Server.middleware`. `CLIENT` spans named `MCP send {method} {target}` from the dispatcher, with a TODO to move them to an outbound middleware. `_meta` inject and extract. No metrics. | +| C# (`src/ModelContextProtocol.Core/Diagnostics.cs`, `McpSessionHandler.cs`) | `System.Diagnostics` `ActivitySource` and `Meter`, both named `Experimental.ModelContextProtocol`. No extra dependency. | Free until a listener subscribes | All four metrics with the semconv buckets, spans in both directions, links to the ambient activity, reuse of an outer `execute_tool ` activity. Skips `notifications/message`. | +| Go | Nothing built in | n/a | n/a | + +Two things C# does that this spec deliberately does not copy. It records `mcp.resource.uri` as a metric attribute, which the conventions list as opt-in for metrics. It also puts a failed tool's `content` JSON into the span status description, which can leak tool output. + +### Third-party instrumentation of this SDK + +| Instrumentation | Targets | Mechanism | `_meta` propagation | +| ------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| Sentry JS 11.1+ (`packages/core/src/integrations/mcp-server`) | v1 `@modelcontextprotocol/sdk` and v2 `@modelcontextprotocol/server` | Patches `McpServer.connect`, then `transport.onmessage` and `transport.send`, and every registered handler. Auto-wraps every `McpServer` when tracing is on. | No. Uses its own attribute names (`mcp.tool.name`, `mcp.prompt.name`) and records inputs and outputs by default. | +| OpenInference (`@arizeai/openinference-instrumentation-mcp`) | v1 module paths only (`@modelcontextprotocol/sdk/client/stdio.js` and so on) | Patches transport `send` and `onmessage` | Yes, inject and extract. Creates no spans of its own. | +| OpenLLMetry (`@traceloop/instrumentation-mcp`) | v1 `sdk/client/index.js`, `sdk/server/index.js` | Patches `Client` and `Server` | No | +| Logfire | Python only | n/a | n/a | + +All of them monkey-patch. That is why they lag the SDK's module layout (only Sentry knows about the v2 packages) and why ESM loaders crash in cases like [#1020](https://github.com/modelcontextprotocol/typescript-sdk/issues/1020). A supported hook removes the need to patch. + +## Goals + +1. Traces and metrics that follow the MCP semantic conventions, for `Client`, `McpServer` and low-level `Server`, on every transport, in both protocol eras (2025-11-25 and 2026-07-28). +2. Client-to-server and server-to-client trace context through `params._meta`, interoperable with the Python and C# SDKs. +3. Zero cost when unused. No instrumentation configured means no allocation, no extra branch beyond one `undefined` check per message, and byte-identical wire output. +4. Works in Node, Bun, Deno, Cloudflare Workers and browsers. +5. Safe defaults. No arguments, results, prompt variables or tokens recorded unless the user asks. +6. A hook other vendors (Sentry, Arize, Traceloop) can implement instead of patching. + +## Non-goals + +- Spans for transport work: HTTP requests, SSE streams, reconnects, stdio process lifecycle. HTTP and fetch instrumentations already cover these, and the conventions treat them as separate. +- Writing `traceparent` HTTP headers from MCP `_meta`, or reading MCP context from HTTP headers. That is SEP-2028's job if it lands. +- An OTel logs signal in the first release (see [Logs and #2370](#logs-and-2370)). +- Converting `ctx.mcpReq.log` or `notifications/message` into OTel log records. +- Configuring OTel SDKs, exporters or context managers. That stays the application's job. +- Detecting an outer `execute_tool` span. `@opentelemetry/api` exposes no span name, so the instrumentation cannot do it reliably, and the conventions only ask for it when it can. +- A v1.x backport (see [Rollout](#rollout)). + +## Dependency strategy + +All four options have to work in Node, Bun, Deno, Workers and browsers. `@opentelemetry/api` itself does: it is plain JavaScript with no dependencies and keeps its global state on `globalThis`. + +| Option | For | Against | +| ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A. `@opentelemetry/api` as a regular dependency of `core-internal`, bundled into `client` and `server` (Python's choice) | Zero setup. One package to install. | A new runtime dependency in every published package, which `DEPENDENCY_POLICY.md` treats as significant. Semconv is Development, so every rename would ship as a change in `client` and `server`. On-by-default would change the wire for existing users. | +| B. `@opentelemetry/api` as an optional peer of `client` and `server` | No install for users who don't want it | A static import breaks when the peer is missing. A dynamic `import()` makes setup async and confuses bundlers, Workers included. | +| C. No dependency. Core accepts the OTel API object (`{ api: otel }`) and implements the conventions against structural copies of the OTel interfaces | No new package | Core then owns a structural mirror of `Tracer`, `Span`, `Context`, `TextMapPropagator` and `Meter`, plus the whole semconv mapping. Semconv churn still lands in `client` and `server` releases. | +| D. **Recommended.** A small OTel-free hook in core, and a separate `@modelcontextprotocol/otel` with `@opentelemetry/api` as a peer | Core gains one option and a handful of types, and no dependency. The semconv mapping versions on its own line while it is Development. Vendors implement the same hook. | One more package to publish and document. | + +D matches what the ecosystem has converged on: `@hono/otel` and `@fastify/otel` sit outside their frameworks. The AI SDK is the closest precedent. Its v7 dropped the direct `@opentelemetry/api` dependency from `ai` in favour of a `Telemetry` interface in core, with spans moved into `@ai-sdk/otel` (which depends on `@opentelemetry/api`). Its interface has an `executeTool({ execute })` method that runs work inside the integration's context, which is the same job `OperationScope.run` does below. + +The peer range for `@opentelemetry/api` is `^1.9.0`. Histogram bucket advice (`advice.explicitBucketBoundaries`) needs 1.7 or later. + +## Public API + +### Core: `instrumentation` on `ProtocolOptions` + +`ProtocolOptions` (`packages/core-internal/src/shared/protocol.ts:72`) gets one optional field. `ClientOptions`, `ServerOptions` and `McpServerOptions` all extend it, so `Client`, `Server` and `McpServer` accept it with no further change. This differs from #2370, which moved `logger` off `ProtocolOptions` because low-level `Server` emits no diagnostics. Low-level `Server` does send and receive messages, so it should be instrumentable. + +```ts +export type ProtocolOptions = { + // ...existing fields... + + /** + * Observes every message this instance sends and receives, for tracing and metrics. + * + * @experimental The shape may change in a minor release. + */ + instrumentation?: ProtocolInstrumentation; +}; +``` + +The types, exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server` the same way `SdkLogger` is: + +```ts +/** @experimental */ +export interface ProtocolInstrumentation { + /** A request or notification is about to be sent. Write trace context into `operation.carrier`. */ + send?(operation: OutboundOperation): OperationScope | undefined; + /** A request or notification arrived and is about to be dispatched. */ + receive?(operation: InboundOperation): OperationScope | undefined; + /** The instance connected to a transport. The returned callback runs once when it closes. */ + connect?(session: SessionStart): ((end: SessionEnd) => void) | undefined; +} + +/** @experimental */ +export interface OperationBase { + readonly method: string; + /** Absent for notifications. */ + readonly id?: RequestId; + /** The params as they go on (or came off) the wire. Read-only. */ + readonly params?: Readonly>; + readonly sessionId?: string; + readonly protocolVersion?: string; +} + +/** @experimental */ +export interface OutboundOperation extends OperationBase { + /** Keys written here are copied into `params._meta`. Prefilled with caller-supplied `traceparent`, `tracestate` and `baggage`. */ + readonly carrier: Record; +} + +/** @experimental */ +export interface InboundOperation extends OperationBase { + /** The request or notification `_meta`, including `traceparent`, `tracestate` and `baggage` when present. */ + readonly meta?: Readonly>; + /** Whether a handler (or fallback handler) is registered for `method`. */ + readonly handled: boolean; + /** The HTTP request, when the transport supplied one. */ + readonly httpRequest?: Request; +} + +/** @experimental */ +export interface OperationScope { + /** Runs the operation's work in the instrumentation's context: the transport send, or the handler. Must call `fn` once. */ + run(fn: () => T): T; + /** Called exactly once when the operation settles. */ + end(outcome: OperationOutcome): void; +} + +/** @experimental */ +export type OperationOutcome = { type: 'result'; result: Result } | { type: 'error'; error: { code: number; message: string }; cause?: unknown } | { type: 'delivered' } | { type: 'timeout' } | { type: 'cancelled' } | { type: 'closed' } | { type: 'failed'; error: unknown }; + +/** @experimental */ +export interface SessionStart { + readonly role: 'client' | 'server'; +} + +/** @experimental */ +export interface SessionEnd { + readonly sessionId?: string; + readonly protocolVersion?: string; + readonly error?: unknown; +} +``` + +Rules `Protocol` follows when calling it: + +- It calls `send` or `receive` once per JSON-RPC request or notification, never for responses. Responses settle the scope of the request they answer. +- It calls `end` exactly once per scope, on every exit path. A `settled` flag guards it, the same way `cleanupMessageId` guards cleanup today. +- It never mutates caller-owned objects. The carrier is merged into a copy of `params._meta`. +- A throwing instrumentation method never fails the operation. `Protocol` catches the error, reports it through `onerror`, and carries on without that scope. If `run` throws before calling `fn`, `Protocol` calls `fn` itself. This is the same guarantee #2370's `guardLogger` gives for loggers. +- When `instrumentation` is undefined, each hook point is one property read and an `undefined` check. + +The hook is intentionally not OTel-shaped. Here is a non-OTel use, an audit log: + +```ts +const audit: ProtocolInstrumentation = { + receive(operation) { + const started = performance.now(); + return { + run: fn => fn(), + end: outcome => auditLog.write({ method: operation.method, outcome: outcome.type, ms: performance.now() - started }) + }; + } +}; +``` + +### `@modelcontextprotocol/otel` + +```ts +export interface OpenTelemetryOptions { + /** @default the global tracer provider */ + tracerProvider?: TracerProvider; + /** @default the global meter provider */ + meterProvider?: MeterProvider; + /** @default the global propagator (`propagation` from `@opentelemetry/api`) */ + propagator?: TextMapPropagator; + /** Record tool arguments and results and prompt arguments. Off by default. */ + captureContent?: boolean; +} + +export function openTelemetry(options?: OpenTelemetryOptions): ProtocolInstrumentation; +``` + +`openTelemetry()` creates its tracer and instruments once, with instrumentation scope name `@modelcontextprotocol/otel` and the package version. Create it once and share it across instances. That matters for `createMcpHandler`, whose factory builds a new `McpServer` for every HTTP request. + +There is no environment-variable configuration. Workers and browsers have no `process.env`, and `captureContent` is a privacy switch that belongs in code. + +Client: + +```ts +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { openTelemetry } from '@modelcontextprotocol/otel'; + +const client = new Client({ name: 'agent', version: '1.0.0' }, { instrumentation: openTelemetry() }); +await client.connect(new StreamableHTTPClientTransport(new URL('https://mcp.example.com/mcp'))); + +// Emits "tools/call get-weather" (CLIENT), a child of the span active here, +// and puts its traceparent in params._meta. +await client.callTool({ name: 'get-weather', arguments: { city: 'Lisbon' } }); +``` + +Server over HTTP: + +```ts +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { openTelemetry } from '@modelcontextprotocol/otel'; +import * as z from 'zod/v4'; + +const instrumentation = openTelemetry(); + +export const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation }); + server.registerTool('get-weather', { inputSchema: z.object({ city: z.string() }) }, async ({ city }) => ({ + // Spans created here are children of "tools/call get-weather" (SERVER). + content: [{ type: 'text', text: await forecast(city) }] + })); + return server; +}); +``` + +Server over stdio uses the same option on the instance the `serveStdio` factory returns. `invoke` and the legacy stateless fallback in `createMcpHandler` also connect the factory's instance, so all entry points pick up the option without changes to `createMcpHandler.ts`, `serveStdio.ts` or `invoke.ts`. + +## Where the hooks go + +| Hook | Location (`packages/core-internal/src/shared/protocol.ts` unless noted) | What happens | +| --------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Outbound request | `_requestWithSchemaViaCodec` (L1379), after `outbound` is built and before `this._transport.send` (L1588) | `send()`. Merge the carrier into `outbound.params._meta`. Wrap `transport.send` in `scope.run` so fetch instrumentation parents the HTTP span under the MCP span. | +| Request settles | Response handler (L1509), `cancel()` (L1465), the `.catch` on send, and `_onclose` (L836) | `end()` with `result` after schema validation, `error` for a JSON-RPC error, `failed` for `InvalidResult` or a send failure, `timeout` when `timeoutHandler` fires, `cancelled` when `options.signal` aborts, `closed` when the connection drops. `cancel()` already has separate call sites for timeout and abort, so the outcome is exact even though both reject with `RequestTimeout` today. | +| Multi-round-trip legs | `flow.retry` and `Client._resolveNonCompleteResult` (`packages/client/src/client/client.ts:743`) | Each retry leg goes through `_requestWithSchemaViaCodec`, so each leg gets its own scope. The client runs the embedded elicitation and sampling handlers inside the scope of the leg that returned `input_required`, so user spans in those handlers nest under that leg. | +| Outbound notification | `_notificationViaCodec` (L1620) | `send()`, merge the carrier, `scope.run` around `transport.send`, `end()` with `delivered` or `failed`. A debounced notification starts its scope inside the microtask (L1659) that writes it, so coalesced notifications produce one scope. | +| Inbound request | Top of `_onrequest` (L938) | `receive()` before the era, method and envelope checks, so rejections the protocol answers itself still get a scope. `sendErrorResponse` ends it with `error`. The handler call (L1115) runs inside `scope.run`, which covers #2820's middleware too. After the result is encoded, `end()` with `result`, or `error` with the thrown value as `cause`. A request aborted by `notifications/cancelled` ends with `cancelled`. | +| Inbound notification | `_onnotification` (L877) | `receive()`, `scope.run` around the handler, `end()` with `delivered`, or `failed` if the handler throws. | +| Session | `connect` (L794) and `_onclose` (L836) | `connect()` when the transport starts. The returned callback runs from `_onclose` with the session id and negotiated version as they were at close. | + +`McpServer`, `Client` and the transports need no telemetry code. The tool or prompt name and the resource URI come from `params`. Negotiated version comes from `_negotiatedProtocolVersion`, session id from `transport.sessionId`, and the HTTP request from `MessageExtraInfo.request`. + +Rejections `createMcpHandler` produces before any instance exists (header ladder, unsupported version, body too large, 405 on GET) never reach `Protocol`. They produce no MCP span. The HTTP server span from the user's HTTP instrumentation already covers them. + +## Spans + +### What gets a span + +| Operation | Sender | Receiver | Notes | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Any request (`initialize`, `server/discover`, `ping`, `tools/*`, `resources/*`, `prompts/*`, `completion/complete`, `logging/setLevel`, `subscriptions/listen`, custom methods) | `CLIENT` | `SERVER` | One span per JSON-RPC request on the wire. Version probing during `Client.connect` (`server/discover`, then a fallback `initialize`) yields one span per probe, which is what happened on the wire. | +| 2025-era server-to-client requests (`sampling/createMessage`, `elicitation/create`, `roots/list`) | `CLIENT` on the server | `SERVER` on the client | Sent through `ctx.mcpReq.send`, so the `CLIENT` span is a child of the handler's `SERVER` span. | +| 2026-era input requests embedded in `input_required` results | none | none | Not wire messages. Each retry leg is a normal `CLIENT`/`SERVER` pair. | +| Notifications (`notifications/initialized`, `progress`, `cancelled`, `*/list_changed`, `resources/updated`, custom) | `CLIENT` | `SERVER` | The span ends when the transport accepts the write (sender) or the handler returns (receiver). | +| `notifications/message` | none | none | The hook still sees it. The OTel package skips it, following C#. It is deprecated by SEP-2577 and usually carries log traffic users want to see as logs, not spans. | +| Responses | none | none | A response settles its request's span. | + +### Name and kind + +- Name: `{method} {target}` for `tools/call` (target `params.name`) and `prompts/get` (target `params.name`), else `{method}`. Never the resource URI. +- Receiver-side unknown methods: when `handled` is false, the name and `mcp.method.name` are `_OTHER`, borrowing the HTTP conventions' rule for unknown methods. A peer can otherwise send any string as a method name and blow up cardinality. +- Receiver-side unknown tools and prompts: `McpServer` answers an unknown or disabled tool or prompt with `-32602`. When a `tools/call` or `prompts/get` span ends with `-32602`, the OTel package renames it to the bare method with `updateName` and drops `gen_ai.tool.name` or `gen_ai.prompt.name` from metric attributes. A rejected name stays on the span as an attribute only. That also covers a prompt whose arguments fail validation, which costs us the prompt name on those metrics. I think that trade is fine. +- `CLIENT` for outbound, `SERVER` for inbound, whatever the instance's role. + +### Attributes + +| Attribute | Set when | Source | +| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `mcp.method.name` | Always | `operation.method`, or `_OTHER` as above | +| `jsonrpc.request.id` | Requests | `String(operation.id)` | +| `mcp.protocol.version` | Once negotiated | `operation.protocolVersion` | +| `mcp.session.id` | When the transport has a session id (legacy stateful Streamable HTTP) | `operation.sessionId`. The 2026-07-28 era and stdio have none. | +| `gen_ai.operation.name` | `tools/call` only | `execute_tool` | +| `gen_ai.tool.name` | `tools/call` | `params.name` | +| `gen_ai.prompt.name` | `prompts/get` | `params.name` | +| `mcp.resource.uri` | `resources/read`, `resources/subscribe`, `resources/unsubscribe`, `notifications/resources/updated` | `params.uri`. Spans only. | +| `rpc.response.status_code` | A JSON-RPC error response | `String(error.code)` | +| `error.type` | Failure (see below) | | +| `network.transport`, `network.protocol.name` | Receiver, when `httpRequest` is present | `tcp` and `http`. Left unset otherwise; see open question 6. | +| `gen_ai.tool.call.arguments`, `gen_ai.tool.call.result`, `gen_ai.prompt.variable.` | Only with `captureContent: true` | See [Privacy](#privacy) | + +`server.address`, `server.port`, `client.address`, `client.port` and `network.protocol.version` are left unset. `Protocol` can't see the URL or socket, and fetch instrumentation records them on the HTTP span anyway. + +The OTel package computes attributes only when `span.isRecording()` is true. A sampled-out or no-op span costs one `startSpan` and one `end`. + +### Status and `error.type` + +| Outcome | `error.type` | `rpc.response.status_code` | Status | +| -------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `result`, `tools/call` with `isError: true` | `tool_error` | unset | `ERROR`, no description (the content stays out of telemetry) | +| `result`, anything else (including `input_required`) | unset | unset | `UNSET` | +| `error`, sender side | code as string | code as string | `ERROR`, description = JSON-RPC message | +| `error`, receiver side, code in `-32700`, `-32600`, `-32601`, `-32602`, `-32002` | unset | code as string | `UNSET` | +| `error`, receiver side, any other code | code as string | code as string | `ERROR`, description = JSON-RPC message. If `cause` is an exception that is not a `ProtocolError`, also `recordException(cause)`. | +| `timeout` | `timeout` | unset | `ERROR` | +| `cancelled` | `cancelled` | unset | `ERROR` (open question 4) | +| `closed` | `connection_closed` | unset | `ERROR` | +| `failed` with an `SdkError` | its `code` (`SEND_FAILED`, `INVALID_RESULT`, ...) | unset | `ERROR` | +| `failed`, anything else | `error.name` when it is a standard `Error` subclass name, else `_OTHER` | unset | `ERROR` | + +The package docs list every `error.type` value it can emit, as the conventions ask. + +### Feature notes + +- **`initialize` and `server/discover`.** Ordinary request spans. `mcp.protocol.version` is unset on the first `initialize` because nothing is negotiated yet. The conventions' examples set it from the request. Doing the same means reading `params.protocolVersion`, which is cheap, so the package does it for these two methods. +- **Progress.** `notifications/progress` sent from a handler inherits the handler's `SERVER` context and injects it, so on the requester the progress span is a child of the remote handler span. That is accurate but chatty on long jobs. Progress callbacks registered with `onprogress` run inside that inbound span. +- **Cancellation.** The `notifications/cancelled` the SDK sends on abort gets its own `CLIENT` span. The 2026-07-28 Streamable HTTP path cancels by aborting the request stream and sends nothing, so there is no notification span there, just `cancelled` on the request. `initialize` is never cancelled on the wire, so it only ends `cancelled` locally. +- **Tasks extension (SEP-2663, #2782).** `tools/call` answered with `resultType: "task"` ends its span when that response is sent. `tasks/get`, `tasks/result` and `tasks/cancel` are ordinary request spans. The background work behind a task outlives the `tools/call` span. The Tasks extension should capture the active context when it creates the task and start the work's spans as children of it. That belongs in the Tasks PR, not here. There is no task id attribute in the conventions, and the package will not invent one (open question 10). +- **`subscriptions/listen`.** The span covers the whole subscription, which can last hours and lands every observation in the top bucket of `mcp.client.operation.duration` (open question 11). +- **Custom methods.** Spans like any other method. The method name is recorded when a handler is registered for it. +- **Middleware (#2820).** `use` middleware runs inside `scope.run`, so spans it creates are children of the `SERVER` span, and an answer it returns without calling `next` still ends the span with that result. + +### Transport-level spans + +The SDK creates protocol spans only. On Streamable HTTP the expected shape matches the conventions' example. The client's `transport.send` runs inside the `CLIENT` span's context, so fetch or undici instrumentation makes the `POST` a child of the MCP span. On the server, the HTTP server span is ambient when the message arrives, and the MCP `SERVER` span links to it. [#1231](https://github.com/modelcontextprotocol/typescript-sdk/issues/1231) (undici marks successful POSTs as aborted on `close()`) makes those HTTP spans look wrong today. It is a separate bug, but it will be more visible once MCP spans sit on top. + +## Context propagation + +### Outbound + +1. `Protocol` prefills `operation.carrier` with any `traceparent`, `tracestate` and `baggage` strings the caller already put in `params._meta`. +2. The OTel package extracts the carrier. If it holds a valid context, that becomes the `CLIENT` span's parent. Otherwise the active context is the parent. +3. It starts the span, then injects the span's context with the configured propagator into the carrier, replacing what was there. +4. `Protocol` writes the carrier's keys into a copy of `params._meta`. + +A caller who set `traceparent` by hand keeps the same trace and gets a more precise parent on the server, the SDK's `CLIENT` span instead of their own. With no instrumentation configured, nothing changes and the passthrough test keeps passing as it does today. + +Both requests and notifications get injected, as the conventions say. Server-to-client traffic does the same thing in reverse. + +### Inbound + +1. The OTel package extracts `operation.meta`. A missing, malformed or invalid `traceparent` yields no context, and the request is served normally. The SDK never rejects a message over its trace context. +2. With a valid extracted context, it is the `SERVER` span's parent and the active span at arrival (the HTTP server span, say) becomes a link. +3. Without one, the active context is the parent and there is no link. That matches Python and keeps the MCP span inside the HTTP trace instead of starting an orphan. +4. `scope.run` makes the `SERVER` span active for the handler and everything it awaits. + +### HTTP headers + +The SDK neither writes nor reads `traceparent` HTTP headers for MCP context. On the client, fetch instrumentation adds the HTTP-level header for the HTTP span, which is already a child of the MCP span. On the server, HTTP instrumentation continues that header's trace for the HTTP span, and the MCP span follows rule 2 or 3 above. A peer that sends only the HTTP header still ends up in one trace through rule 3. Forwarding `_meta` to downstream HTTP calls is SEP-2028's business. + +### Peers without support + +`_meta` is open, SEP-414 reserves these keys, and every conforming peer ignores keys it doesn't know. Nothing needs negotiating. A peer that sends no trace context gets rule 3. The SDK neither advertises nor requires anything. + +### Context managers by runtime + +Parent extraction passes contexts explicitly, so it works everywhere. Making the handler's span active for user code needs a context manager registered by the application: + +| Runtime | Context manager | +| ------------------ | ------------------------------------------------------------------------------------------------------------------ | +| Node, Bun, Deno | `AsyncLocalStorageContextManager` from `@opentelemetry/context-async-hooks` | +| Cloudflare Workers | An `AsyncLocalStorage`-based manager, with `nodejs_compat` | +| Browsers | `ZoneContextManager`, or none. Without one, MCP spans are still correct but user spans inside handlers won't nest. | + +`scope.run` wraps the call that creates the handler's promise chain (`Promise.resolve().then(() => handler(...))`). `AsyncLocalStorage` carries the context into every continuation of that chain, including the response `send`. + +## Metrics + +| Instrument | Type | Unit | Recorded | +| ------------------------------- | --------- | ---- | -------------------------------------------------- | +| `mcp.client.operation.duration` | Histogram | `s` | When an outbound scope ends | +| `mcp.server.operation.duration` | Histogram | `s` | When an inbound scope ends | +| `mcp.client.session.duration` | Histogram | `s` | When a `Client` connection closes | +| `mcp.server.session.duration` | Histogram | `s` | When a server connection with a `sessionId` closes | + +All four pass `advice: { explicitBucketBoundaries: [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300] }`. Durations come from `performance.now()`. On Workers that clock only advances across I/O, so durations there measure I/O time. The docs will say so. + +Operation metric attributes come from a fixed allowlist: `mcp.method.name`, `error.type`, `rpc.response.status_code`, `gen_ai.operation.name`, `gen_ai.tool.name`, `gen_ai.prompt.name`, `mcp.protocol.version`, and `network.transport` and `network.protocol.name` when known. Session metric attributes are `error.type`, `mcp.protocol.version` and the network pair. + +Never on metrics: `mcp.session.id`, `jsonrpc.request.id`, `mcp.resource.uri` (opt-in in the conventions, not offered in this version), and content attributes. + +Cardinality on the receiver is bounded by what the server has registered. Unknown methods become `_OTHER`, rejected tool and prompt names are dropped, `error.type` comes from the documented list plus JSON-RPC codes, and protocol versions are a short list. The OTel metrics SDK's per-instrument cardinality limit (2000 by default) is the backstop. On the sender, tool and prompt names come from the server's listing, which the client chose to connect to. + +Session duration is only recorded where a session means something. A `Client` connection always counts, from `connect` to close. On the server, `createMcpHandler` builds one instance per HTTP request on the 2026-07-28 era and the stateless legacy path. Recording each of those as a session would fill the histogram with millisecond "sessions". So the server metric only records when the transport had a `sessionId`, which means legacy stateful Streamable HTTP. A stdio server is a real session without a session id, and this rule misses it (open question 7). + +## Logs and #2370 + +#2370 adds `SdkLogger` (optional `debug` and `warn`) on `Client`, `McpServer`, `createMcpHandler` and `OAuthClientProvider`, wrapped by `guardLogger` so a throwing logger never fails an SDK call. This spec leaves it alone: + +- **#2370 lands as it is.** Neither change depends on the other, and they can merge in either order. `ProtocolOptions` gains `instrumentation` while `logger` stays on `ClientOptions` and `McpServerOptions`. +- **`SdkLogger` stays the path for SDK diagnostics.** The instrumentation hook does not carry diagnostics. Some diagnostics come from the OAuth flow, which runs in `auth()` and the auth providers, outside any `Protocol`. #2370 put the logger on `OAuthClientProvider` for exactly that reason, and a protocol hook can't reach that code. +- **No logs bridge in the first release.** `@opentelemetry/api-logs` is still 0.x. Users who want SDK diagnostics as OTel log records write a small adapter, which the docs page will show: + +```ts +import { logs, SeverityNumber } from '@opentelemetry/api-logs'; +import type { SdkLogger } from '@modelcontextprotocol/server'; + +const otelLogger = logs.getLogger('@modelcontextprotocol/server'); +const logger: SdkLogger = { + debug: (...args) => otelLogger.emit({ severityNumber: SeverityNumber.DEBUG, body: args.map(String).join(' ') }), + warn: (...args) => otelLogger.emit({ severityNumber: SeverityNumber.WARN, body: args.map(String).join(' ') }) +}; +``` + +- **Later, if asked for:** `@modelcontextprotocol/otel` can export `openTelemetryLogger(): SdkLogger`. That is additive and changes nothing in #2370's API. +- **MCP protocol logging** (`notifications/message`) is a separate thing. The SDK does not turn it into OTel logs. The deprecated-logging docs will link the new OpenTelemetry page as the migration target SEP-2577 names. + +On KKonstantinov's review comment about RFC 5424 levels: OTel severity numbers cover that mapping if a bridge ever ships (`debug` is `DEBUG` 5, `warn` is `WARN` 13). It doesn't affect this spec. + +## Privacy + +Never recorded by default: + +- Tool arguments and results, prompt arguments, completion values +- Resource contents, sampling messages, elicitation requests and responses +- The `content` of a failed tool result (C# records it, this does not) +- JSON-RPC error `data` +- HTTP headers, `authInfo`, access tokens, OAuth state +- `requestState` and `inputResponses` (2026-07-28 multi-round-trip material) +- Baggage values as attributes + +Recorded by default, and worth knowing about: + +- **`mcp.resource.uri`** on spans. The conventions make it conditionally required, but URIs can carry user names, paths or database names. Open question 9 asks whether to keep the default. +- **`mcp.session.id`.** The spec says session ids must be unguessable and must not be used for authentication, so recording one doesn't grant access. It is still an identifier that lands in telemetry storage. +- **JSON-RPC error messages** as status descriptions. The message is already on the wire to the peer. On the receiver it can echo client input, such as the tool name in "Tool X not found". +- **Exception message and stack** for handler failures that are not `ProtocolError`. These are developer-written strings and stay in the operator's backend. +- **Baggage propagation.** The default global propagator usually includes W3C Baggage, which sends baggage entries to the MCP server, possibly a third party. `openTelemetry({ propagator: new W3CTraceContextPropagator() })` sends trace context only (open question 8). + +With `captureContent: true`, the package adds: + +- `gen_ai.tool.call.arguments`: `params.arguments` as a JSON string. `@opentelemetry/api` span attributes can't hold structured values, and the conventions allow a JSON string on spans in that case. +- `gen_ai.tool.call.result`: `structuredContent` when present, otherwise `content`, as a JSON string, on success only. +- `gen_ai.prompt.variable.` for each `prompts/get` argument. + +Serialization happens only when the span is recording. Length limits come from the OTel SDK's `attributeValueLengthLimit`. + +## Coexistence with other instrumentations + +- **Sentry.** Sentry 11.1+ auto-wraps every v2 `McpServer` by patching `connect` and the transport. With `openTelemetry()` also on, the server gets two spans per message, and they won't nest, because ours parents to the client's `_meta` context. The docs should tell users to pick one. The real fix is Sentry implementing `ProtocolInstrumentation` instead of patching, and the experimental period is the time to ask them (open question 12). +- **OpenInference, OpenLLMetry.** They patch v1 module paths only, so they don't touch v2 packages. Someone mixing a v1 client with OpenInference and a v2 server with this package gets working propagation, because both use the same `_meta` keys. +- **Python and C# peers.** Same keys, same span names (except Python's `MCP send` client prefix) and same attributes, so cross-language traces join. + +## Why not #2820's middleware or extensions + +#2820 adds `Protocol.use(method, middleware)` and extensions shaped `{ id, setup(ctx) }`. I checked both against what telemetry needs. + +| Need | `use` middleware | Extension | +| -------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------- | +| Every inbound request | No. Method names are exact and there is no wildcard, by design. | Only through `ctx.use`, same limit | +| Inbound notifications | No | No | +| Outbound requests and notifications, `_meta` injection | No | No | +| Errors the protocol answers before dispatch (`-32601`, era mismatch, missing envelope) | No. Middleware runs after those checks. | No | +| Timeouts, local cancel, connection close on the sender | No | No | +| Leaves the wire alone | Yes | No. Every extension is advertised under `capabilities.extensions[id]`. | +| Status | `@experimental` | `@experimental` | + +Python built its server spans on middleware because its server middleware already sees every message, and it still had to hardcode client spans in the dispatcher with a TODO to move them. Telemetry is not a protocol extension, and it shouldn't show up in capabilities. So the hook is separate. It composes with middleware: the scope wraps the resolved handler, middleware included. If `use` later gets a wildcard and an outbound side, moving the OTel package onto it is an internal change to the package that users never see. + +## Testing + +Unit tests for the hook in `packages/core-internal/test/shared/instrumentation.test.ts`, using a fake `ProtocolInstrumentation` over `InMemoryTransport.createLinkedPair()`: + +- `end` runs exactly once on every exit path: result, JSON-RPC error, handler throw, timeout, abort, abort before send, connection close, send failure, `InvalidResult`, era mismatch, method not found, missing envelope, `notifications/cancelled` on an in-flight handler, debounced notification. +- The carrier lands in `_meta` without mutating the caller's params. Caller-supplied trace keys are prefilled. +- A throwing `send`, `receive`, `run`, `end` or `connect` never changes the operation's result, and reaches `onerror`. +- With no instrumentation, outbound messages are byte-identical to today. Extend `traceContextMeta.test.ts` with this. + +The OTel package tests use `BasicTracerProvider` with `InMemorySpanExporter`, `MeterProvider` with an in-memory `MetricReader`, and `AsyncLocalStorageContextManager`: + +- Table-driven checks of name, kind, parent, links, attributes and status for every row in the span and status tables, on both eras. +- Client and server in one process: `CLIENT` to `SERVER` parenting through `_meta`, and a user span inside a tool handler nested under the `SERVER` span. +- A 2025-era `elicitation/create` from inside a tool: the server's `CLIENT` span is a child of its `SERVER` span, and the client's `SERVER` span is a child of that. +- A 2026-era `input_required` round trip: two `tools/call` legs, embedded handler spans under the first leg. +- Privacy: no argument, result or tool-error content without `captureContent`. +- Cardinality: an unknown method and an unknown tool name appear in neither span names nor metric attributes. +- Metrics: bucket advice, attribute allowlist, no session metric for per-request instances. +- Optional CI step: run OTel Weaver's `registry live-check` against the emitted telemetry, pinned to a semconv-genai commit. + +End-to-end and runtime tests: + +- New entries in `test/e2e/requirements.ts`, for example `observability:trace-context:client-to-server`, `observability:trace-context:server-to-client` and `observability:no-wire-change-when-disabled`, with scenarios across the stdio, Streamable HTTP and in-memory cells. +- `createMcpHandler` behind an instrumented HTTP server: the MCP `SERVER` span links to the HTTP server span. +- The existing runtime suites (`test/integration/test/server/bun.test.ts`, `deno.test.ts`, `cloudflareWorkers.test.ts`) import the package and serve one traced call. +- Conformance: the MCP conformance suite has no SEP-414 scenario today. Contributing one belongs upstream (open question 13). + +## Docs + +- A new how-to page, `docs/servers/opentelemetry.md` or `docs/advanced/opentelemetry.md`, written to `docs/_meta/CONVENTIONS.md`. It covers install, client and server setup, what spans and metrics show up (quoting real output from a companion example), propagation, `captureContent`, the context-manager table, the Workers clock caveat, and using it with Sentry. +- The package README, including the full `error.type` list. +- `docs/servers/logging-progress-cancellation.md`: point "use OpenTelemetry instead" at the new page, and add the `SdkLogger` adapter example once #2370 is merged. +- `docs/clients/middleware.md`: one line noting that fetch middleware runs inside the MCP `CLIENT` span's context. +- A companion example under `examples/` (stdio client and server, console exporter) that produces the output the docs page quotes. + +## Rollout + +Stacked PRs that can each land alone: + +1. **Hook in core.** `instrumentation` on `ProtocolOptions`, the types, the call sites, and unit tests. Changeset: minor for `core-internal`, `client` and `server`, flagged `@experimental`. No new dependency. +2. **`@modelcontextprotocol/otel` with traces and propagation.** New package at `packages/otel/`, independently versioned like the framework integrations, peer dependency `@opentelemetry/api ^1.9.0`. +3. **Metrics.** +4. **`captureContent`.** +5. **Docs page and example.** +6. **E2E requirements and runtime smoke tests.** Can fold into 2 to 4 as they land. + +Before PR 1, maintainers sign off on #1264 per the discuss-before-you-code rule in `CONTRIBUTING.md`. That is what this document is for. + +Semver: + +- The hook is `@experimental`, so its shape can change in a minor release, matching `use`, extensions and `acceptResultType` in #2820. It drops the tag once a second implementation (Sentry or another vendor) has used it. +- The OTel package starts at `0.x` while the MCP conventions are Development. Semconv renames ship as minors in `0.x` with a changeset naming each renamed attribute. When the conventions go stable, the package goes to `1.0` and follows them. If GenAI semconv adopts an `OTEL_SEMCONV_STABILITY_OPT_IN` transition for MCP, the package follows it then. +- Nothing is breaking. Without the option, behaviour and wire output are unchanged. + +v1.x backport: no. `CONTRIBUTING.md` limits `v1.x` to bug fixes, and this is a feature. v1 users already have OpenInference, OpenLLMetry and Sentry, all of which target v1 module paths. If maintainers want v1 coverage later, the package's semconv mapping can sit behind a `Transport` decorator, since v1 and v2 share the transport shape. That stays out of scope here. + +## Open questions + +1. **New package.** Is a new published `@modelcontextprotocol/otel` acceptable, on its own `0.x` line? The alternative is option A, a direct `@opentelemetry/api` dependency in `client` and `server` (Python's approach). +2. **Opt-in.** Python traces every server by default. This spec is opt-in for wire-compatibility and Sentry reasons. Should the SDK instead turn on when a global tracer provider is registered? +3. **Where the hook lives.** `ProtocolOptions`, which includes low-level `Server`, or only `ClientOptions` and `McpServerOptions`? A single `instrumentation`, or an array so the OTel package and a vendor hook can both run? +4. **Cancelled requests.** Should a caller's abort set status `ERROR`, or record `error.type: cancelled` with status `UNSET`? The conventions don't say. +5. **Where the hook types come from.** The OTel package has to name `ProtocolInstrumentation` without depending on `client` or `server` (it serves both). The options are a structural copy plus a compile-time assignability test, or exporting the types from `@modelcontextprotocol/core`, which is schemas and constants today. +6. **`network.transport` on stdio and on the client.** `Protocol` can't tell stdio from in-memory, or HTTP on the client side. Add an optional self-describing field to `Transport`, or leave these recommended attributes unset? +7. **Server session duration.** Only legacy stateful HTTP qualifies under the current rule. Should `serveStdio` and plain `connect(stdioTransport)` count as sessions, and how should `Protocol` know? +8. **Baggage.** Should the default propagator send W3C Baggage to MCP servers, which may be third parties, or should the default be trace context only? +9. **`mcp.resource.uri` on spans.** Keep it on by default because the conventions make it conditionally required, or make it opt-in for privacy? +10. **Semconv gaps.** `server/discover`, `subscriptions/listen`, the Tasks methods and a task id attribute have no conventions yet. Who takes these to `semantic-conventions-genai`? +11. **`subscriptions/listen` durations.** Leave long-lived subscriptions in `mcp.*.operation.duration` per the conventions, or exclude them? +12. **Vendors.** Should Sentry and Arize be invited to implement the hook during the experimental period, before it is stabilized? +13. **Conformance.** Should someone propose a SEP-414 propagation scenario to the MCP conformance suite, so all SDKs are tested the same way? +14. **Remote parents on public servers.** Should there be an option to start a new trace and only link the client's context, for servers that don't trust callers' trace ids? The conventions say to use the remote parent by default. diff --git a/docs/advanced/opentelemetry.md b/docs/advanced/opentelemetry.md new file mode 100644 index 0000000000..1ac252e3d8 --- /dev/null +++ b/docs/advanced/opentelemetry.md @@ -0,0 +1,160 @@ +--- +shape: how-to +--- + +# OpenTelemetry + +`@modelcontextprotocol/otel` traces and measures every MCP request and notification, following the [OpenTelemetry semantic conventions for MCP](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md). Pass `openTelemetry()` as the `instrumentation` option. + +```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#openTelemetry_server" +import { openTelemetry } from '@modelcontextprotocol/otel'; +import { McpServer } from '@modelcontextprotocol/server'; +import { trace } from '@opentelemetry/api'; +import * as z from 'zod/v4'; + +const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation: openTelemetry() }); + +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}` }] }; +}); +``` + +::: warning Experimental +The `instrumentation` option and `@modelcontextprotocol/otel` are experimental, and so are the MCP semantic conventions they follow. Span and attribute names can change in a minor release. +::: + +## Install the package + +The package uses only the OpenTelemetry API. Your application brings the SDK and an exporter. + +```sh +npm install @modelcontextprotocol/otel @opentelemetry/api @opentelemetry/sdk-trace-node +``` + +## Start the OpenTelemetry SDK + +Register a tracer provider once, before any MCP traffic. `NodeTracerProvider.register()` also installs the W3C propagators and an `AsyncLocalStorage` context manager, which makes the MCP span active inside your handlers. + +```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#sdk_setup" +import { InMemorySpanExporter, NodeTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node'; + +// Swap the in-memory exporter for an OTLP exporter to send spans to a backend. +const exporter = new InMemorySpanExporter(); +const provider = new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); +// Registers the tracer provider, the W3C propagators and an AsyncLocalStorage context manager. +provider.register(); +``` + +Without a registered SDK, `openTelemetry()` records nothing and leaves `params._meta` untouched. + +## Trace a client + +The same option works on `Client`. + +```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#openTelemetry_client" +import { Client } from '@modelcontextprotocol/client'; + +const client = new Client({ name: 'agent', version: '1.0.0' }, { instrumentation: openTelemetry() }); +``` + +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. The span the tool started nests under the server span: + +``` +invoke_agent (internal) + tools/call get-weather (client) + tools/call get-weather (server) + fetch forecast (internal) +``` + +The server span carries the conventions' attributes (plus `jsonrpc.request.id`, left out here): + +``` +{ + 'mcp.method.name': 'tools/call', + 'mcp.protocol.version': '2025-11-25', + 'gen_ai.operation.name': 'execute_tool', + 'gen_ai.tool.name': 'get-weather' +} +``` + +::: 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 + +`openTelemetry()` records `mcp.client.operation.duration` for what an instance sends and `mcp.server.operation.duration` for what it receives, in seconds, with the conventions' bucket boundaries. Register a meter provider the same way as the tracer provider. 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 a method it has no handler for as `_OTHER`, and leaves an unknown tool or prompt name out, so a client can't grow your metric cardinality. + +## Serve over HTTP + +`createMcpHandler` builds a server per request. Create the instrumentation once and share it. + +```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#openTelemetry_createMcpHandler" +import { createMcpHandler } from '@modelcontextprotocol/server'; + +const instrumentation = openTelemetry(); + +const handler = createMcpHandler(() => { + const perRequest = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation }); + // perRequest.registerTool(...) + return perRequest; +}); +``` + +When HTTP instrumentation is active too, the MCP server span stays a child of the client's MCP span and links to the HTTP server span. + +## 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. + +## Record tool arguments and results + +By default spans carry no tool arguments, tool results or prompt arguments. Turn them on with `captureContent` when you need them for debugging. + +```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#openTelemetry_captureContent" +const debugInstrumentation = openTelemetry({ captureContent: true }); +``` + +::: warning +Captured content goes to your telemetry backend as is. Tool arguments and results can hold personal data or secrets. +::: + +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 + +- `openTelemetry()` from `@modelcontextprotocol/otel` goes in the `instrumentation` option of `Client`, `McpServer` or `Server`. +- Your application starts the OpenTelemetry SDK; without one nothing is recorded. +- 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. +- Tool arguments and results are only recorded with `captureContent: true`. diff --git a/docs/get-started/packages.md b/docs/get-started/packages.md index 40de2c6741..0b77a9f25f 100644 --- a/docs/get-started/packages.md +++ b/docs/get-started/packages.md @@ -26,14 +26,15 @@ npm install @modelcontextprotocol/server # expose tools, resources, prompts npm install @modelcontextprotocol/client # connect to servers and call them ``` -Those two are the starting point for almost everything; a process that plays both roles installs both. The full published set is nine packages: +Those two are the starting point for almost everything; a process that plays both roles installs both. The full published set is ten packages: - `@modelcontextprotocol/server` and `@modelcontextprotocol/client` — the two starting points, one per side. - `@modelcontextprotocol/node`, `@modelcontextprotocol/express`, `@modelcontextprotocol/hono`, `@modelcontextprotocol/fastify` — optional adapters for serving over HTTP. +- `@modelcontextprotocol/otel` — OpenTelemetry tracing and metrics, see [OpenTelemetry](../advanced/opentelemetry.md). - `@modelcontextprotocol/core` — the raw Zod wire schemas. - `@modelcontextprotocol/server-legacy` and `@modelcontextprotocol/codemod` — migration surfaces for v1 code. -A tenth package in the repository, `@modelcontextprotocol/core-internal`, is private: `server` and `client` bundle it at build time, so it never appears in your dependency tree. +An eleventh package in the repository, `@modelcontextprotocol/core-internal`, is private: `server` and `client` bundle it at build time, so it never appears in your dependency tree. ## Keep Node-only code behind the `./stdio` subpath diff --git a/docs/servers/logging-progress-cancellation.md b/docs/servers/logging-progress-cancellation.md index a23aff35b5..256bc20380 100644 --- a/docs/servers/logging-progress-cancellation.md +++ b/docs/servers/logging-progress-cancellation.md @@ -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. diff --git a/examples/README.md b/examples/README.md index 51bc960a13..7be299933a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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) | Traces and duration metrics with `@modelcontextprotocol/otel`; 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 | diff --git a/examples/guides/advanced/opentelemetry.examples.ts b/examples/guides/advanced/opentelemetry.examples.ts new file mode 100644 index 0000000000..270fe700bc --- /dev/null +++ b/examples/guides/advanced/opentelemetry.examples.ts @@ -0,0 +1,131 @@ +/** + * 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 { metrics, SpanKind } from '@opentelemetry/api'; +import type { DataPoint, Histogram } from '@opentelemetry/sdk-metrics'; +import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-node'; + +class CollectingMetricReader extends MetricReader { + protected async onShutdown(): Promise {} + protected async onForceFlush(): Promise {} +} +const metricReader = new CollectingMetricReader(); +metrics.setGlobalMeterProvider(new MeterProvider({ readers: [metricReader] })); + +// "Start the OpenTelemetry SDK" — the application's job, once, before any MCP traffic. +//#region sdk_setup +import { InMemorySpanExporter, NodeTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node'; + +// Swap the in-memory exporter for an OTLP exporter to send spans to a backend. +const exporter = new InMemorySpanExporter(); +const provider = new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); +// Registers the tracer provider, the W3C propagators and an AsyncLocalStorage context manager. +provider.register(); +//#endregion sdk_setup + +// "Trace a server" — the page's lead block. +//#region openTelemetry_server +import { openTelemetry } from '@modelcontextprotocol/otel'; +import { McpServer } from '@modelcontextprotocol/server'; +import { trace } from '@opentelemetry/api'; +import * as z from 'zod/v4'; + +const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation: openTelemetry() }); + +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 openTelemetry_server + +// "Trace a client" +//#region openTelemetry_client +import { Client } from '@modelcontextprotocol/client'; + +const client = new Client({ name: 'agent', version: '1.0.0' }, { instrumentation: openTelemetry() }); +//#endregion openTelemetry_client + +// "Serve over HTTP" — one instrumentation shared by every per-request server. +//#region openTelemetry_createMcpHandler +import { createMcpHandler } from '@modelcontextprotocol/server'; + +const instrumentation = openTelemetry(); + +const handler = createMcpHandler(() => { + const perRequest = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation }); + // perRequest.registerTool(...) + return perRequest; +}); +//#endregion openTelemetry_createMcpHandler +void handler; + +// "Record tool arguments and results" +//#region openTelemetry_captureContent +const debugInstrumentation = openTelemetry({ captureContent: true }); +//#endregion openTelemetry_captureContent +void debugInstrumentation; + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). Connects the client and server in memory, +// makes one tool call inside an agent span, and prints what was recorded. +// --------------------------------------------------------------------------- + +const { InMemoryTransport } = await import('@modelcontextprotocol/client'); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); +exporter.reset(); + +// "Trace a client" — 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 = { [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(stableAttributes); + +const { resourceMetrics } = await metricReader.collect(); +const clientDuration = resourceMetrics.scopeMetrics + .flatMap(scope => scope.metrics) + .find(metric => metric.descriptor.name === 'mcp.client.operation.duration'); +const toolCallPoint = (clientDuration?.dataPoints as DataPoint[] | 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 provider.shutdown(); diff --git a/examples/opentelemetry/README.md b/examples/opentelemetry/README.md new file mode 100644 index 0000000000..0d972a6b69 --- /dev/null +++ b/examples/opentelemetry/README.md @@ -0,0 +1,19 @@ +# opentelemetry + +Trace and measure MCP traffic with `@modelcontextprotocol/otel`. Both sides pass `openTelemetry()` as the `instrumentation` option; 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 an exporter (for example `OTLPTraceExporter`) to the tracer provider. See [the OpenTelemetry guide](../../docs/advanced/opentelemetry.md). diff --git a/examples/opentelemetry/client.ts b/examples/opentelemetry/client.ts new file mode 100644 index 0000000000..c713500105 --- /dev/null +++ b/examples/opentelemetry/client.ts @@ -0,0 +1,75 @@ +/** + * Drives the OpenTelemetry example: one tool call inside an agent span, then + * checks the trace on both sides of the wire. + * + * - The client's `tools/call trace-info` span is a child of the agent span. + * - The server's span for that call is in the same trace, and its parent is + * the client's span: trace context crossed the wire in `params._meta`. + * - A span the tool started nests under the server span. + * - The client recorded `mcp.client.operation.duration` for the call. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { openTelemetry } from '@modelcontextprotocol/otel'; +import { metrics, SpanKind, trace } from '@opentelemetry/api'; +import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; +import { InMemorySpanExporter, NodeTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node'; + +// The client application's OpenTelemetry SDK. In-memory here so the example can assert on what it recorded. +const exporter = new InMemorySpanExporter(); +const tracerProvider = new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); +tracerProvider.register(); +class CollectingMetricReader extends MetricReader { + protected async onShutdown(): Promise {} + protected async onForceFlush(): Promise {} +} +const metricReader = new CollectingMetricReader(); +metrics.setGlobalMeterProvider(new MeterProvider({ readers: [metricReader] })); + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'opentelemetry-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' }, instrumentation: openTelemetry() } +); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +const result = await trace.getTracer('agent').startActiveSpan('invoke_agent', async agentSpan => { + try { + return await client.callTool({ name: 'trace-info', arguments: {} }); + } finally { + agentSpan.end(); + } +}); +const server = result.structuredContent as { traceId: string; spanName: string; parentSpanId: string }; + +const spans = exporter.getFinishedSpans(); +const agentSpan = spans.find(span => span.name === 'invoke_agent')!; +const callSpan = spans.find(span => span.name === 'tools/call trace-info' && span.kind === SpanKind.CLIENT); +check.ok(callSpan, 'the client should record a "tools/call trace-info" CLIENT span'); +check.equal(callSpan.parentSpanContext?.spanId, agentSpan.spanContext().spanId, 'the call span should be a child of the agent span'); +check.equal(callSpan.attributes['gen_ai.tool.name'], 'trace-info'); +check.equal(callSpan.attributes['mcp.protocol.version'], era === 'modern' ? '2026-07-28' : '2025-11-25'); + +check.equal(server.traceId, callSpan.spanContext().traceId, 'the server span should be in the client trace'); +check.equal(server.parentSpanId, callSpan.spanContext().spanId, 'the server span should be a child of the client call span'); +check.equal(server.spanName, 'tools/call trace-info'); + +const { resourceMetrics } = await metricReader.collect(); +const duration = resourceMetrics.scopeMetrics + .flatMap(scope => scope.metrics) + .find(m => m.descriptor.name === 'mcp.client.operation.duration'); +check.ok(duration, 'the client should record mcp.client.operation.duration'); +check.ok(duration.dataPoints.some(point => point.attributes['gen_ai.tool.name'] === 'trace-info')); + +console.log(`trace ${server.traceId}`); +console.log(` ${agentSpan.name} (client process)`); +console.log(` ${callSpan.name} (client process)`); +console.log(` ${server.spanName} (server process)`); + +await client.close(); +await tracerProvider.shutdown(); diff --git a/examples/opentelemetry/package.json b/examples/opentelemetry/package.json new file mode 100644 index 0000000000..ae5d7754f5 --- /dev/null +++ b/examples/opentelemetry/package.json @@ -0,0 +1,24 @@ +{ + "name": "@mcp-examples/opentelemetry", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", + "@modelcontextprotocol/otel": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-trace-node": "catalog:otel", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/opentelemetry/server.ts b/examples/opentelemetry/server.ts new file mode 100644 index 0000000000..d5501ba113 --- /dev/null +++ b/examples/opentelemetry/server.ts @@ -0,0 +1,72 @@ +/** + * OpenTelemetry — a traced server. + * + * The application starts the OpenTelemetry SDK; `openTelemetry()` from + * `@modelcontextprotocol/otel` turns every MCP request the server handles into + * a SERVER span, parented on the client's span through `params._meta`. Code in + * a tool runs inside that span, so its own spans nest under it. + * + * The `trace-info` tool reports the span it ran in, so the client can check the + * trace crossed the process boundary. A real server would export its spans with + * an OTLP exporter instead. + */ +import { serve } from '@hono/node-server'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; +import { openTelemetry } from '@modelcontextprotocol/otel'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { trace } from '@opentelemetry/api'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-node'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import * as z from 'zod/v4'; + +// Start the SDK before any MCP traffic: tracer provider, W3C propagators, AsyncLocalStorage context. +// Add `spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())]` to export the spans. +new NodeTracerProvider().register(); + +// One instrumentation, shared by every server instance the factory builds. +const instrumentation = openTelemetry(); + +function buildServer(): McpServer { + const server = new McpServer({ name: 'opentelemetry-example', version: '1.0.0' }, { instrumentation }); + + server.registerTool( + 'trace-info', + { + description: 'Reports the span this tool call ran in', + inputSchema: z.object({}), + outputSchema: z.object({ traceId: z.string(), spanName: z.string(), parentSpanId: z.string() }) + }, + async () => { + // Your own spans nest under the MCP server span. + await trace.getTracer('opentelemetry-example').startActiveSpan('look up forecast', async span => span.end()); + + const active = trace.getActiveSpan() as (ReturnType & Partial) | undefined; + const structuredContent = { + traceId: active?.spanContext().traceId ?? '', + spanName: active?.name ?? '', + parentSpanId: active?.parentSpanContext?.spanId ?? '' + }; + return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent }; + } + ); + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + // `createMcpHonoApp()` binds the endpoint behind localhost host/origin + // validation by default, matching the framework factories' defaults. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/package.json b/examples/package.json index 79b480721a..d274f02ca9 100644 --- a/examples/package.json +++ b/examples/package.json @@ -30,8 +30,12 @@ "@modelcontextprotocol/fastify": "workspace:^", "@modelcontextprotocol/hono": "workspace:^", "@modelcontextprotocol/node": "workspace:^", + "@modelcontextprotocol/otel": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/server-legacy": "workspace:^", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-trace-node": "catalog:otel", "@valibot/to-json-schema": "catalog:devTools", "ajv": "catalog:runtimeShared", "arktype": "catalog:devTools", diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 28288e5c6d..a55f20c7fc 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -23,6 +23,7 @@ "@modelcontextprotocol/fastify": ["./node_modules/@modelcontextprotocol/fastify/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"], + "@modelcontextprotocol/otel": ["./node_modules/@modelcontextprotocol/otel/src/index.ts"], "@modelcontextprotocol/core-internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/index.ts" ], diff --git a/packages/core-internal/test/packageTopologyPins.test.ts b/packages/core-internal/test/packageTopologyPins.test.ts index 5bc882e8e3..f6c527d147 100644 --- a/packages/core-internal/test/packageTopologyPins.test.ts +++ b/packages/core-internal/test/packageTopologyPins.test.ts @@ -55,6 +55,7 @@ const PUBLIC_PACKAGES: Record Experimental. The `instrumentation` option and the MCP semantic conventions can change in a minor release. + +## Install + +```bash +npm install @modelcontextprotocol/otel @opentelemetry/api +``` + +The package depends only on the OpenTelemetry API. Your application starts an OpenTelemetry SDK and exporter; without one nothing is recorded. + +## Usage + +```ts +import { Client } from '@modelcontextprotocol/client'; +import { openTelemetry } from '@modelcontextprotocol/otel'; +import { McpServer } from '@modelcontextprotocol/server'; + +const instrumentation = openTelemetry(); + +const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation }); +const client = new Client({ name: 'agent', version: '1.0.0' }, { instrumentation }); +``` + +Every request and notification becomes a span (`CLIENT` on the sender, `SERVER` on the receiver), named `{method}` or `{method} {tool or prompt name}`. Trace context and baggage travel in `params._meta` (SEP-414), so client and server spans join one trace and spans started in a handler nest under the server span. Each instance also records `mcp.client.operation.duration` and `mcp.server.operation.duration`. + +## Options + +| Option | Default | What it does | +| ---------------- | ---------------------- | ----------------------------------------------------------------- | +| `tracerProvider` | global tracer provider | Where spans go | +| `meterProvider` | global meter provider | Where metrics go | +| `propagator` | global propagator | How trace context is written to and read from `params._meta` | +| `captureContent` | `false` | Record tool arguments, tool results and prompt arguments on spans | + +## `error.type` values + +- A JSON-RPC error code as a string, such as `-32603`. On the receiver, `-32700`, `-32600`, `-32601`, `-32602` and `-32002` are not errors. +- `tool_error`: a `tools/call` result with `isError: true`. +- `timeout`, `cancelled`, `connection_closed`. +- An SDK or system error code (`SEND_FAILED`, `INVALID_RESULT`, `ECONNREFUSED`), a standard error name (`TypeError`), or `_OTHER`. + +See the [OpenTelemetry guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/advanced/opentelemetry.md) and the [`examples/opentelemetry`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/opentelemetry) story. diff --git a/packages/otel/eslint.config.mjs b/packages/otel/eslint.config.mjs new file mode 100644 index 0000000000..a3c43f6ac5 --- /dev/null +++ b/packages/otel/eslint.config.mjs @@ -0,0 +1,12 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + settings: { + 'import/internal-regex': '^@modelcontextprotocol/' + } + } +]; diff --git a/packages/otel/package.json b/packages/otel/package.json new file mode 100644 index 0000000000..fdd14c2bf8 --- /dev/null +++ b/packages/otel/package.json @@ -0,0 +1,82 @@ +{ + "name": "@modelcontextprotocol/otel", + "private": false, + "version": "0.0.0", + "description": "OpenTelemetry tracing and metrics for the Model Context Protocol TypeScript SDK, following the MCP semantic conventions", + "license": "Apache-2.0", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "opentelemetry", + "otel", + "tracing", + "metrics" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ test/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ test/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": {}, + "peerDependencies": { + "@opentelemetry/api": "catalog:otel" + }, + "devDependencies": { + "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/client": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/node": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/context-async-hooks": "catalog:otel", + "@opentelemetry/core": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-trace-base": "catalog:otel", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools", + "zod": "catalog:runtimeShared" + } +} diff --git a/packages/otel/src/index.ts b/packages/otel/src/index.ts new file mode 100644 index 0000000000..8c6972c153 --- /dev/null +++ b/packages/otel/src/index.ts @@ -0,0 +1,11 @@ +export type { OpenTelemetryOptions } from './openTelemetry'; +export { openTelemetry } from './openTelemetry'; +export type { + InboundOperation, + OperationBase, + OperationOutcome, + OperationScope, + OutboundOperation, + ProtocolInstrumentation, + RequestId +} from './types'; diff --git a/packages/otel/src/openTelemetry.ts b/packages/otel/src/openTelemetry.ts new file mode 100644 index 0000000000..5add2a67ca --- /dev/null +++ b/packages/otel/src/openTelemetry.ts @@ -0,0 +1,341 @@ +import type { + Attributes, + Context, + Histogram, + Link, + MeterProvider, + Span, + TextMapGetter, + TextMapPropagator, + TracerProvider +} from '@opentelemetry/api'; +import { + context, + defaultTextMapSetter, + isSpanContextValid, + metrics, + propagation, + SpanKind, + SpanStatusCode, + trace +} from '@opentelemetry/api'; + +import type { InboundOperation, OperationBase, OperationOutcome, OutboundOperation, ProtocolInstrumentation } from './types'; + +/** + * Options for {@linkcode openTelemetry}. + */ +export interface OpenTelemetryOptions { + /** + * Where spans go. + * + * @default the global tracer provider + */ + tracerProvider?: TracerProvider; + /** + * Where metrics go. + * + * @default the global meter provider + */ + meterProvider?: MeterProvider; + /** + * How trace context is written to and read from `params._meta`. + * + * @default the global propagator + */ + propagator?: TextMapPropagator; + /** + * Record tool arguments, tool results and prompt arguments on spans + * (`gen_ai.tool.call.arguments`, `gen_ai.tool.call.result`, + * `gen_ai.prompt.variable.`). These can hold sensitive data. + * + * @default false + */ + captureContent?: boolean; +} + +const SCOPE_NAME = '@modelcontextprotocol/otel'; + +/** Bucket boundaries the MCP semantic conventions specify for every MCP duration histogram. */ +const DURATION_BUCKETS = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300]; + +/** Codes the conventions say a receiver should not treat as errors: the caller sent something it could not serve. */ +const NOT_RECEIVER_ERRORS = new Set([-32_700, -32_600, -32_601, -32_602, -32_002]); +const INVALID_PARAMS = -32_602; + +const RESOURCE_URI_METHODS = new Set(['resources/read', 'resources/subscribe', 'resources/unsubscribe', 'notifications/resources/updated']); + +/** Log notifications are deprecated (SEP-2577) and belong in logs, not spans. */ +const SKIPPED_METHODS = new Set(['notifications/message']); + +const STANDARD_ERROR_NAMES = new Set(['Error', 'TypeError', 'RangeError', 'SyntaxError', 'AbortError', 'TimeoutError']); + +/** Attributes that may go on metrics; everything else is span-only. */ +const METRIC_ATTRIBUTES = [ + 'mcp.method.name', + 'mcp.protocol.version', + 'gen_ai.operation.name', + 'gen_ai.tool.name', + 'gen_ai.prompt.name', + 'network.transport', + 'network.protocol.name' +] as const; + +const metaGetter: TextMapGetter>> = { + keys: carrier => Object.keys(carrier), + get: (carrier, key) => { + const value = carrier[key]; + return typeof value === 'string' ? value : undefined; + } +}; + +type Side = 'client' | 'server'; + +/** + * Traces and measures every MCP request and notification an instance sends or + * receives, following the OpenTelemetry semantic conventions for MCP, and + * propagates trace context through `params._meta`. + * + * Create it once and pass it as the `instrumentation` option of `Client`, + * `McpServer` or `Server`. One instance can be shared by every server a + * `createMcpHandler` factory builds. + * + * @example + * ```ts + * const instrumentation = openTelemetry(); + * const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation }); + * ``` + */ +export function openTelemetry(options: OpenTelemetryOptions = {}): ProtocolInstrumentation { + const { propagator, captureContent = false } = options; + const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); + const tracer = tracerProvider.getTracer(SCOPE_NAME); + + // Created on first use: the global meter provider has no proxy, so instruments made before the SDK starts would stay no-ops. + let durations: Record | undefined; + const duration = (side: Side): Histogram => { + if (durations === undefined) { + const meter = (options.meterProvider ?? metrics.getMeterProvider()).getMeter(SCOPE_NAME); + const histogram = (name: string, description: string) => + meter.createHistogram(name, { unit: 's', description, advice: { explicitBucketBoundaries: DURATION_BUCKETS } }); + durations = { + client: histogram( + 'mcp.client.operation.duration', + 'The duration of the MCP request or notification as observed on the sender from the time it was sent until the response or ack is received.' + ), + server: histogram( + 'mcp.server.operation.duration', + 'MCP request or notification duration as observed on the receiver from the time it was received until the result or ack is sent.' + ) + }; + } + return durations[side]; + }; + + const inject = (ctx: Context, carrier: Record) => + propagator === undefined + ? propagation.inject(ctx, carrier, defaultTextMapSetter) + : propagator.inject(ctx, carrier, defaultTextMapSetter); + const extract = (ctx: Context, carrier: Readonly>) => + propagator === undefined ? propagation.extract(ctx, carrier, metaGetter) : propagator.extract(ctx, carrier, metaGetter); + + const finish = ( + span: Span, + side: Side, + method: string, + target: string | undefined, + metricAttributes: Attributes, + outcome: OperationOutcome, + started: number + ) => { + let errorType: string | undefined; + switch (outcome.type) { + case 'result': { + if (method === 'tools/call' && outcome.result.isError === true) { + errorType = 'tool_error'; + span.setStatus({ code: SpanStatusCode.ERROR }); + } else if (captureContent && method === 'tools/call' && span.isRecording()) { + const value = toJson(outcome.result.structuredContent ?? outcome.result.content); + if (value !== undefined) span.setAttribute('gen_ai.tool.call.result', value); + } + break; + } + case 'error': { + const { code, message } = outcome.error; + span.setAttribute('rpc.response.status_code', String(code)); + metricAttributes['rpc.response.status_code'] = String(code); + if (side === 'client' || !NOT_RECEIVER_ERRORS.has(code)) { + errorType = String(code); + span.setStatus({ code: SpanStatusCode.ERROR, message }); + if (side === 'server' && isUnexpectedException(outcome.cause)) span.recordException(outcome.cause); + } + // An unknown or disabled tool or prompt: keep the client-chosen name out of the span name and metrics. + if (side === 'server' && code === INVALID_PARAMS && target !== undefined) { + span.updateName(method); + delete metricAttributes['gen_ai.tool.name']; + delete metricAttributes['gen_ai.prompt.name']; + } + break; + } + case 'timeout': { + errorType = 'timeout'; + span.setStatus({ code: SpanStatusCode.ERROR }); + break; + } + case 'cancelled': { + errorType = 'cancelled'; + span.setStatus({ code: SpanStatusCode.ERROR }); + break; + } + case 'closed': { + errorType = 'connection_closed'; + span.setStatus({ code: SpanStatusCode.ERROR }); + break; + } + case 'failed': { + errorType = errorTypeOf(outcome.error); + span.setStatus({ code: SpanStatusCode.ERROR, ...(outcome.error instanceof Error && { message: outcome.error.message }) }); + break; + } + case 'delivered': { + break; + } + } + if (errorType !== undefined) { + span.setAttribute('error.type', errorType); + metricAttributes['error.type'] = errorType; + } + span.end(); + duration(side).record((performance.now() - started) / 1000, metricAttributes); + }; + + return { + send(operation: OutboundOperation) { + if (SKIPPED_METHODS.has(operation.method)) return; + // Trace context the caller already put in `_meta` parents the span; otherwise the active context does. + const parent = extract(context.active(), operation.carrier); + const target = targetOf(operation); + const attributes = startAttributes(operation, operation.method, target, captureContent); + const span = tracer.startSpan(spanName(operation.method, target), { kind: SpanKind.CLIENT, attributes }, parent); + const spanContext = trace.setSpan(parent, span); + const injected: Record = {}; + inject(spanContext, injected); + Object.assign(operation.carrier, injected); + const metricAttributes = pickMetricAttributes(attributes); + const started = performance.now(); + return { + run: fn => context.with(spanContext, fn), + end: outcome => finish(span, 'client', operation.method, target, metricAttributes, outcome, started) + }; + }, + + receive(operation: InboundOperation) { + if (SKIPPED_METHODS.has(operation.method)) return; + const active = context.active(); + const parent = operation.meta === undefined ? active : extract(active, operation.meta); + // With a remote parent from `_meta`, the ambient span (an HTTP server span, say) becomes a link. + const ambient = trace.getSpanContext(active); + const remote = trace.getSpanContext(parent); + const links: Link[] = + remote !== undefined && + remote !== ambient && + isSpanContextValid(remote) && + ambient !== undefined && + isSpanContextValid(ambient) + ? [{ context: ambient }] + : []; + // A method nobody handles is a client-chosen string: record it as `_OTHER` to bound cardinality. + const method = operation.handled ? operation.method : '_OTHER'; + const target = operation.handled ? targetOf(operation) : undefined; + const attributes = startAttributes(operation, method, target, captureContent && operation.handled); + if (operation.httpRequest !== undefined) { + attributes['network.protocol.name'] = 'http'; + attributes['network.transport'] = 'tcp'; + } + const span = tracer.startSpan(spanName(method, target), { kind: SpanKind.SERVER, attributes, links }, parent); + const spanContext = trace.setSpan(parent, span); + const metricAttributes = pickMetricAttributes(attributes); + const started = performance.now(); + return { + run: fn => context.with(spanContext, fn), + end: outcome => finish(span, 'server', method, target, metricAttributes, outcome, started) + }; + } + }; +} + +/** The low-cardinality span-name target: the tool or prompt name. */ +function targetOf(operation: OperationBase): string | undefined { + if (operation.method !== 'tools/call' && operation.method !== 'prompts/get') return undefined; + const name = operation.params?.name; + return typeof name === 'string' ? name : undefined; +} + +function spanName(method: string, target: string | undefined): string { + return target === undefined ? method : `${method} ${target}`; +} + +function startAttributes(operation: OperationBase, method: string, target: string | undefined, captureContent: boolean): Attributes { + const attributes: Attributes = { 'mcp.method.name': method }; + if (operation.id !== undefined) attributes['jsonrpc.request.id'] = String(operation.id); + const requestedVersion = operation.method === 'initialize' ? operation.params?.protocolVersion : undefined; + const protocolVersion = operation.protocolVersion ?? (typeof requestedVersion === 'string' ? requestedVersion : undefined); + if (protocolVersion !== undefined) attributes['mcp.protocol.version'] = protocolVersion; + if (operation.sessionId !== undefined) attributes['mcp.session.id'] = operation.sessionId; + if (method === 'tools/call') { + attributes['gen_ai.operation.name'] = 'execute_tool'; + if (target !== undefined) attributes['gen_ai.tool.name'] = target; + if (captureContent) { + const value = toJson(operation.params?.arguments); + if (value !== undefined) attributes['gen_ai.tool.call.arguments'] = value; + } + } + if (method === 'prompts/get') { + if (target !== undefined) attributes['gen_ai.prompt.name'] = target; + const promptArguments = operation.params?.arguments; + if (captureContent && isRecord(promptArguments)) { + for (const [name, value] of Object.entries(promptArguments)) { + attributes[`gen_ai.prompt.variable.${name}`] = typeof value === 'string' ? value : (toJson(value) ?? ''); + } + } + } + if (RESOURCE_URI_METHODS.has(method)) { + const uri = operation.params?.uri; + if (typeof uri === 'string') attributes['mcp.resource.uri'] = uri; + } + return attributes; +} + +function pickMetricAttributes(attributes: Attributes): Attributes { + const picked: Attributes = {}; + for (const key of METRIC_ATTRIBUTES) { + if (attributes[key] !== undefined) picked[key] = attributes[key]; + } + return picked; +} + +/** A thrown value that is not a protocol error (those carry a numeric `code`) and is worth an exception event. */ +function isUnexpectedException(cause: unknown): cause is Error { + return cause instanceof Error && typeof (cause as { code?: unknown }).code !== 'number'; +} + +/** A low-cardinality `error.type` for a local failure: an SDK or system error code, a standard error name, or `_OTHER`. */ +function errorTypeOf(error: unknown): string { + if (!(error instanceof Error)) return '_OTHER'; + const code = (error as { code?: unknown }).code; + if (typeof code === 'string' && /^[A-Z][A-Z0-9_]*$/.test(code)) return code; + return STANDARD_ERROR_NAMES.has(error.name) ? error.name : '_OTHER'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function toJson(value: unknown): string | undefined { + if (value === undefined) return undefined; + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} diff --git a/packages/otel/src/types.ts b/packages/otel/src/types.ts new file mode 100644 index 0000000000..9f2db165bf --- /dev/null +++ b/packages/otel/src/types.ts @@ -0,0 +1,52 @@ +/* + * Structural copies of the experimental instrumentation types that + * `@modelcontextprotocol/client` and `@modelcontextprotocol/server` export, so this + * package needs neither of them at runtime. `test/types.test.ts` checks they stay + * assignable to the SDK's own types. + */ + +/** A JSON-RPC request id. */ +export type RequestId = string | number; + +/** Fields shared by {@linkcode OutboundOperation} and {@linkcode InboundOperation}. */ +export interface OperationBase { + readonly method: string; + readonly id?: RequestId; + readonly params?: Readonly>; + readonly sessionId?: string; + readonly protocolVersion?: string; +} + +/** A request or notification the instrumented instance is sending. */ +export interface OutboundOperation extends OperationBase { + readonly carrier: Record; +} + +/** A request or notification the instrumented instance received. */ +export interface InboundOperation extends OperationBase { + readonly meta?: Readonly>; + readonly handled: boolean; + readonly httpRequest?: Request; +} + +/** How an observed operation settled. */ +export type OperationOutcome = + | { type: 'result'; result: Readonly> } + | { type: 'error'; error: { code: number; message: string }; cause?: unknown } + | { type: 'delivered' } + | { type: 'timeout' } + | { type: 'cancelled' } + | { type: 'closed' } + | { type: 'failed'; error: unknown }; + +/** The lifetime of one observed operation. */ +export interface OperationScope { + run(fn: () => T): T; + end(outcome: OperationOutcome): void; +} + +/** The value `openTelemetry()` returns: pass it as the `instrumentation` option. */ +export interface ProtocolInstrumentation { + send?(operation: OutboundOperation): OperationScope | undefined; + receive?(operation: InboundOperation): OperationScope | undefined; +} diff --git a/packages/otel/test/helpers.ts b/packages/otel/test/helpers.ts new file mode 100644 index 0000000000..21e2df9e59 --- /dev/null +++ b/packages/otel/test/helpers.ts @@ -0,0 +1,67 @@ +import type { Attributes, Tracer } from '@opentelemetry/api'; +import { context } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator } from '@opentelemetry/core'; +import type { DataPoint, Histogram, ResourceMetrics } from '@opentelemetry/sdk-metrics'; +import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; + +import type { OpenTelemetryOptions, ProtocolInstrumentation } from '../src/index'; +import { openTelemetry } from '../src/index'; + +// The context manager is process-global; everything else in a test is local to it. +context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); + +class TestMetricReader extends MetricReader { + protected async onShutdown(): Promise {} + protected async onForceFlush(): Promise {} +} + +/** An OpenTelemetry SDK that keeps spans and metrics in memory, wired into `openTelemetry()` without touching globals. */ +export function telemetry(options: Pick = {}) { + const exporter = new InMemorySpanExporter(); + const tracerProvider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); + const reader = new TestMetricReader(); + const meterProvider = new MeterProvider({ readers: [reader] }); + const propagator = new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()] }); + const instrumentation: ProtocolInstrumentation = openTelemetry({ tracerProvider, meterProvider, propagator, ...options }); + const tracer: Tracer = tracerProvider.getTracer('test'); + + const spans = (): ReadableSpan[] => exporter.getFinishedSpans(); + /** The one finished span with this name and kind; fails when there is not exactly one. */ + const span = (name: string, kind: number): ReadableSpan => { + const matches = spans().filter(s => s.name === name && s.kind === kind); + if (matches.length !== 1) { + throw new Error( + `expected one ${name} span of kind ${kind}, found ${matches.length} in [${spans() + .map(s => `${s.name}/${s.kind}`) + .join(', ')}]` + ); + } + return matches[0]!; + }; + /** Data points recorded for a histogram. */ + const histogram = async (name: string): Promise<{ unit: string; boundaries: number[]; points: DataPoint[] }> => { + const { resourceMetrics } = await reader.collect(); + const metric = findMetric(resourceMetrics, name); + if (metric === undefined) throw new Error(`no ${name} metric was recorded`); + const points = metric.dataPoints as DataPoint[]; + return { unit: metric.descriptor.unit, boundaries: points[0]?.value.buckets.boundaries ?? [], points }; + }; + + return { instrumentation, tracer, spans, span, histogram, propagator }; +} + +function findMetric(resourceMetrics: ResourceMetrics, name: string) { + for (const scope of resourceMetrics.scopeMetrics) { + const metric = scope.metrics.find(m => m.descriptor.name === name); + if (metric !== undefined) return metric; + } + return; +} + +/** Just the attributes of a span, for readable assertions. */ +export function attributesOf(span: ReadableSpan): Attributes { + return { ...span.attributes }; +} diff --git a/packages/otel/test/openTelemetry.test.ts b/packages/otel/test/openTelemetry.test.ts new file mode 100644 index 0000000000..8fca4e8e34 --- /dev/null +++ b/packages/otel/test/openTelemetry.test.ts @@ -0,0 +1,415 @@ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import type { ClientOptions } from '@modelcontextprotocol/client'; +import { Client, InMemoryTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { createMcpHandler, McpServer, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server'; +import { context, propagation, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { openTelemetry } from '../src/index'; +import { attributesOf, telemetry } from './helpers'; + +const cleanups: Array<() => Promise | unknown> = []; +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); +}); + +/** A client and server over in-memory transports, both instrumented. */ +async function connect(t: ReturnType, buildServer: (server: McpServer) => void, clientOptions: ClientOptions = {}) { + const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation: t.instrumentation }); + buildServer(server); + const client = new Client({ name: 'agent', version: '1.0.0' }, { instrumentation: t.instrumentation, ...clientOptions }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + cleanups.push(() => client.close()); + return { client, server }; +} + +function registerGreet(server: McpServer) { + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name}` }], + structuredContent: { greeting: `hello ${name}` } + })); +} + +describe('openTelemetry() spans', () => { + it('emits a CLIENT span and a SERVER child span for a tool call, named after the tool', async () => { + const t = telemetry(); + const { client } = await connect(t, registerGreet); + + await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); + + const clientSpan = t.span('tools/call greet', SpanKind.CLIENT); + const serverSpan = t.span('tools/call greet', SpanKind.SERVER); + expect(serverSpan.spanContext().traceId).toBe(clientSpan.spanContext().traceId); + expect(serverSpan.parentSpanContext?.spanId).toBe(clientSpan.spanContext().spanId); + expect(attributesOf(clientSpan)).toEqual({ + 'mcp.method.name': 'tools/call', + 'gen_ai.operation.name': 'execute_tool', + 'gen_ai.tool.name': 'greet', + 'jsonrpc.request.id': expect.any(String), + 'mcp.protocol.version': '2025-11-25' + }); + expect(attributesOf(serverSpan)).toMatchObject({ + 'mcp.method.name': 'tools/call', + 'gen_ai.tool.name': 'greet', + 'jsonrpc.request.id': clientSpan.attributes['jsonrpc.request.id'] + }); + expect(clientSpan.status.code).toBe(SpanStatusCode.UNSET); + expect(serverSpan.status.code).toBe(SpanStatusCode.UNSET); + }); + + it('parents the client span on the active span and makes the server span active inside the tool', async () => { + const t = telemetry(); + const { client } = await connect(t, server => { + server.registerTool('lookup', { inputSchema: z.object({}) }, async () => { + await t.tracer.startActiveSpan('db.query', async span => span.end()); + return { content: [] }; + }); + }); + + await t.tracer.startActiveSpan('invoke_agent', async agent => { + await client.callTool({ name: 'lookup', arguments: {} }); + agent.end(); + }); + + const agent = t.span('invoke_agent', SpanKind.INTERNAL); + const clientSpan = t.span('tools/call lookup', SpanKind.CLIENT); + const serverSpan = t.span('tools/call lookup', SpanKind.SERVER); + const dbSpan = t.span('db.query', SpanKind.INTERNAL); + expect(clientSpan.parentSpanContext?.spanId).toBe(agent.spanContext().spanId); + expect(serverSpan.parentSpanContext?.spanId).toBe(clientSpan.spanContext().spanId); + expect(dbSpan.parentSpanContext?.spanId).toBe(serverSpan.spanContext().spanId); + }); + + it('traces the handshake and notifications, and skips log notifications', async () => { + const t = telemetry(); + const { client } = await connect(t, server => { + server.server.registerCapabilities({ logging: {} }); + server.registerTool('noisy', { inputSchema: z.object({}) }, async (_args, ctx) => { + await ctx.mcpReq.log('info', 'working'); + return { content: [] }; + }); + }); + await client.setLoggingLevel('info'); + + await client.callTool({ name: 'noisy', arguments: {} }); + + expect(t.span('initialize', SpanKind.CLIENT).attributes['mcp.protocol.version']).toBe('2025-11-25'); + expect(t.span('initialize', SpanKind.SERVER)).toBeDefined(); + expect(t.span('notifications/initialized', SpanKind.CLIENT).attributes['jsonrpc.request.id']).toBeUndefined(); + expect(t.span('notifications/initialized', SpanKind.SERVER)).toBeDefined(); + expect(t.spans().filter(s => s.name === 'notifications/message')).toEqual([]); + }); + + it('marks a tool result with isError as tool_error on both sides', async () => { + const t = telemetry(); + const { client } = await connect(t, server => { + server.registerTool('fail', { inputSchema: z.object({}) }, () => ({ + content: [{ type: 'text', text: 'secret detail' }], + isError: true + })); + }); + + await client.callTool({ name: 'fail', arguments: {} }); + + for (const kind of [SpanKind.CLIENT, SpanKind.SERVER]) { + const span = t.span('tools/call fail', kind); + expect(span.attributes['error.type']).toBe('tool_error'); + expect(span.status).toEqual({ code: SpanStatusCode.ERROR }); + } + }); + + it('treats an unknown tool as a caller error: an error on the client, not on the server, and no tool name in the server span name', async () => { + const t = telemetry(); + const { client } = await connect(t, registerGreet); + + await expect(client.callTool({ name: 'nope', arguments: {} })).rejects.toThrow(/not found/); + + const clientSpan = t.span('tools/call nope', SpanKind.CLIENT); + expect(clientSpan.attributes).toMatchObject({ 'error.type': '-32602', 'rpc.response.status_code': '-32602' }); + expect(clientSpan.status.code).toBe(SpanStatusCode.ERROR); + const serverSpan = t.span('tools/call', SpanKind.SERVER); + expect(serverSpan.attributes['rpc.response.status_code']).toBe('-32602'); + expect(serverSpan.attributes['error.type']).toBeUndefined(); + expect(serverSpan.status.code).toBe(SpanStatusCode.UNSET); + }); + + it('records an exception event when a handler throws something other than a protocol error', async () => { + const t = telemetry(); + const { client } = await connect(t, server => { + server.server.setRequestHandler('acme/boom', { params: z.object({}) }, () => { + throw new Error('database unreachable'); + }); + server.server.setRequestHandler('acme/refuse', { params: z.object({}) }, () => { + throw new ProtocolError(-32_001, 'quota exceeded'); + }); + }); + + await expect(client.request({ method: 'acme/boom', params: {} }, z.object({}))).rejects.toThrow('database unreachable'); + await expect(client.request({ method: 'acme/refuse', params: {} }, z.object({}))).rejects.toThrow('quota exceeded'); + + const boom = t.span('acme/boom', SpanKind.SERVER); + expect(boom.attributes['error.type']).toBe(String(ProtocolErrorCode.InternalError)); + expect(boom.status).toEqual({ code: SpanStatusCode.ERROR, message: 'database unreachable' }); + expect(boom.events.map(e => [e.name, e.attributes?.['exception.message']])).toEqual([['exception', 'database unreachable']]); + const refuse = t.span('acme/refuse', SpanKind.SERVER); + expect(refuse.attributes['error.type']).toBe('-32001'); + expect(refuse.events).toEqual([]); + }); + + it('records a method the server does not handle as _OTHER', async () => { + const t = telemetry(); + const { client } = await connect(t, registerGreet); + + await expect(client.request({ method: 'attacker/chosen-name', params: {} }, z.object({}))).rejects.toThrow(); + + const serverSpan = t.span('_OTHER', SpanKind.SERVER); + expect(serverSpan.attributes['mcp.method.name']).toBe('_OTHER'); + expect(serverSpan.attributes['rpc.response.status_code']).toBe('-32601'); + expect(t.span('attacker/chosen-name', SpanKind.CLIENT).attributes['error.type']).toBe('-32601'); + }); + + it('names prompt spans after the prompt and records resource URIs on resource spans', async () => { + const t = telemetry(); + const { client } = await connect(t, server => { + server.registerPrompt('review', { argsSchema: z.object({ language: z.string() }) }, ({ language }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `review this ${language}` } }] + })); + server.registerResource('readme', 'file:///readme.md', {}, uri => ({ contents: [{ uri: uri.href, text: '# hi' }] })); + }); + + await client.getPrompt({ name: 'review', arguments: { language: 'TypeScript' } }); + await client.readResource({ uri: 'file:///readme.md' }); + + expect(t.span('prompts/get review', SpanKind.CLIENT).attributes['gen_ai.prompt.name']).toBe('review'); + expect(t.span('prompts/get review', SpanKind.SERVER).attributes['gen_ai.prompt.variable.language']).toBeUndefined(); + expect(t.span('resources/read', SpanKind.SERVER).attributes['mcp.resource.uri']).toBe('file:///readme.md'); + }); + + it('records no tool arguments, tool results or prompt arguments by default', async () => { + const t = telemetry(); + const { client } = await connect(t, registerGreet); + + await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); + + const recorded = JSON.stringify(t.spans().map(s => s.attributes)); + expect(recorded).not.toContain('Ada'); + expect(recorded).not.toContain('gen_ai.tool.call'); + }); + + it('records tool arguments, tool results and prompt arguments with captureContent', async () => { + const t = telemetry({ captureContent: true }); + const { client } = await connect(t, server => { + registerGreet(server); + server.registerPrompt('review', { argsSchema: z.object({ language: z.string() }) }, () => ({ messages: [] })); + }); + + await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); + await client.getPrompt({ name: 'review', arguments: { language: 'TypeScript' } }); + + for (const kind of [SpanKind.CLIENT, SpanKind.SERVER]) { + expect(t.span('tools/call greet', kind).attributes).toMatchObject({ + 'gen_ai.tool.call.arguments': '{"name":"Ada"}', + 'gen_ai.tool.call.result': '{"greeting":"hello Ada"}' + }); + expect(t.span('prompts/get review', kind).attributes['gen_ai.prompt.variable.language']).toBe('TypeScript'); + } + }); + + it('follows a 2025-era elicitation from inside a tool back to the client', async () => { + const t = telemetry(); + const { client } = await connect( + t, + server => { + server.registerTool('confirm', { inputSchema: z.object({}) }, async (_args, ctx) => { + const answer = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Proceed?', + requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } } } + }); + return { content: [{ type: 'text', text: answer.action }] }; + }); + }, + { capabilities: { elicitation: {} } } + ); + client.setRequestHandler('elicitation/create', () => ({ action: 'accept', content: { ok: true } })); + + await client.callTool({ name: 'confirm', arguments: {} }); + + const toolSpan = t.span('tools/call confirm', SpanKind.SERVER); + const askSpan = t.span('elicitation/create', SpanKind.CLIENT); + const answerSpan = t.span('elicitation/create', SpanKind.SERVER); + expect(askSpan.parentSpanContext?.spanId).toBe(toolSpan.spanContext().spanId); + expect(answerSpan.parentSpanContext?.spanId).toBe(askSpan.spanContext().spanId); + }); + + it('ends a timed-out request with error.type timeout', async () => { + const t = telemetry(); + const { client } = await connect(t, server => { + server.registerTool('slow', { inputSchema: z.object({}) }, () => new Promise(() => {})); + }); + + await expect(client.callTool({ name: 'slow', arguments: {} }, { timeout: 20 })).rejects.toThrow(/timed out/); + + const span = t.span('tools/call slow', SpanKind.CLIENT); + expect(span.attributes['error.type']).toBe('timeout'); + expect(span.status.code).toBe(SpanStatusCode.ERROR); + expect(t.span('notifications/cancelled', SpanKind.CLIENT)).toBeDefined(); + }); + + it('parents the client span on trace context the caller put in _meta', async () => { + const t = telemetry(); + const { client } = await connect(t, registerGreet); + const upstream = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + + await client.callTool({ name: 'greet', arguments: { name: 'Ada' }, _meta: { traceparent: upstream } }); + + const clientSpan = t.span('tools/call greet', SpanKind.CLIENT); + expect(clientSpan.spanContext().traceId).toBe('0af7651916cd43dd8448eb211c80319c'); + expect(clientSpan.parentSpanContext?.spanId).toBe('b7ad6b7169203331'); + expect(t.span('tools/call greet', SpanKind.SERVER).parentSpanContext?.spanId).toBe(clientSpan.spanContext().spanId); + }); + + it('propagates baggage through _meta', async () => { + const t = telemetry(); + let baggageInTool: string | undefined; + const { client } = await connect(t, server => { + server.registerTool('whoami', { inputSchema: z.object({}) }, () => { + baggageInTool = propagation.getActiveBaggage()?.getEntry('tenant')?.value; + return { content: [] }; + }); + }); + + const withBaggage = propagation.setBaggage(context.active(), propagation.createBaggage({ tenant: { value: 'acme' } })); + await context.with(withBaggage, () => client.callTool({ name: 'whoami', arguments: {} })); + + expect(baggageInTool).toBe('acme'); + }); +}); + +describe('openTelemetry() metrics', () => { + it('records operation durations with the semconv unit, buckets and low-cardinality attributes', async () => { + const t = telemetry(); + const { client } = await connect(t, server => { + registerGreet(server); + server.registerResource('readme', 'file:///readme.md', {}, uri => ({ contents: [{ uri: uri.href, text: '# hi' }] })); + }); + + await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); + await client.readResource({ uri: 'file:///readme.md' }); + await expect(client.callTool({ name: 'nope', arguments: {} })).rejects.toThrow(); + + const clientDuration = await t.histogram('mcp.client.operation.duration'); + expect(clientDuration.unit).toBe('s'); + expect(clientDuration.boundaries).toEqual([0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300]); + expect(clientDuration.points.map(p => p.attributes)).toEqual( + expect.arrayContaining([ + { + 'mcp.method.name': 'tools/call', + 'gen_ai.operation.name': 'execute_tool', + 'gen_ai.tool.name': 'greet', + 'mcp.protocol.version': '2025-11-25' + }, + { 'mcp.method.name': 'resources/read', 'mcp.protocol.version': '2025-11-25' }, + { + 'mcp.method.name': 'tools/call', + 'gen_ai.operation.name': 'execute_tool', + 'gen_ai.tool.name': 'nope', + 'mcp.protocol.version': '2025-11-25', + 'error.type': '-32602', + 'rpc.response.status_code': '-32602' + } + ]) + ); + + const serverDuration = await t.histogram('mcp.server.operation.duration'); + const serverToolCalls = serverDuration.points.filter(p => p.attributes['mcp.method.name'] === 'tools/call').map(p => p.attributes); + // The rejected tool name stays out of server metrics. + expect(serverToolCalls).toEqual( + expect.arrayContaining([ + { + 'mcp.method.name': 'tools/call', + 'gen_ai.operation.name': 'execute_tool', + 'gen_ai.tool.name': 'greet', + 'mcp.protocol.version': '2025-11-25' + }, + { + 'mcp.method.name': 'tools/call', + 'gen_ai.operation.name': 'execute_tool', + 'mcp.protocol.version': '2025-11-25', + 'rpc.response.status_code': '-32602' + } + ]) + ); + expect(JSON.stringify(serverDuration.points)).not.toContain('nope'); + expect(JSON.stringify(clientDuration.points)).not.toContain('file:///readme.md'); + }); +}); + +describe('openTelemetry() over Streamable HTTP', () => { + it('serves a modern-era call through createMcpHandler: HTTP attributes, and a link to the ambient HTTP span', async () => { + const t = telemetry(); + const instrumentation = t.instrumentation; + const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation }); + registerGreet(server); + return server; + }); + cleanups.push(() => handler.close()); + // Stands in for HTTP instrumentation: each POST gets a span, active while the server handles it. + const fetchWithHttpSpan = (url: string | URL, init?: RequestInit) => + t.tracer.startActiveSpan('POST', { kind: SpanKind.SERVER }, span => + handler.fetch(new Request(url, init)).finally(() => span.end()) + ); + const client = new Client({ name: 'agent', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' }, instrumentation }); + await client.connect(new StreamableHTTPClientTransport(new URL('http://mcp.test/mcp'), { fetch: fetchWithHttpSpan })); + cleanups.push(() => client.close()); + + await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); + + const clientSpan = t.span('tools/call greet', SpanKind.CLIENT); + const serverSpan = t.span('tools/call greet', SpanKind.SERVER); + const httpSpan = t.spans().find(s => s.name === 'POST' && s.parentSpanContext?.spanId === clientSpan.spanContext().spanId)!; + expect(httpSpan).toBeDefined(); + expect(serverSpan.parentSpanContext?.spanId).toBe(clientSpan.spanContext().spanId); + expect(serverSpan.links.map(l => l.context.spanId)).toEqual([httpSpan.spanContext().spanId]); + expect(serverSpan.attributes).toMatchObject({ + 'mcp.protocol.version': '2026-07-28', + 'network.protocol.name': 'http', + 'network.transport': 'tcp' + }); + }); + + it('works over a real HTTP server with the global OpenTelemetry API left unconfigured', async () => { + const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation: openTelemetry() }); + server.registerTool('echo-meta', { inputSchema: z.object({}) }, (_args, ctx) => ({ + content: [{ type: 'text', text: JSON.stringify(ctx.mcpReq._meta?.traceparent ?? null) }] + })); + return server; + }); + const { toNodeHandler } = await import('@modelcontextprotocol/node'); + const httpServer: HttpServer = createServer(toNodeHandler(handler)); + await new Promise(resolve => httpServer.listen(0, '127.0.0.1', resolve)); + cleanups.push(async () => { + await handler.close(); + httpServer.close(); + }); + const address = httpServer.address() as { port: number }; + const client = new Client({ name: 'agent', version: '1.0.0' }, { instrumentation: openTelemetry() }); + await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp`))); + cleanups.push(() => client.close()); + const upstream = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + + const plain = await client.callTool({ name: 'echo-meta', arguments: {} }); + const passthrough = await client.callTool({ name: 'echo-meta', arguments: {}, _meta: { traceparent: upstream } }); + + // No SDK and no propagator registered: nothing is injected, and caller-supplied context passes through untouched. + expect(plain.content).toEqual([{ type: 'text', text: 'null' }]); + expect(passthrough.content).toEqual([{ type: 'text', text: JSON.stringify(upstream) }]); + expect(trace.getActiveSpan()).toBeUndefined(); + }); +}); diff --git a/packages/otel/test/types.test.ts b/packages/otel/test/types.test.ts new file mode 100644 index 0000000000..67d865410d --- /dev/null +++ b/packages/otel/test/types.test.ts @@ -0,0 +1,17 @@ +import type { ProtocolInstrumentation as ClientInstrumentation } from '@modelcontextprotocol/client'; +import type { ProtocolInstrumentation as ServerInstrumentation } from '@modelcontextprotocol/server'; +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import type { ProtocolInstrumentation } from '../src/index'; +import { openTelemetry } from '../src/index'; + +describe('ProtocolInstrumentation', () => { + it('is accepted wherever the client and server packages take an instrumentation', () => { + const instrumentation = openTelemetry(); + expectTypeOf(instrumentation).toExtend(); + expectTypeOf(instrumentation).toExtend(); + expectTypeOf().toExtend(); + expect(typeof instrumentation.send).toBe('function'); + expect(typeof instrumentation.receive).toBe('function'); + }); +}); diff --git a/packages/otel/tsconfig.json b/packages/otel/tsconfig.json new file mode 100644 index 0000000000..47e7d76e53 --- /dev/null +++ b/packages/otel/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/core-internal": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/index.ts" + ], + "@modelcontextprotocol/core/internal": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" + ], + "@modelcontextprotocol/core/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" + ], + "@modelcontextprotocol/core-internal/public": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" + ], + "@modelcontextprotocol/core-internal/ext/skills": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" + ] + } + } +} diff --git a/packages/otel/tsdown.config.ts b/packages/otel/tsdown.config.ts new file mode 100644 index 0000000000..3a32c6199a --- /dev/null +++ b/packages/otel/tsdown.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + failOnWarn: 'ci-only', + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + fixedExtension: true, + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'esnext', + platform: 'neutral', + dts: { + resolver: 'tsc', + compilerOptions: { + paths: {}, + preserveSymlinks: true + } + }, + external: ['@opentelemetry/api'] +}); diff --git a/packages/otel/typedoc.json b/packages/otel/typedoc.json new file mode 100644 index 0000000000..dd70079427 --- /dev/null +++ b/packages/otel/typedoc.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src"], + "entryPointStrategy": "expand", + "exclude": ["**/*.test.ts"], + "navigation": { + "includeGroups": true, + "includeCategories": true + } +} diff --git a/packages/otel/vitest.config.js b/packages/otel/vitest.config.js new file mode 100644 index 0000000000..496fca3200 --- /dev/null +++ b/packages/otel/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea66e0cb5f..f807355643 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,25 @@ catalogs: wrangler: specifier: ^4.14.4 version: 4.78.0 + otel: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/context-async-hooks': + specifier: ^2.12.0 + version: 2.12.0 + '@opentelemetry/core': + specifier: ^2.12.0 + version: 2.12.0 + '@opentelemetry/sdk-metrics': + specifier: ^2.12.0 + version: 2.12.0 + '@opentelemetry/sdk-trace-base': + specifier: ^2.12.0 + version: 2.12.0 + '@opentelemetry/sdk-trace-node': + specifier: ^2.12.0 + version: 2.12.0 runtimeClientOnly: cross-spawn: specifier: ^7.0.5 @@ -328,12 +347,24 @@ importers: '@modelcontextprotocol/node': specifier: workspace:^ version: link:../packages/middleware/node + '@modelcontextprotocol/otel': + specifier: workspace:^ + version: link:../packages/otel '@modelcontextprotocol/server': specifier: workspace:^ version: link:../packages/server '@modelcontextprotocol/server-legacy': specifier: workspace:^ version: link:../packages/server-legacy + '@opentelemetry/api': + specifier: catalog:otel + version: 1.9.1 + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': + specifier: catalog:otel + version: 2.12.0(@opentelemetry/api@1.9.1) '@valibot/to-json-schema': specifier: catalog:devTools version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) @@ -806,6 +837,43 @@ importers: specifier: catalog:devTools version: 4.21.0 + examples/opentelemetry: + dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 2.1.5(hono@4.12.9) + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/hono': + specifier: workspace:* + version: link:../../packages/middleware/hono + '@modelcontextprotocol/otel': + specifier: workspace:* + version: link:../../packages/otel + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + '@opentelemetry/api': + specifier: catalog:otel + version: 1.9.1 + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': + specifier: catalog:otel + version: 2.12.0(@opentelemetry/api@1.9.1) + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/parallel-calls: dependencies: '@hono/node-server': @@ -1807,6 +1875,75 @@ importers: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + packages/otel: + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../client + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/node': + specifier: workspace:^ + version: link:../middleware/node + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../server + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@opentelemetry/api': + specifier: catalog:otel + version: 1.9.1 + '@opentelemetry/context-async-hooks': + specifier: catalog:otel + version: 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': + specifier: catalog:otel + version: 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': + specifier: catalog:otel + version: 2.12.0(@opentelemetry/api@1.9.1) + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + packages/server: dependencies: '@modelcontextprotocol/core': @@ -3371,6 +3508,50 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} + '@opentelemetry/context-async-hooks@2.12.0': + resolution: {integrity: sha512-iK/1nD7mmZVkuYjVjkdFBiCvzntJdFny1S3CYbUYyOq7V5enL6XX+gJ7eh965L1LnS11VA8mQ7Ej/ck04SB0MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.12.0': + resolution: {integrity: sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/resources@2.12.0': + resolution: {integrity: sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.12.0': + resolution: {integrity: sha512-mVMaEHwCUJuY3S4Esn+nT0yQA3qCEGh+SOPJsXygU0Gl453WcsDNjtEa8EfdewMwoLyr1tlofB4fXytVpiXuow==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.12.0': + resolution: {integrity: sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==} + engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.12.0': + resolution: {integrity: sha512-9DufH1IlmQMoKAfot5y/FhTP1RnmhLb5K6eKmJO/bvzPjYwPAazoiAUbTZFdPbqxOXN0FEbhZx/e6SB1Og4jmg==} + engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.12.0': + resolution: {integrity: sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/semantic-conventions@1.40.0': resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} @@ -8033,8 +8214,50 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 - '@opentelemetry/api@1.9.1': - optional: true + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/context-async-hooks@2.12.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resources@2.12.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-metrics@2.12.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-trace-node@2.12.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.12.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace@2.12.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/semantic-conventions@1.40.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 64875347ec..de95dcae38 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,6 +46,13 @@ catalogs: fastify: ^5.2.0 hono: ^4.11.4 raw-body: ^3.0.0 + otel: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/context-async-hooks': ^2.12.0 + '@opentelemetry/core': ^2.12.0 + '@opentelemetry/sdk-metrics': ^2.12.0 + '@opentelemetry/sdk-trace-base': ^2.12.0 + '@opentelemetry/sdk-trace-node': ^2.12.0 runtimeShared: '@cfworker/json-schema': ^4.1.1 ajv: ^8.17.1 From 0df12e6894a676d5f766a88c756cc916ade26279 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 19:48:59 +0000 Subject: [PATCH 3/6] feat: built-in OpenTelemetry, no option and no separate package 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. --- .changeset/otel-package.md | 5 - .changeset/protocol-instrumentation.md | 2 +- .github/workflows/publish.yml | 2 +- README.md | 2 - docs/_meta/specs/otel.md | 15 +- docs/advanced/opentelemetry.md | 114 +-- docs/get-started/packages.md | 5 +- examples/README.md | 2 +- .../guides/advanced/opentelemetry.examples.ts | 72 +- examples/opentelemetry/README.md | 5 +- examples/opentelemetry/client.ts | 15 +- examples/opentelemetry/package.json | 5 +- examples/opentelemetry/server.ts | 30 +- examples/package.json | 5 +- examples/tsconfig.json | 1 - packages/client/package.json | 1 + packages/client/src/client/client.ts | 3 + packages/client/src/client/openTelemetry.ts | 9 + packages/core-internal/package.json | 1 + .../core-internal/src/exports/public/index.ts | 10 - packages/core-internal/src/index.ts | 2 + .../src/shared}/openTelemetry.ts | 142 +-- packages/core-internal/src/shared/protocol.ts | 32 +- .../test/packageTopologyPins.test.ts | 1 - .../test/shared/instrumentation.test.ts | 71 +- packages/otel/README.md | 46 - packages/otel/eslint.config.mjs | 12 - packages/otel/package.json | 82 -- packages/otel/src/index.ts | 11 - packages/otel/src/types.ts | 52 - packages/otel/test/helpers.ts | 67 -- packages/otel/test/types.test.ts | 17 - packages/otel/tsconfig.json | 30 - packages/otel/tsdown.config.ts | 21 - packages/otel/typedoc.json | 10 - packages/otel/vitest.config.js | 3 - packages/server/package.json | 1 + packages/server/src/server/openTelemetry.ts | 9 + packages/server/src/server/server.ts | 3 + pnpm-lock.yaml | 960 ++++++++++++++---- pnpm-workspace.yaml | 9 +- test/e2e/package.json | 4 + test/e2e/requirements.ts | 4 +- test/e2e/scenarios/observability.test.ts | 58 ++ test/e2e/scenarios/protocol.test.ts | 42 - test/integration/package.json | 5 + test/integration/test/instrumentation.test.ts | 148 --- .../test/otel}/openTelemetry.test.ts | 205 ++-- test/integration/test/otel/sdk.ts | 67 ++ test/integration/test/otel/withoutSdk.test.ts | 44 + 50 files changed, 1216 insertions(+), 1246 deletions(-) delete mode 100644 .changeset/otel-package.md create mode 100644 packages/client/src/client/openTelemetry.ts rename packages/{otel/src => core-internal/src/shared}/openTelemetry.ts (72%) delete mode 100644 packages/otel/README.md delete mode 100644 packages/otel/eslint.config.mjs delete mode 100644 packages/otel/package.json delete mode 100644 packages/otel/src/index.ts delete mode 100644 packages/otel/src/types.ts delete mode 100644 packages/otel/test/helpers.ts delete mode 100644 packages/otel/test/types.test.ts delete mode 100644 packages/otel/tsconfig.json delete mode 100644 packages/otel/tsdown.config.ts delete mode 100644 packages/otel/typedoc.json delete mode 100644 packages/otel/vitest.config.js create mode 100644 packages/server/src/server/openTelemetry.ts create mode 100644 test/e2e/scenarios/observability.test.ts delete mode 100644 test/integration/test/instrumentation.test.ts rename {packages/otel/test => test/integration/test/otel}/openTelemetry.test.ts (62%) create mode 100644 test/integration/test/otel/sdk.ts create mode 100644 test/integration/test/otel/withoutSdk.test.ts diff --git a/.changeset/otel-package.md b/.changeset/otel-package.md deleted file mode 100644 index 94b38548cb..0000000000 --- a/.changeset/otel-package.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/otel': minor ---- - -New package: `@modelcontextprotocol/otel`. `openTelemetry()` returns an `instrumentation` for `Client`, `McpServer` and `Server` that emits spans and `mcp.client.operation.duration` / `mcp.server.operation.duration` histograms following the OpenTelemetry semantic conventions for MCP, and propagates W3C trace context and baggage through `params._meta`. Tool arguments, tool results and prompt arguments are recorded only with `captureContent: true`. Experimental while the MCP semantic conventions are in development; `@opentelemetry/api` is a peer dependency. diff --git a/.changeset/protocol-instrumentation.md b/.changeset/protocol-instrumentation.md index a1c354a806..74723cc3ac 100644 --- a/.changeset/protocol-instrumentation.md +++ b/.changeset/protocol-instrumentation.md @@ -4,4 +4,4 @@ '@modelcontextprotocol/server': minor --- -Add an experimental `instrumentation` option to `Client`, `Server` and `McpServer` for tracing and metrics. The SDK calls its `send` hook for every request and notification it sends and its `receive` hook for every one it receives, and ends the returned scope once with how the message settled. Trace-context keys the hook writes go into `params._meta`, and the handler or transport send runs inside the scope. Leaving the option out changes nothing on the wire, and a hook that throws never fails the operation. +`Client`, `Server` and `McpServer` now emit OpenTelemetry spans and `mcp.client.operation.duration` / `mcp.server.operation.duration` histograms for every MCP request and notification, 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. Spans never carry tool arguments, tool results or prompt arguments. `@opentelemetry/api` is a new dependency of `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. The span and attribute names are experimental while the MCP semantic conventions are in development. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5dba15e707..d8db820e13 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -40,4 +40,4 @@ jobs: - name: Publish preview packages run: pnpm dlx pkg-pr-new publish --packageManager=npm --pnpm './packages/core' './packages/server' './packages/server-legacy' './packages/client' - './packages/codemod' './packages/middleware/express' './packages/middleware/fastify' './packages/middleware/hono' './packages/middleware/node' './packages/otel' + './packages/codemod' './packages/middleware/express' './packages/middleware/fastify' './packages/middleware/hono' './packages/middleware/node' diff --git a/README.md b/README.md index 4be32fb4cf..52d9fdac90 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,6 @@ They are intentionally thin adapters: they should not introduce new MCP function - **`@modelcontextprotocol/fastify`**: Fastify helpers (app defaults + Host header validation) - **`@modelcontextprotocol/hono`**: Hono helpers (app defaults + JSON body parsing hook + Host header validation) -The SDK also publishes **`@modelcontextprotocol/otel`**, OpenTelemetry tracing and metrics for clients and servers (see [OpenTelemetry](docs/advanced/opentelemetry.md)). - ## Installation ### Server diff --git a/docs/_meta/specs/otel.md b/docs/_meta/specs/otel.md index 8b55725181..2a01f02425 100644 --- a/docs/_meta/specs/otel.md +++ b/docs/_meta/specs/otel.md @@ -1,6 +1,6 @@ # OpenTelemetry support: design spec -- **Status:** draft for maintainer review. Implemented in [#3115](https://github.com/modelcontextprotocol/typescript-sdk/pull/3115), with the differences listed under [Implementation status](#implementation-status). +- **Status:** draft for maintainer review. A simpler version is implemented in [#3115](https://github.com/modelcontextprotocol/typescript-sdk/pull/3115); see [Implementation status](#implementation-status). - **Tracking issue:** [#1264](https://github.com/modelcontextprotocol/typescript-sdk/issues/1264) ("OpenTelemetry integration out of the box", part of v2). - **Related:** [#2370](https://github.com/modelcontextprotocol/typescript-sdk/pull/2370) (injectable `SdkLogger`), [#2820](https://github.com/modelcontextprotocol/typescript-sdk/pull/2820) (extensions and request middleware), [#2270](https://github.com/modelcontextprotocol/typescript-sdk/pull/2270) (SEP-414 `_meta` key constants), SEP-414, SEP-2577, SEP-2028. - **Base:** `origin/main` at `b415e60b`. File and line references below are against that commit. @@ -9,11 +9,16 @@ This file lives under `docs/_meta/`, which `docs/.vitepress/config.mts` excludes ## Implementation status -#3115 implements this spec in one PR (the hook, `@modelcontextprotocol/otel` with spans, propagation, operation metrics and `captureContent`, the docs page and the `examples/opentelemetry` story). It differs in three places: +[#3115](https://github.com/modelcontextprotocol/typescript-sdk/pull/3115) implements a simpler version of this spec, after feedback from Cloudflare's Workers observability team: the OpenTelemetry API is a no-op that looks for an SDK on `globalThis`, so the library can call it directly, and a runtime supports it by providing that interface. The PR differs from the sections below in these places: -- **No `connect` hook and no session metrics yet.** `Protocol` doesn't know whether it is the client or the server, and open question 7 is unresolved, so `ProtocolInstrumentation` has only `send` and `receive`. `mcp.client.session.duration` and `mcp.server.session.duration` come with the session hook. -- **Hook types are copied structurally** in `packages/otel/src/types.ts`, so the package depends on neither `client` nor `server`. `packages/otel/test/types.test.ts` checks both directions of assignability. This is one answer to open question 5. -- **No stacked PRs.** Everything is in #3115 and can be split along the [rollout](#rollout) lines if reviewers prefer. +- **Built in and on by default, with no option.** `@opentelemetry/api` is a dependency of `client` and `server`, and the MCP mapping lives in `core-internal` (`shared/openTelemetry.ts`). Registering an OpenTelemetry SDK is the whole setup. There is no `instrumentation` option, no `captureContent`, and no separate `@modelcontextprotocol/otel` package. Users who want MCP spans dropped (for example because Sentry's MCP integration already traces them) filter the instrumentation scopes in their SDK. +- **The hook is internal.** `ProtocolInstrumentation` and its call sites in `Protocol` are unchanged, but `Client` and `Server` attach OpenTelemetry through a package-internal setter. Exporting the hook later, for vendors or alternative backends, is non-breaking. +- **Instrumentation scopes** are `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. +- **No Workers backend.** Workers will provide the OpenTelemetry API itself; until then a Workers OpenTelemetry SDK that registers a global tracer provider works. +- **No session hook or session metrics** yet (open question 7). +- **Tool arguments, results and prompt arguments are never recorded.** Opt-in content capture can come back as an option later without a break. + +## Summary ## Summary diff --git a/docs/advanced/opentelemetry.md b/docs/advanced/opentelemetry.md index 1ac252e3d8..822f1e155c 100644 --- a/docs/advanced/opentelemetry.md +++ b/docs/advanced/opentelemetry.md @@ -4,63 +4,57 @@ shape: how-to # OpenTelemetry -`@modelcontextprotocol/otel` traces and measures every MCP request and notification, following the [OpenTelemetry semantic conventions for MCP](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md). Pass `openTelemetry()` as the `instrumentation` option. +`Client`, `McpServer` and `Server` emit OpenTelemetry spans and metrics for every MCP request and notification, 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#openTelemetry_server" -import { openTelemetry } from '@modelcontextprotocol/otel'; -import { McpServer } from '@modelcontextprotocol/server'; -import { trace } from '@opentelemetry/api'; -import * as z from 'zod/v4'; +```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'; -const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation: openTelemetry() }); +// 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 })] }); -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}` }] }; -}); +context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +propagation.setGlobalPropagator(new W3CTraceContextPropagator()); +trace.setGlobalTracerProvider(tracerProvider); +metrics.setGlobalMeterProvider(new MeterProvider({ readers: [metricReader] })); ``` ::: warning Experimental -The `instrumentation` option and `@modelcontextprotocol/otel` are experimental, and so are the MCP semantic conventions they follow. Span and attribute names can change in a minor release. +The MCP semantic conventions are still in development. Span and attribute names can change in a minor release. ::: -## Install the package +## Register an OpenTelemetry SDK -The package uses only the OpenTelemetry API. Your application brings the SDK and an exporter. +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 @modelcontextprotocol/otel @opentelemetry/api @opentelemetry/sdk-trace-node +npm install @opentelemetry/api @opentelemetry/sdk-trace @opentelemetry/context-async-hooks @opentelemetry/core ``` -## Start the OpenTelemetry SDK +The server needs no telemetry configuration of its own. -Register a tracer provider once, before any MCP traffic. `NodeTracerProvider.register()` also installs the W3C propagators and an `AsyncLocalStorage` context manager, which makes the MCP span active inside your handlers. +```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#server_traced" +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; -```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#sdk_setup" -import { InMemorySpanExporter, NodeTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node'; +const server = new McpServer({ name: 'weather', version: '1.0.0' }); -// Swap the in-memory exporter for an OTLP exporter to send spans to a backend. -const exporter = new InMemorySpanExporter(); -const provider = new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); -// Registers the tracer provider, the W3C propagators and an AsyncLocalStorage context manager. -provider.register(); +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}` }] }; +}); ``` -Without a registered SDK, `openTelemetry()` records nothing and leaves `params._meta` untouched. +The context manager is what makes the MCP span active inside your handlers, so spans you start there nest under it. -## Trace a client - -The same option works on `Client`. - -```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#openTelemetry_client" -import { Client } from '@modelcontextprotocol/client'; - -const client = new Client({ name: 'agent', version: '1.0.0' }, { instrumentation: openTelemetry() }); -``` +## See the spans Make a call inside a span of your own, here an agent run. @@ -71,7 +65,7 @@ await trace.getTracer('agent').startActiveSpan('invoke_agent', async agentSpan = }); ``` -The client's span carries its trace context to the server in `params._meta`, so both sides land in one trace. The span the tool started nests under the server span: +The client's span carries its trace context to the server in `params._meta`, so both sides land in one trace: ``` invoke_agent (internal) @@ -80,10 +74,10 @@ invoke_agent (internal) fetch forecast (internal) ``` -The server span carries the conventions' attributes (plus `jsonrpc.request.id`, left out here): +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', @@ -97,7 +91,7 @@ Any SDK that follows [SEP-414](https://modelcontextprotocol.io/community/seps/41 ## Read the duration metrics -`openTelemetry()` records `mcp.client.operation.duration` for what an instance sends and `mcp.server.operation.duration` for what it receives, in seconds, with the conventions' bucket boundaries. Register a meter provider the same way as the tracer provider. The tool call above recorded: +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) { @@ -110,24 +104,6 @@ mcp.client.operation.duration (s) { Metrics never carry session ids, request ids or resource URIs. A server records a method it has no handler for as `_OTHER`, and leaves an unknown tool or prompt name out, so a client can't grow your metric cardinality. -## Serve over HTTP - -`createMcpHandler` builds a server per request. Create the instrumentation once and share it. - -```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#openTelemetry_createMcpHandler" -import { createMcpHandler } from '@modelcontextprotocol/server'; - -const instrumentation = openTelemetry(); - -const handler = createMcpHandler(() => { - const perRequest = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation }); - // perRequest.registerTool(...) - return perRequest; -}); -``` - -When HTTP instrumentation is active too, the MCP server span stays a child of the client's MCP span and links to the HTTP server span. - ## Know what counts as an error Failed operations set `error.type` and an `ERROR` status: @@ -137,24 +113,22 @@ Failed operations set `error.type` and an `ERROR` status: - 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. -## Record tool arguments and results +Spans never carry tool arguments, tool results or prompt arguments. -By default spans carry no tool arguments, tool results or prompt arguments. Turn them on with `captureContent` when you need them for debugging. +## Serve over HTTP -```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#openTelemetry_captureContent" -const debugInstrumentation = openTelemetry({ captureContent: true }); -``` +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. -::: warning -Captured content goes to your telemetry backend as is. Tool arguments and results can hold personal data or secrets. +::: 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 -- `openTelemetry()` from `@modelcontextprotocol/otel` goes in the `instrumentation` option of `Client`, `McpServer` or `Server`. -- Your application starts the OpenTelemetry SDK; without one nothing is recorded. +- 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. -- Tool arguments and results are only recorded with `captureContent: true`. +- Spans never carry tool arguments, tool results or prompt arguments. diff --git a/docs/get-started/packages.md b/docs/get-started/packages.md index 0b77a9f25f..40de2c6741 100644 --- a/docs/get-started/packages.md +++ b/docs/get-started/packages.md @@ -26,15 +26,14 @@ npm install @modelcontextprotocol/server # expose tools, resources, prompts npm install @modelcontextprotocol/client # connect to servers and call them ``` -Those two are the starting point for almost everything; a process that plays both roles installs both. The full published set is ten packages: +Those two are the starting point for almost everything; a process that plays both roles installs both. The full published set is nine packages: - `@modelcontextprotocol/server` and `@modelcontextprotocol/client` — the two starting points, one per side. - `@modelcontextprotocol/node`, `@modelcontextprotocol/express`, `@modelcontextprotocol/hono`, `@modelcontextprotocol/fastify` — optional adapters for serving over HTTP. -- `@modelcontextprotocol/otel` — OpenTelemetry tracing and metrics, see [OpenTelemetry](../advanced/opentelemetry.md). - `@modelcontextprotocol/core` — the raw Zod wire schemas. - `@modelcontextprotocol/server-legacy` and `@modelcontextprotocol/codemod` — migration surfaces for v1 code. -An eleventh package in the repository, `@modelcontextprotocol/core-internal`, is private: `server` and `client` bundle it at build time, so it never appears in your dependency tree. +A tenth package in the repository, `@modelcontextprotocol/core-internal`, is private: `server` and `client` bundle it at build time, so it never appears in your dependency tree. ## Keep Node-only code behind the `./stdio` subpath diff --git a/examples/README.md b/examples/README.md index 7be299933a..6563c03f01 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,7 +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) | Traces and duration metrics with `@modelcontextprotocol/otel`; trace context crosses the wire in `params._meta` | stdio + http | dual | +| [`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 | diff --git a/examples/guides/advanced/opentelemetry.examples.ts b/examples/guides/advanced/opentelemetry.examples.ts index 270fe700bc..c5a3e6a9ae 100644 --- a/examples/guides/advanced/opentelemetry.examples.ts +++ b/examples/guides/advanced/opentelemetry.examples.ts @@ -12,37 +12,39 @@ * @module */ /* eslint-disable no-console */ -import { metrics, SpanKind } from '@opentelemetry/api'; import type { DataPoint, Histogram } from '@opentelemetry/sdk-metrics'; import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; -import type { ReadableSpan } from '@opentelemetry/sdk-trace-node'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace'; class CollectingMetricReader extends MetricReader { protected async onShutdown(): Promise {} protected async onForceFlush(): Promise {} } const metricReader = new CollectingMetricReader(); -metrics.setGlobalMeterProvider(new MeterProvider({ readers: [metricReader] })); -// "Start the OpenTelemetry SDK" — the application's job, once, before any MCP traffic. +// "Register an OpenTelemetry SDK" — the application's job, once, before any MCP traffic. //#region sdk_setup -import { InMemorySpanExporter, NodeTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node'; +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 provider = new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); -// Registers the tracer provider, the W3C propagators and an AsyncLocalStorage context manager. -provider.register(); +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 -// "Trace a server" — the page's lead block. -//#region openTelemetry_server -import { openTelemetry } from '@modelcontextprotocol/otel'; +// "Register an OpenTelemetry SDK" — the server needs no telemetry configuration. +//#region server_traced import { McpServer } from '@modelcontextprotocol/server'; -import { trace } from '@opentelemetry/api'; import * as z from 'zod/v4'; -const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation: openTelemetry() }); +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. @@ -52,46 +54,21 @@ server.registerTool('get-weather', { inputSchema: z.object({ city: z.string() }) }); return { content: [{ type: 'text', text: `${city}: ${forecast}` }] }; }); -//#endregion openTelemetry_server - -// "Trace a client" -//#region openTelemetry_client -import { Client } from '@modelcontextprotocol/client'; - -const client = new Client({ name: 'agent', version: '1.0.0' }, { instrumentation: openTelemetry() }); -//#endregion openTelemetry_client - -// "Serve over HTTP" — one instrumentation shared by every per-request server. -//#region openTelemetry_createMcpHandler -import { createMcpHandler } from '@modelcontextprotocol/server'; - -const instrumentation = openTelemetry(); - -const handler = createMcpHandler(() => { - const perRequest = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation }); - // perRequest.registerTool(...) - return perRequest; -}); -//#endregion openTelemetry_createMcpHandler -void handler; - -// "Record tool arguments and results" -//#region openTelemetry_captureContent -const debugInstrumentation = openTelemetry({ captureContent: true }); -//#endregion openTelemetry_captureContent -void debugInstrumentation; +//#endregion server_traced // --------------------------------------------------------------------------- -// Harness (not shown on the page). Connects the client and server in memory, -// makes one tool call inside an agent span, and prints what was recorded. +// 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 { InMemoryTransport } = await import('@modelcontextprotocol/client'); +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(); -// "Trace a client" — the call the page's span tree comes from. +// "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' } }); @@ -115,10 +92,11 @@ 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(stableAttributes); +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[] | undefined)?.find( @@ -128,4 +106,4 @@ if (toolCallPoint === undefined) throw new Error('opentelemetry.md claim failed: console.log(`${clientDuration!.descriptor.name} (${clientDuration!.descriptor.unit})`, toolCallPoint.attributes); await client.close(); -await provider.shutdown(); +await tracerProvider.shutdown(); diff --git a/examples/opentelemetry/README.md b/examples/opentelemetry/README.md index 0d972a6b69..290feac7dc 100644 --- a/examples/opentelemetry/README.md +++ b/examples/opentelemetry/README.md @@ -1,7 +1,6 @@ # opentelemetry -Trace and measure MCP traffic with `@modelcontextprotocol/otel`. Both sides pass `openTelemetry()` as the `instrumentation` option; 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. +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 @@ -16,4 +15,4 @@ trace 4bf92f3577b34da6a3ce929d0e0e4736 tools/call trace-info (server process) ``` -The server and client keep spans in memory to stay self-contained. In an application, add an exporter (for example `OTLPTraceExporter`) to the tracer provider. See [the OpenTelemetry guide](../../docs/advanced/opentelemetry.md). +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). diff --git a/examples/opentelemetry/client.ts b/examples/opentelemetry/client.ts index c713500105..39485b8f63 100644 --- a/examples/opentelemetry/client.ts +++ b/examples/opentelemetry/client.ts @@ -11,15 +11,18 @@ import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { openTelemetry } from '@modelcontextprotocol/otel'; -import { metrics, SpanKind, trace } from '@opentelemetry/api'; +import { context, metrics, propagation, SpanKind, trace } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; -import { InMemorySpanExporter, NodeTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node'; +import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace'; // The client application's OpenTelemetry SDK. In-memory here so the example can assert on what it recorded. const exporter = new InMemorySpanExporter(); -const tracerProvider = new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); -tracerProvider.register(); +const tracerProvider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] }); +context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +propagation.setGlobalPropagator(new W3CTraceContextPropagator()); +trace.setGlobalTracerProvider(tracerProvider); class CollectingMetricReader extends MetricReader { protected async onShutdown(): Promise {} protected async onForceFlush(): Promise {} @@ -31,7 +34,7 @@ const { transport, url, era } = parseExampleArgs(); const client = new Client( { name: 'opentelemetry-example-client', version: '1.0.0' }, - { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' }, instrumentation: openTelemetry() } + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } ); await (transport === 'stdio' diff --git a/examples/opentelemetry/package.json b/examples/opentelemetry/package.json index ae5d7754f5..34f6b93395 100644 --- a/examples/opentelemetry/package.json +++ b/examples/opentelemetry/package.json @@ -11,11 +11,12 @@ "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", "@modelcontextprotocol/hono": "workspace:*", - "@modelcontextprotocol/otel": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "@opentelemetry/api": "catalog:otel", + "@opentelemetry/context-async-hooks": "catalog:otel", + "@opentelemetry/core": "catalog:otel", "@opentelemetry/sdk-metrics": "catalog:otel", - "@opentelemetry/sdk-trace-node": "catalog:otel", + "@opentelemetry/sdk-trace": "catalog:otel", "zod": "catalog:runtimeShared" }, "devDependencies": { diff --git a/examples/opentelemetry/server.ts b/examples/opentelemetry/server.ts index d5501ba113..ba8602bd2b 100644 --- a/examples/opentelemetry/server.ts +++ b/examples/opentelemetry/server.ts @@ -1,10 +1,10 @@ /** * OpenTelemetry — a traced server. * - * The application starts the OpenTelemetry SDK; `openTelemetry()` from - * `@modelcontextprotocol/otel` turns every MCP request the server handles into - * a SERVER span, parented on the client's span through `params._meta`. Code in - * a tool runs inside that span, so its own spans nest under it. + * The application registers an OpenTelemetry SDK and nothing else: the SDK turns + * every MCP request the server handles into a SERVER span, parented on the + * client's span through `params._meta`. Code in a tool runs inside that span, so + * its own spans nest under it. * * The `trace-info` tool reports the span it ran in, so the client can check the * trace crossed the process boundary. A real server would export its spans with @@ -13,23 +13,23 @@ import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; import { createMcpHonoApp } from '@modelcontextprotocol/hono'; -import { openTelemetry } from '@modelcontextprotocol/otel'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; -import { trace } from '@opentelemetry/api'; -import type { ReadableSpan } from '@opentelemetry/sdk-trace-node'; -import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { context, propagation, trace } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace'; +import { TracerProvider } from '@opentelemetry/sdk-trace'; import * as z from 'zod/v4'; -// Start the SDK before any MCP traffic: tracer provider, W3C propagators, AsyncLocalStorage context. -// Add `spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())]` to export the spans. -new NodeTracerProvider().register(); - -// One instrumentation, shared by every server instance the factory builds. -const instrumentation = openTelemetry(); +// Register the OpenTelemetry SDK before any MCP traffic. That is all the MCP server needs. +// Add `spanProcessors: [new BatchSpanProcessor({ exporter: new OTLPTraceExporter() })]` to export the spans. +context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +propagation.setGlobalPropagator(new W3CTraceContextPropagator()); +trace.setGlobalTracerProvider(new TracerProvider()); function buildServer(): McpServer { - const server = new McpServer({ name: 'opentelemetry-example', version: '1.0.0' }, { instrumentation }); + const server = new McpServer({ name: 'opentelemetry-example', version: '1.0.0' }); server.registerTool( 'trace-info', diff --git a/examples/package.json b/examples/package.json index d274f02ca9..e6f711fc1f 100644 --- a/examples/package.json +++ b/examples/package.json @@ -30,12 +30,13 @@ "@modelcontextprotocol/fastify": "workspace:^", "@modelcontextprotocol/hono": "workspace:^", "@modelcontextprotocol/node": "workspace:^", - "@modelcontextprotocol/otel": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/server-legacy": "workspace:^", "@opentelemetry/api": "catalog:otel", + "@opentelemetry/context-async-hooks": "catalog:otel", + "@opentelemetry/core": "catalog:otel", "@opentelemetry/sdk-metrics": "catalog:otel", - "@opentelemetry/sdk-trace-node": "catalog:otel", + "@opentelemetry/sdk-trace": "catalog:otel", "@valibot/to-json-schema": "catalog:devTools", "ajv": "catalog:runtimeShared", "arktype": "catalog:devTools", diff --git a/examples/tsconfig.json b/examples/tsconfig.json index a55f20c7fc..28288e5c6d 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -23,7 +23,6 @@ "@modelcontextprotocol/fastify": ["./node_modules/@modelcontextprotocol/fastify/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"], - "@modelcontextprotocol/otel": ["./node_modules/@modelcontextprotocol/otel/src/index.ts"], "@modelcontextprotocol/core-internal": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/index.ts" ], diff --git a/packages/client/package.json b/packages/client/package.json index df1fd506a9..626562d5d3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -147,6 +147,7 @@ }, "dependencies": { "@modelcontextprotocol/core": "workspace:*", + "@opentelemetry/api": "catalog:otel", "cross-spawn": "catalog:runtimeClientOnly", "eventsource": "catalog:runtimeClientOnly", "eventsource-parser": "catalog:runtimeClientOnly", diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 096a8660e2..db81017a1f 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -76,10 +76,12 @@ import { SdkError, SdkErrorCode, SERVER_INFO_META_KEY, + setProtocolInstrumentation, SUBSCRIPTION_ID_META_KEY, SUPPORTED_MODERN_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal'; +import { defaultInstrumentation } from './openTelemetry'; import type { PriorDiscovery } from './probeClassifier'; import type { CacheMode, CacheScope, ResponseCacheStore } from './responseCache'; import { ClientResponseCache, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } from './responseCache'; @@ -656,6 +658,7 @@ export class Client extends Protocol { options?: ClientOptions ) { super(options); + setProtocolInstrumentation(this, defaultInstrumentation()); this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; diff --git a/packages/client/src/client/openTelemetry.ts b/packages/client/src/client/openTelemetry.ts new file mode 100644 index 0000000000..4118705fc8 --- /dev/null +++ b/packages/client/src/client/openTelemetry.ts @@ -0,0 +1,9 @@ +import type { ProtocolInstrumentation } from '@modelcontextprotocol/core-internal'; +import { openTelemetryInstrumentation } from '@modelcontextprotocol/core-internal'; + +let shared: ProtocolInstrumentation | undefined; + +/** OpenTelemetry instrumentation every `Client` attaches; created on first use and shared. */ +export function defaultInstrumentation(): ProtocolInstrumentation { + return (shared ??= openTelemetryInstrumentation('@modelcontextprotocol/client')); +} diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 7c8ebaf2b8..5e7612b729 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -56,6 +56,7 @@ }, "dependencies": { "@modelcontextprotocol/core": "workspace:*", + "@opentelemetry/api": "catalog:otel", "content-type": "catalog:runtimeShared", "json-schema-typed": "catalog:runtimeShared", "zod": "catalog:runtimeShared" diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index 5087c665f2..bf985d4160 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -45,16 +45,6 @@ export { isJsonContentType } from '../../shared/mediaType'; // Metadata utilities export { getDisplayName } from '../../shared/metadataUtils'; -// Instrumentation types (experimental) -export type { - InboundOperation, - OperationBase, - OperationOutcome, - OperationScope, - OutboundOperation, - ProtocolInstrumentation -} from '../../shared/instrumentation'; - // Protocol types and the Protocol base class export type { BaseContext, diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index c93a604fa1..eb892cc49b 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -10,9 +10,11 @@ export * from './shared/inboundClassification'; export * from './shared/inputRequired'; export * from './shared/inputRequiredDriver'; export * from './shared/inputRequiredEngine'; +export * from './shared/instrumentation'; export * from './shared/mcpParamHeaders'; export * from './shared/mediaType'; export * from './shared/metadataUtils'; +export * from './shared/openTelemetry'; export * from './shared/protocol'; export * from './shared/protocolEras'; export * from './shared/resultCacheHints'; diff --git a/packages/otel/src/openTelemetry.ts b/packages/core-internal/src/shared/openTelemetry.ts similarity index 72% rename from packages/otel/src/openTelemetry.ts rename to packages/core-internal/src/shared/openTelemetry.ts index 5add2a67ca..b75359c567 100644 --- a/packages/otel/src/openTelemetry.ts +++ b/packages/core-internal/src/shared/openTelemetry.ts @@ -1,14 +1,4 @@ -import type { - Attributes, - Context, - Histogram, - Link, - MeterProvider, - Span, - TextMapGetter, - TextMapPropagator, - TracerProvider -} from '@opentelemetry/api'; +import type { Attributes, Context, Histogram, Link, Span, TextMapGetter } from '@opentelemetry/api'; import { context, defaultTextMapSetter, @@ -20,41 +10,7 @@ import { trace } from '@opentelemetry/api'; -import type { InboundOperation, OperationBase, OperationOutcome, OutboundOperation, ProtocolInstrumentation } from './types'; - -/** - * Options for {@linkcode openTelemetry}. - */ -export interface OpenTelemetryOptions { - /** - * Where spans go. - * - * @default the global tracer provider - */ - tracerProvider?: TracerProvider; - /** - * Where metrics go. - * - * @default the global meter provider - */ - meterProvider?: MeterProvider; - /** - * How trace context is written to and read from `params._meta`. - * - * @default the global propagator - */ - propagator?: TextMapPropagator; - /** - * Record tool arguments, tool results and prompt arguments on spans - * (`gen_ai.tool.call.arguments`, `gen_ai.tool.call.result`, - * `gen_ai.prompt.variable.`). These can hold sensitive data. - * - * @default false - */ - captureContent?: boolean; -} - -const SCOPE_NAME = '@modelcontextprotocol/otel'; +import type { InboundOperation, OperationBase, OperationOutcome, OutboundOperation, ProtocolInstrumentation } from './instrumentation'; /** Bucket boundaries the MCP semantic conventions specify for every MCP duration histogram. */ const DURATION_BUCKETS = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300]; @@ -92,30 +48,19 @@ const metaGetter: TextMapGetter>> = { type Side = 'client' | 'server'; /** - * Traces and measures every MCP request and notification an instance sends or - * receives, following the OpenTelemetry semantic conventions for MCP, and - * propagates trace context through `params._meta`. - * - * Create it once and pass it as the `instrumentation` option of `Client`, - * `McpServer` or `Server`. One instance can be shared by every server a - * `createMcpHandler` factory builds. - * - * @example - * ```ts - * const instrumentation = openTelemetry(); - * const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation }); - * ``` + * OpenTelemetry instrumentation for MCP traffic, following the OpenTelemetry semantic conventions + * for MCP: one span per request and notification, the `mcp.client.operation.duration` and + * `mcp.server.operation.duration` histograms, and trace context in `params._meta`. It goes through + * the global OpenTelemetry API, so nothing is recorded until the application registers an SDK. */ -export function openTelemetry(options: OpenTelemetryOptions = {}): ProtocolInstrumentation { - const { propagator, captureContent = false } = options; - const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); - const tracer = tracerProvider.getTracer(SCOPE_NAME); +export function openTelemetryInstrumentation(scopeName: string): ProtocolInstrumentation { + const tracer = trace.getTracer(scopeName); // Created on first use: the global meter provider has no proxy, so instruments made before the SDK starts would stay no-ops. let durations: Record | undefined; const duration = (side: Side): Histogram => { if (durations === undefined) { - const meter = (options.meterProvider ?? metrics.getMeterProvider()).getMeter(SCOPE_NAME); + const meter = metrics.getMeter(scopeName); const histogram = (name: string, description: string) => meter.createHistogram(name, { unit: 's', description, advice: { explicitBucketBoundaries: DURATION_BUCKETS } }); durations = { @@ -132,12 +77,7 @@ export function openTelemetry(options: OpenTelemetryOptions = {}): ProtocolInstr return durations[side]; }; - const inject = (ctx: Context, carrier: Record) => - propagator === undefined - ? propagation.inject(ctx, carrier, defaultTextMapSetter) - : propagator.inject(ctx, carrier, defaultTextMapSetter); - const extract = (ctx: Context, carrier: Readonly>) => - propagator === undefined ? propagation.extract(ctx, carrier, metaGetter) : propagator.extract(ctx, carrier, metaGetter); + const extract = (ctx: Context, carrier: Readonly>) => propagation.extract(ctx, carrier, metaGetter); const finish = ( span: Span, @@ -154,9 +94,6 @@ export function openTelemetry(options: OpenTelemetryOptions = {}): ProtocolInstr if (method === 'tools/call' && outcome.result.isError === true) { errorType = 'tool_error'; span.setStatus({ code: SpanStatusCode.ERROR }); - } else if (captureContent && method === 'tools/call' && span.isRecording()) { - const value = toJson(outcome.result.structuredContent ?? outcome.result.content); - if (value !== undefined) span.setAttribute('gen_ai.tool.call.result', value); } break; } @@ -177,13 +114,9 @@ export function openTelemetry(options: OpenTelemetryOptions = {}): ProtocolInstr } break; } - case 'timeout': { - errorType = 'timeout'; - span.setStatus({ code: SpanStatusCode.ERROR }); - break; - } + case 'timeout': case 'cancelled': { - errorType = 'cancelled'; + errorType = outcome.type; span.setStatus({ code: SpanStatusCode.ERROR }); break; } @@ -212,14 +145,14 @@ export function openTelemetry(options: OpenTelemetryOptions = {}): ProtocolInstr return { send(operation: OutboundOperation) { if (SKIPPED_METHODS.has(operation.method)) return; + const target = targetOf(operation); + const attributes = startAttributes(operation, operation.method, target); // Trace context the caller already put in `_meta` parents the span; otherwise the active context does. const parent = extract(context.active(), operation.carrier); - const target = targetOf(operation); - const attributes = startAttributes(operation, operation.method, target, captureContent); const span = tracer.startSpan(spanName(operation.method, target), { kind: SpanKind.CLIENT, attributes }, parent); const spanContext = trace.setSpan(parent, span); const injected: Record = {}; - inject(spanContext, injected); + propagation.inject(spanContext, injected, defaultTextMapSetter); Object.assign(operation.carrier, injected); const metricAttributes = pickMetricAttributes(attributes); const started = performance.now(); @@ -231,6 +164,14 @@ export function openTelemetry(options: OpenTelemetryOptions = {}): ProtocolInstr receive(operation: InboundOperation) { if (SKIPPED_METHODS.has(operation.method)) return; + // A method nobody handles is a client-chosen string: record it as `_OTHER` to bound cardinality. + const method = operation.handled ? operation.method : '_OTHER'; + const target = operation.handled ? targetOf(operation) : undefined; + const attributes = startAttributes(operation, method, target); + if (operation.httpRequest !== undefined) { + attributes['network.protocol.name'] = 'http'; + attributes['network.transport'] = 'tcp'; + } const active = context.active(); const parent = operation.meta === undefined ? active : extract(active, operation.meta); // With a remote parent from `_meta`, the ambient span (an HTTP server span, say) becomes a link. @@ -244,14 +185,6 @@ export function openTelemetry(options: OpenTelemetryOptions = {}): ProtocolInstr isSpanContextValid(ambient) ? [{ context: ambient }] : []; - // A method nobody handles is a client-chosen string: record it as `_OTHER` to bound cardinality. - const method = operation.handled ? operation.method : '_OTHER'; - const target = operation.handled ? targetOf(operation) : undefined; - const attributes = startAttributes(operation, method, target, captureContent && operation.handled); - if (operation.httpRequest !== undefined) { - attributes['network.protocol.name'] = 'http'; - attributes['network.transport'] = 'tcp'; - } const span = tracer.startSpan(spanName(method, target), { kind: SpanKind.SERVER, attributes, links }, parent); const spanContext = trace.setSpan(parent, span); const metricAttributes = pickMetricAttributes(attributes); @@ -275,7 +208,7 @@ function spanName(method: string, target: string | undefined): string { return target === undefined ? method : `${method} ${target}`; } -function startAttributes(operation: OperationBase, method: string, target: string | undefined, captureContent: boolean): Attributes { +function startAttributes(operation: OperationBase, method: string, target: string | undefined): Attributes { const attributes: Attributes = { 'mcp.method.name': method }; if (operation.id !== undefined) attributes['jsonrpc.request.id'] = String(operation.id); const requestedVersion = operation.method === 'initialize' ? operation.params?.protocolVersion : undefined; @@ -285,20 +218,8 @@ function startAttributes(operation: OperationBase, method: string, target: strin if (method === 'tools/call') { attributes['gen_ai.operation.name'] = 'execute_tool'; if (target !== undefined) attributes['gen_ai.tool.name'] = target; - if (captureContent) { - const value = toJson(operation.params?.arguments); - if (value !== undefined) attributes['gen_ai.tool.call.arguments'] = value; - } - } - if (method === 'prompts/get') { - if (target !== undefined) attributes['gen_ai.prompt.name'] = target; - const promptArguments = operation.params?.arguments; - if (captureContent && isRecord(promptArguments)) { - for (const [name, value] of Object.entries(promptArguments)) { - attributes[`gen_ai.prompt.variable.${name}`] = typeof value === 'string' ? value : (toJson(value) ?? ''); - } - } } + if (method === 'prompts/get' && target !== undefined) attributes['gen_ai.prompt.name'] = target; if (RESOURCE_URI_METHODS.has(method)) { const uri = operation.params?.uri; if (typeof uri === 'string') attributes['mcp.resource.uri'] = uri; @@ -326,16 +247,3 @@ function errorTypeOf(error: unknown): string { if (typeof code === 'string' && /^[A-Z][A-Z0-9_]*$/.test(code)) return code; return STANDARD_ERROR_NAMES.has(error.name) ? error.name : '_OTHER'; } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function toJson(value: unknown): string | undefined { - if (value === undefined) return undefined; - try { - return JSON.stringify(value); - } catch { - return undefined; - } -} diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 20d53926a0..fad0052c0e 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -97,14 +97,6 @@ export type ProtocolOptions = { * e.g., `['notifications/tools/list_changed']` */ debouncedNotificationMethods?: string[]; - - /** - * Observes every request and notification this instance sends and receives, for tracing and - * metrics. Leaving it out costs nothing and leaves the wire unchanged. - * - * @experimental The shape may change in a minor release. - */ - instrumentation?: ProtocolInstrumentation; }; /** @@ -565,6 +557,20 @@ export function setNegotiatedProtocolVersion( writeNegotiatedProtocolVersion(instance, version); } +let writeInstrumentation: (instance: Protocol, instrumentation: ProtocolInstrumentation) => void; + +/** + * Package-internal: attaches the instrumentation that observes a {@linkcode Protocol} + * instance's traffic. `Client` and `Server` attach OpenTelemetry in their constructors. + * Exported on the core internal barrel only — never public API. + */ +export function setProtocolInstrumentation( + instance: Protocol, + instrumentation: ProtocolInstrumentation +): void { + writeInstrumentation(instance, instrumentation); +} + /** * Implements MCP protocol framing on top of a pluggable transport, including * features like request/response linking, notifications, and progress. @@ -597,8 +603,14 @@ export abstract class Protocol { writeNegotiatedProtocolVersion = (instance, version) => { instance._negotiatedProtocolVersion = version; }; + writeInstrumentation = (instance, instrumentation) => { + instance._instrumentation = instrumentation; + }; } + /** Observes this instance's traffic; attached package-internally via `setProtocolInstrumentation`. */ + private _instrumentation?: ProtocolInstrumentation; + protected _supportedProtocolVersions: string[]; /** @@ -1761,7 +1773,7 @@ export abstract class Protocol { /** Starts the instrumentation scope for an outbound message and merges its trace-context carrier into `params._meta`. */ private _startOutbound(message: T): { message: T; scope: GuardedScope | undefined } { - const instrumentation = this._options?.instrumentation; + const instrumentation = this._instrumentation; if (instrumentation?.send === undefined) return { message, scope: undefined }; const params = message.params; const carrier = traceContextCarrier(params?._meta); @@ -1786,7 +1798,7 @@ export abstract class Protocol { handled: boolean, extra?: MessageExtraInfo ): GuardedScope | undefined { - const instrumentation = this._options?.instrumentation; + const instrumentation = this._instrumentation; if (instrumentation?.receive === undefined) return undefined; const params = message.params; const meta = params?._meta; diff --git a/packages/core-internal/test/packageTopologyPins.test.ts b/packages/core-internal/test/packageTopologyPins.test.ts index f6c527d147..5bc882e8e3 100644 --- a/packages/core-internal/test/packageTopologyPins.test.ts +++ b/packages/core-internal/test/packageTopologyPins.test.ts @@ -55,7 +55,6 @@ const PUBLIC_PACKAGES: Record { @@ -56,10 +49,12 @@ function recorder(als = new AsyncLocalStorage()) { return { scopes, instrumentation, als, find }; } -async function pair(options?: { a?: ProtocolOptions; b?: ProtocolOptions }) { +async function pair(instrumentation?: { a?: ProtocolInstrumentation; b?: ProtocolInstrumentation }) { const [ta, tb] = InMemoryTransport.createLinkedPair(); - const a = new TestProtocol(options?.a); - const b = new TestProtocol(options?.b); + const a = new TestProtocol(); + const b = new TestProtocol(); + if (instrumentation?.a) setProtocolInstrumentation(a, instrumentation.a); + if (instrumentation?.b) setProtocolInstrumentation(b, instrumentation.b); await a.connect(ta); await b.connect(tb); return { a, b, ta, tb }; @@ -68,7 +63,7 @@ async function pair(options?: { a?: ProtocolOptions; b?: ProtocolOptions }) { const tick = () => new Promise(resolve => setTimeout(resolve, 0)); const Ok = z.object({ ok: z.boolean() }); -describe('ProtocolOptions.instrumentation', () => { +describe('Protocol instrumentation', () => { it('leaves outbound messages untouched when no instrumentation is configured', async () => { const [ta, tb] = InMemoryTransport.createLinkedPair(); const sent: JSONRPCMessage[] = []; @@ -92,7 +87,7 @@ describe('ProtocolOptions.instrumentation', () => { it('observes a request on both sides and carries the injected trace context to the peer', async () => { const client = recorder(); const server = recorder(client.als); - const { a, b } = await pair({ a: { instrumentation: client.instrumentation }, b: { instrumentation: server.instrumentation } }); + const { a, b } = await pair({ a: client.instrumentation, b: server.instrumentation }); let activeInHandler: string | undefined; let metaInHandler: unknown; b.setRequestHandler('acme/echo', { params: z.object({ v: z.number() }) }, async (_params, ctx) => { @@ -117,7 +112,7 @@ describe('ProtocolOptions.instrumentation', () => { it('prefills the carrier with caller-supplied trace context without mutating the caller params', async () => { const client = recorder(); - const { a, b } = await pair({ a: { instrumentation: client.instrumentation } }); + const { a, b } = await pair({ a: client.instrumentation }); let metaInHandler: unknown; b.setRequestHandler('acme/echo', { params: z.object({}) }, (_params, ctx) => { metaInHandler = ctx.mcpReq._meta; @@ -140,7 +135,7 @@ describe('ProtocolOptions.instrumentation', () => { it('runs the transport send in the outbound scope', async () => { const client = recorder(); - const { a, b, ta } = await pair({ a: { instrumentation: client.instrumentation } }); + const { a, b, ta } = await pair({ a: client.instrumentation }); b.setRequestHandler('acme/echo', { params: z.object({}) }, () => ({ ok: true })); const activeAtSend: (string | undefined)[] = []; const send = ta.send.bind(ta); @@ -158,7 +153,7 @@ describe('ProtocolOptions.instrumentation', () => { it('ends both sides with the JSON-RPC error a handler throws', async () => { const client = recorder(); const server = recorder(); - const { a, b } = await pair({ a: { instrumentation: client.instrumentation }, b: { instrumentation: server.instrumentation } }); + const { a, b } = await pair({ a: client.instrumentation, b: server.instrumentation }); const thrown = new ProtocolError(ProtocolErrorCode.InvalidParams, 'bad input'); b.setRequestHandler('acme/fail', { params: z.object({}) }, () => { throw thrown; @@ -176,7 +171,7 @@ describe('ProtocolOptions.instrumentation', () => { it('reports an unhandled method with handled: false and a method-not-found error', async () => { const server = recorder(); - const { a } = await pair({ b: { instrumentation: server.instrumentation } }); + const { a } = await pair({ b: server.instrumentation }); await expect(a.request({ method: 'acme/missing', params: {} }, Ok)).rejects.toThrow(); @@ -190,7 +185,7 @@ describe('ProtocolOptions.instrumentation', () => { it('ends a timed-out request with timeout and observes the cancellation notification', async () => { const client = recorder(); const server = recorder(); - const { a, b } = await pair({ a: { instrumentation: client.instrumentation }, b: { instrumentation: server.instrumentation } }); + const { a, b } = await pair({ a: client.instrumentation, b: server.instrumentation }); b.setRequestHandler( 'acme/slow', { params: z.object({}) }, @@ -208,7 +203,7 @@ describe('ProtocolOptions.instrumentation', () => { it('ends a request aborted by the caller with cancelled', async () => { const client = recorder(); - const { a, b } = await pair({ a: { instrumentation: client.instrumentation } }); + const { a, b } = await pair({ a: client.instrumentation }); b.setRequestHandler('acme/slow', { params: z.object({}) }, () => new Promise(() => {})); const controller = new AbortController(); @@ -222,7 +217,7 @@ describe('ProtocolOptions.instrumentation', () => { it('ends in-flight operations on both sides with closed when the connection drops', async () => { const client = recorder(); const server = recorder(); - const { a, b } = await pair({ a: { instrumentation: client.instrumentation }, b: { instrumentation: server.instrumentation } }); + const { a, b } = await pair({ a: client.instrumentation, b: server.instrumentation }); let started!: () => void; const handlerStarted = new Promise(resolve => (started = resolve)); b.setRequestHandler('acme/slow', { params: z.object({}) }, () => { @@ -241,7 +236,7 @@ describe('ProtocolOptions.instrumentation', () => { it('ends a result that fails validation with failed', async () => { const client = recorder(); - const { a, b } = await pair({ a: { instrumentation: client.instrumentation } }); + const { a, b } = await pair({ a: client.instrumentation }); b.setRequestHandler('acme/echo', { params: z.object({}) }, () => ({ ok: 'not a boolean' })); await expect(a.request({ method: 'acme/echo', params: {} }, Ok)).rejects.toThrow('Invalid result'); @@ -251,7 +246,7 @@ describe('ProtocolOptions.instrumentation', () => { it('ends a failed send with failed', async () => { const client = recorder(); - const { a, ta } = await pair({ a: { instrumentation: client.instrumentation } }); + const { a, ta } = await pair({ a: client.instrumentation }); const failure = new Error('socket gone'); ta.send = () => Promise.reject(failure); @@ -265,7 +260,7 @@ describe('ProtocolOptions.instrumentation', () => { it('observes notifications on both sides, including a handler that throws', async () => { const client = recorder(); const server = recorder(); - const { a, b } = await pair({ a: { instrumentation: client.instrumentation }, b: { instrumentation: server.instrumentation } }); + const { a, b } = await pair({ a: client.instrumentation, b: server.instrumentation }); const errors: Error[] = []; b.onerror = error => errors.push(error); let activeInHandler: string | undefined; @@ -295,23 +290,19 @@ describe('ProtocolOptions.instrumentation', () => { const errors: Error[] = []; const { a, b } = await pair({ a: { - instrumentation: { - send: () => { - throw new Error('send hook broke'); - } + send: () => { + throw new Error('send hook broke'); } }, b: { - instrumentation: { - receive: () => ({ - run: () => { - throw new Error('run broke'); - }, - end: () => { - throw new Error('end broke'); - } - }) - } + receive: () => ({ + run: () => { + throw new Error('run broke'); + }, + end: () => { + throw new Error('end broke'); + } + }) } }); a.onerror = error => errors.push(error); @@ -334,7 +325,7 @@ describe('ProtocolOptions.instrumentation', () => { it('runs the work itself when scope.run never calls it', async () => { const errors: Error[] = []; - const { a, b } = await pair({ b: { instrumentation: { receive: () => ({ run: () => undefined as never, end: () => {} }) } } }); + const { a, b } = await pair({ b: { receive: () => ({ run: () => undefined as never, end: () => {} }) } }); b.onerror = error => errors.push(error); let calls = 0; b.setRequestHandler('acme/echo', { params: z.object({}) }, () => { diff --git a/packages/otel/README.md b/packages/otel/README.md deleted file mode 100644 index c734072376..0000000000 --- a/packages/otel/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# `@modelcontextprotocol/otel` - -OpenTelemetry tracing and metrics for the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk), following the [OpenTelemetry semantic conventions for MCP](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md). - -> Experimental. The `instrumentation` option and the MCP semantic conventions can change in a minor release. - -## Install - -```bash -npm install @modelcontextprotocol/otel @opentelemetry/api -``` - -The package depends only on the OpenTelemetry API. Your application starts an OpenTelemetry SDK and exporter; without one nothing is recorded. - -## Usage - -```ts -import { Client } from '@modelcontextprotocol/client'; -import { openTelemetry } from '@modelcontextprotocol/otel'; -import { McpServer } from '@modelcontextprotocol/server'; - -const instrumentation = openTelemetry(); - -const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation }); -const client = new Client({ name: 'agent', version: '1.0.0' }, { instrumentation }); -``` - -Every request and notification becomes a span (`CLIENT` on the sender, `SERVER` on the receiver), named `{method}` or `{method} {tool or prompt name}`. Trace context and baggage travel in `params._meta` (SEP-414), so client and server spans join one trace and spans started in a handler nest under the server span. Each instance also records `mcp.client.operation.duration` and `mcp.server.operation.duration`. - -## Options - -| Option | Default | What it does | -| ---------------- | ---------------------- | ----------------------------------------------------------------- | -| `tracerProvider` | global tracer provider | Where spans go | -| `meterProvider` | global meter provider | Where metrics go | -| `propagator` | global propagator | How trace context is written to and read from `params._meta` | -| `captureContent` | `false` | Record tool arguments, tool results and prompt arguments on spans | - -## `error.type` values - -- A JSON-RPC error code as a string, such as `-32603`. On the receiver, `-32700`, `-32600`, `-32601`, `-32602` and `-32002` are not errors. -- `tool_error`: a `tools/call` result with `isError: true`. -- `timeout`, `cancelled`, `connection_closed`. -- An SDK or system error code (`SEND_FAILED`, `INVALID_RESULT`, `ECONNREFUSED`), a standard error name (`TypeError`), or `_OTHER`. - -See the [OpenTelemetry guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/advanced/opentelemetry.md) and the [`examples/opentelemetry`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/opentelemetry) story. diff --git a/packages/otel/eslint.config.mjs b/packages/otel/eslint.config.mjs deleted file mode 100644 index a3c43f6ac5..0000000000 --- a/packages/otel/eslint.config.mjs +++ /dev/null @@ -1,12 +0,0 @@ -// @ts-check - -import baseConfig from '@modelcontextprotocol/eslint-config'; - -export default [ - ...baseConfig, - { - settings: { - 'import/internal-regex': '^@modelcontextprotocol/' - } - } -]; diff --git a/packages/otel/package.json b/packages/otel/package.json deleted file mode 100644 index fdd14c2bf8..0000000000 --- a/packages/otel/package.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "name": "@modelcontextprotocol/otel", - "private": false, - "version": "0.0.0", - "description": "OpenTelemetry tracing and metrics for the Model Context Protocol TypeScript SDK, following the MCP semantic conventions", - "license": "Apache-2.0", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "opentelemetry", - "otel", - "tracing", - "metrics" - ], - "exports": { - ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - } - }, - "main": "./dist/index.cjs", - "types": "./dist/index.d.mts", - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build", - "lint": "eslint src/ test/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ test/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": {}, - "peerDependencies": { - "@opentelemetry/api": "catalog:otel" - }, - "devDependencies": { - "@eslint/js": "catalog:devTools", - "@modelcontextprotocol/client": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/node": "workspace:^", - "@modelcontextprotocol/server": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@opentelemetry/api": "catalog:otel", - "@opentelemetry/context-async-hooks": "catalog:otel", - "@opentelemetry/core": "catalog:otel", - "@opentelemetry/sdk-metrics": "catalog:otel", - "@opentelemetry/sdk-trace-base": "catalog:otel", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsdown": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools", - "zod": "catalog:runtimeShared" - } -} diff --git a/packages/otel/src/index.ts b/packages/otel/src/index.ts deleted file mode 100644 index 8c6972c153..0000000000 --- a/packages/otel/src/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type { OpenTelemetryOptions } from './openTelemetry'; -export { openTelemetry } from './openTelemetry'; -export type { - InboundOperation, - OperationBase, - OperationOutcome, - OperationScope, - OutboundOperation, - ProtocolInstrumentation, - RequestId -} from './types'; diff --git a/packages/otel/src/types.ts b/packages/otel/src/types.ts deleted file mode 100644 index 9f2db165bf..0000000000 --- a/packages/otel/src/types.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Structural copies of the experimental instrumentation types that - * `@modelcontextprotocol/client` and `@modelcontextprotocol/server` export, so this - * package needs neither of them at runtime. `test/types.test.ts` checks they stay - * assignable to the SDK's own types. - */ - -/** A JSON-RPC request id. */ -export type RequestId = string | number; - -/** Fields shared by {@linkcode OutboundOperation} and {@linkcode InboundOperation}. */ -export interface OperationBase { - readonly method: string; - readonly id?: RequestId; - readonly params?: Readonly>; - readonly sessionId?: string; - readonly protocolVersion?: string; -} - -/** A request or notification the instrumented instance is sending. */ -export interface OutboundOperation extends OperationBase { - readonly carrier: Record; -} - -/** A request or notification the instrumented instance received. */ -export interface InboundOperation extends OperationBase { - readonly meta?: Readonly>; - readonly handled: boolean; - readonly httpRequest?: Request; -} - -/** How an observed operation settled. */ -export type OperationOutcome = - | { type: 'result'; result: Readonly> } - | { type: 'error'; error: { code: number; message: string }; cause?: unknown } - | { type: 'delivered' } - | { type: 'timeout' } - | { type: 'cancelled' } - | { type: 'closed' } - | { type: 'failed'; error: unknown }; - -/** The lifetime of one observed operation. */ -export interface OperationScope { - run(fn: () => T): T; - end(outcome: OperationOutcome): void; -} - -/** The value `openTelemetry()` returns: pass it as the `instrumentation` option. */ -export interface ProtocolInstrumentation { - send?(operation: OutboundOperation): OperationScope | undefined; - receive?(operation: InboundOperation): OperationScope | undefined; -} diff --git a/packages/otel/test/helpers.ts b/packages/otel/test/helpers.ts deleted file mode 100644 index 21e2df9e59..0000000000 --- a/packages/otel/test/helpers.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Attributes, Tracer } from '@opentelemetry/api'; -import { context } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator } from '@opentelemetry/core'; -import type { DataPoint, Histogram, ResourceMetrics } from '@opentelemetry/sdk-metrics'; -import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; -import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; - -import type { OpenTelemetryOptions, ProtocolInstrumentation } from '../src/index'; -import { openTelemetry } from '../src/index'; - -// The context manager is process-global; everything else in a test is local to it. -context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); - -class TestMetricReader extends MetricReader { - protected async onShutdown(): Promise {} - protected async onForceFlush(): Promise {} -} - -/** An OpenTelemetry SDK that keeps spans and metrics in memory, wired into `openTelemetry()` without touching globals. */ -export function telemetry(options: Pick = {}) { - const exporter = new InMemorySpanExporter(); - const tracerProvider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); - const reader = new TestMetricReader(); - const meterProvider = new MeterProvider({ readers: [reader] }); - const propagator = new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()] }); - const instrumentation: ProtocolInstrumentation = openTelemetry({ tracerProvider, meterProvider, propagator, ...options }); - const tracer: Tracer = tracerProvider.getTracer('test'); - - const spans = (): ReadableSpan[] => exporter.getFinishedSpans(); - /** The one finished span with this name and kind; fails when there is not exactly one. */ - const span = (name: string, kind: number): ReadableSpan => { - const matches = spans().filter(s => s.name === name && s.kind === kind); - if (matches.length !== 1) { - throw new Error( - `expected one ${name} span of kind ${kind}, found ${matches.length} in [${spans() - .map(s => `${s.name}/${s.kind}`) - .join(', ')}]` - ); - } - return matches[0]!; - }; - /** Data points recorded for a histogram. */ - const histogram = async (name: string): Promise<{ unit: string; boundaries: number[]; points: DataPoint[] }> => { - const { resourceMetrics } = await reader.collect(); - const metric = findMetric(resourceMetrics, name); - if (metric === undefined) throw new Error(`no ${name} metric was recorded`); - const points = metric.dataPoints as DataPoint[]; - return { unit: metric.descriptor.unit, boundaries: points[0]?.value.buckets.boundaries ?? [], points }; - }; - - return { instrumentation, tracer, spans, span, histogram, propagator }; -} - -function findMetric(resourceMetrics: ResourceMetrics, name: string) { - for (const scope of resourceMetrics.scopeMetrics) { - const metric = scope.metrics.find(m => m.descriptor.name === name); - if (metric !== undefined) return metric; - } - return; -} - -/** Just the attributes of a span, for readable assertions. */ -export function attributesOf(span: ReadableSpan): Attributes { - return { ...span.attributes }; -} diff --git a/packages/otel/test/types.test.ts b/packages/otel/test/types.test.ts deleted file mode 100644 index 67d865410d..0000000000 --- a/packages/otel/test/types.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ProtocolInstrumentation as ClientInstrumentation } from '@modelcontextprotocol/client'; -import type { ProtocolInstrumentation as ServerInstrumentation } from '@modelcontextprotocol/server'; -import { describe, expect, expectTypeOf, it } from 'vitest'; - -import type { ProtocolInstrumentation } from '../src/index'; -import { openTelemetry } from '../src/index'; - -describe('ProtocolInstrumentation', () => { - it('is accepted wherever the client and server packages take an instrumentation', () => { - const instrumentation = openTelemetry(); - expectTypeOf(instrumentation).toExtend(); - expectTypeOf(instrumentation).toExtend(); - expectTypeOf().toExtend(); - expect(typeof instrumentation.send).toBe('function'); - expect(typeof instrumentation.receive).toBe('function'); - }); -}); diff --git a/packages/otel/tsconfig.json b/packages/otel/tsconfig.json deleted file mode 100644 index 47e7d76e53..0000000000 --- a/packages/otel/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "paths": { - "*": ["./*"], - "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], - "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], - "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], - "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], - "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], - "@modelcontextprotocol/core-internal": [ - "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/index.ts" - ], - "@modelcontextprotocol/core/internal": [ - "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/internal.ts" - ], - "@modelcontextprotocol/core/ext/skills": [ - "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/ext/skills/index.ts" - ], - "@modelcontextprotocol/core-internal/public": [ - "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" - ], - "@modelcontextprotocol/core-internal/ext/skills": [ - "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/ext/skills/index.ts" - ] - } - } -} diff --git a/packages/otel/tsdown.config.ts b/packages/otel/tsdown.config.ts deleted file mode 100644 index 3a32c6199a..0000000000 --- a/packages/otel/tsdown.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - failOnWarn: 'ci-only', - entry: ['src/index.ts'], - format: ['esm', 'cjs'], - fixedExtension: true, - outDir: 'dist', - clean: true, - sourcemap: true, - target: 'esnext', - platform: 'neutral', - dts: { - resolver: 'tsc', - compilerOptions: { - paths: {}, - preserveSymlinks: true - } - }, - external: ['@opentelemetry/api'] -}); diff --git a/packages/otel/typedoc.json b/packages/otel/typedoc.json deleted file mode 100644 index dd70079427..0000000000 --- a/packages/otel/typedoc.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://typedoc.org/schema.json", - "entryPoints": ["src"], - "entryPointStrategy": "expand", - "exclude": ["**/*.test.ts"], - "navigation": { - "includeGroups": true, - "includeCategories": true - } -} diff --git a/packages/otel/vitest.config.js b/packages/otel/vitest.config.js deleted file mode 100644 index 496fca3200..0000000000 --- a/packages/otel/vitest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -import baseConfig from '@modelcontextprotocol/vitest-config'; - -export default baseConfig; diff --git a/packages/server/package.json b/packages/server/package.json index e147d8b516..db576e34cc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -160,6 +160,7 @@ }, "dependencies": { "@modelcontextprotocol/core": "workspace:*", + "@opentelemetry/api": "catalog:otel", "zod": "catalog:runtimeShared" }, "devDependencies": { diff --git a/packages/server/src/server/openTelemetry.ts b/packages/server/src/server/openTelemetry.ts new file mode 100644 index 0000000000..afc6beee7d --- /dev/null +++ b/packages/server/src/server/openTelemetry.ts @@ -0,0 +1,9 @@ +import type { ProtocolInstrumentation } from '@modelcontextprotocol/core-internal'; +import { openTelemetryInstrumentation } from '@modelcontextprotocol/core-internal'; + +let shared: ProtocolInstrumentation | undefined; + +/** OpenTelemetry instrumentation every `Server` attaches; created on first use and shared. */ +export function defaultInstrumentation(): ProtocolInstrumentation { + return (shared ??= openTelemetryInstrumentation('@modelcontextprotocol/server')); +} diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 441857f646..914f0f0195 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -60,12 +60,14 @@ import { ProtocolErrorCode, SdkError, SdkErrorCode, + setProtocolInstrumentation, SUPPORTED_MODERN_PROTOCOL_VERSIONS, withRequestStateValue } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; import { coerceEmbeddedInputRequest, LegacyInputRequiredShim, resolveLegacyShimOptions } from './legacyInputRequiredShim'; +import { defaultInstrumentation } from './openTelemetry'; import { perRequestClassificationOf } from './perRequestTransport'; /** @@ -320,6 +322,7 @@ export class Server extends Protocol { options?: ServerOptions ) { super(options); + setProtocolInstrumentation(this, defaultInstrumentation()); this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f807355643..0afcc93b2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,22 +84,19 @@ catalogs: otel: '@opentelemetry/api': specifier: ^1.9.0 - version: 1.9.1 + version: 1.10.0 '@opentelemetry/context-async-hooks': - specifier: ^2.12.0 - version: 2.12.0 + specifier: ^3.0.0 + version: 3.0.0 '@opentelemetry/core': - specifier: ^2.12.0 - version: 2.12.0 + specifier: ^3.0.0 + version: 3.0.0 '@opentelemetry/sdk-metrics': - specifier: ^2.12.0 - version: 2.12.0 - '@opentelemetry/sdk-trace-base': - specifier: ^2.12.0 - version: 2.12.0 - '@opentelemetry/sdk-trace-node': - specifier: ^2.12.0 - version: 2.12.0 + specifier: ^3.0.0 + version: 3.0.0 + '@opentelemetry/sdk-trace': + specifier: ^3.0.0 + version: 3.0.0 runtimeClientOnly: cross-spawn: specifier: ^7.0.5 @@ -257,10 +254,10 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitepress: specifier: ^1.6.4 - version: 1.6.4(@algolia/client-search@5.55.0)(@types/node@24.12.0)(change-case@5.4.4)(postcss@8.5.15)(search-insights@2.17.3)(typescript@5.9.3) + version: 1.6.4(@algolia/client-search@5.55.0)(@types/node@24.12.0)(change-case@5.4.4)(postcss@8.5.31)(search-insights@2.17.3)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@24.12.0)(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3)) zod: specifier: catalog:runtimeShared version: 4.3.6 @@ -347,9 +344,6 @@ importers: '@modelcontextprotocol/node': specifier: workspace:^ version: link:../packages/middleware/node - '@modelcontextprotocol/otel': - specifier: workspace:^ - version: link:../packages/otel '@modelcontextprotocol/server': specifier: workspace:^ version: link:../packages/server @@ -358,13 +352,19 @@ importers: version: link:../packages/server-legacy '@opentelemetry/api': specifier: catalog:otel - version: 1.9.1 + version: 1.10.0 + '@opentelemetry/context-async-hooks': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/core': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) '@opentelemetry/sdk-metrics': specifier: catalog:otel - version: 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-node': + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/sdk-trace': specifier: catalog:otel - version: 2.12.0(@opentelemetry/api@1.9.1) + version: 3.0.0(@opentelemetry/api@1.10.0) '@valibot/to-json-schema': specifier: catalog:devTools version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) @@ -510,7 +510,7 @@ importers: version: 4.21.0 vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) examples/client-quickstart: dependencies: @@ -851,21 +851,24 @@ importers: '@modelcontextprotocol/hono': specifier: workspace:* version: link:../../packages/middleware/hono - '@modelcontextprotocol/otel': - specifier: workspace:* - version: link:../../packages/otel '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server '@opentelemetry/api': specifier: catalog:otel - version: 1.9.1 + version: 1.10.0 + '@opentelemetry/context-async-hooks': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/core': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) '@opentelemetry/sdk-metrics': specifier: catalog:otel - version: 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-node': + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/sdk-trace': specifier: catalog:otel - version: 2.12.0(@opentelemetry/api@1.9.1) + version: 3.0.0(@opentelemetry/api@1.10.0) zod: specifier: catalog:runtimeShared version: 4.3.6 @@ -1096,7 +1099,7 @@ importers: version: link:../../packages/server better-auth: specifier: ^1.6.2 - version: 1.6.20(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)))(vue@3.5.38(typescript@5.9.3)) + version: 1.6.20(@opentelemetry/api@1.10.0)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)))(vue@3.5.38(typescript@5.9.3)) better-sqlite3: specifier: ^12.6.2 version: 12.8.0 @@ -1154,7 +1157,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) examples/skills: dependencies: @@ -1386,6 +1389,9 @@ importers: '@modelcontextprotocol/core': specifier: workspace:* version: link:../core + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 cross-spawn: specifier: catalog:runtimeClientOnly version: 7.0.6 @@ -1467,7 +1473,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/codemod: dependencies: @@ -1522,7 +1528,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/core: dependencies: @@ -1568,13 +1574,16 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/core-internal: dependencies: '@modelcontextprotocol/core': specifier: workspace:* version: link:../core + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 content-type: specifier: catalog:runtimeShared version: 1.0.5 @@ -1647,7 +1656,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) yaml: specifier: ^2.8.3 version: 2.8.3 @@ -1717,7 +1726,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/middleware/fastify: dependencies: @@ -1766,7 +1775,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/middleware/hono: dependencies: @@ -1815,7 +1824,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/middleware/node: dependencies: @@ -1873,82 +1882,16 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) - - packages/otel: - devDependencies: - '@eslint/js': - specifier: catalog:devTools - version: 9.39.4 - '@modelcontextprotocol/client': - specifier: workspace:^ - version: link:../client - '@modelcontextprotocol/eslint-config': - specifier: workspace:^ - version: link:../../common/eslint-config - '@modelcontextprotocol/node': - specifier: workspace:^ - version: link:../middleware/node - '@modelcontextprotocol/server': - specifier: workspace:^ - version: link:../server - '@modelcontextprotocol/tsconfig': - specifier: workspace:^ - version: link:../../common/tsconfig - '@modelcontextprotocol/vitest-config': - specifier: workspace:^ - version: link:../../common/vitest-config - '@opentelemetry/api': - specifier: catalog:otel - version: 1.9.1 - '@opentelemetry/context-async-hooks': - specifier: catalog:otel - version: 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/core': - specifier: catalog:otel - version: 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': - specifier: catalog:otel - version: 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': - specifier: catalog:otel - version: 2.12.0(@opentelemetry/api@1.9.1) - '@typescript/native-preview': - specifier: catalog:devTools - version: 7.0.0-dev.20260327.2 - eslint: - specifier: catalog:devTools - version: 9.39.4 - eslint-config-prettier: - specifier: catalog:devTools - version: 10.1.8(eslint@9.39.4) - eslint-plugin-n: - specifier: catalog:devTools - version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) - prettier: - specifier: catalog:devTools - version: 3.6.2 - tsdown: - specifier: catalog:devTools - version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) - typescript: - specifier: catalog:devTools - version: 5.9.3 - typescript-eslint: - specifier: catalog:devTools - version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) - vitest: - specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) - zod: - specifier: catalog:runtimeShared - version: 4.3.6 + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/server: dependencies: '@modelcontextprotocol/core': specifier: workspace:* version: link:../core + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 zod: specifier: catalog:runtimeShared version: 4.3.6 @@ -2012,7 +1955,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/server-legacy: dependencies: @@ -2100,7 +2043,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) test/conformance: devDependencies: @@ -2191,6 +2134,18 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 + '@opentelemetry/context-async-hooks': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/core': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/sdk-trace': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) '@types/cors': specifier: catalog:devTools version: 2.8.19 @@ -2229,7 +2184,7 @@ importers: version: 1.3.1(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) zod: specifier: catalog:runtimeShared version: 4.3.6 @@ -2253,7 +2208,7 @@ importers: version: 7.0.0-dev.20260327.2 vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) zod: specifier: catalog:runtimeShared version: 4.3.6 @@ -2290,6 +2245,21 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 + '@opentelemetry/context-async-hooks': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/core': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/sdk-trace': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) '@typescript/native-preview': specifier: catalog:devTools version: 7.0.0-dev.20260327.2 @@ -2307,7 +2277,7 @@ importers: version: 1.3.1(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) wrangler: specifier: catalog:devTools version: 4.78.0 @@ -2690,6 +2660,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} @@ -2708,6 +2684,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} @@ -2726,6 +2708,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} @@ -2744,6 +2732,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} @@ -2762,6 +2756,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} @@ -2780,6 +2780,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} @@ -2798,6 +2804,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} @@ -2816,6 +2828,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} @@ -2834,6 +2852,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} @@ -2852,6 +2876,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} @@ -2870,6 +2900,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} @@ -2888,6 +2924,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} @@ -2906,6 +2948,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} @@ -2924,6 +2972,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} @@ -2942,6 +2996,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} @@ -2960,6 +3020,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} @@ -2978,6 +3044,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} @@ -2990,6 +3062,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} @@ -3008,6 +3086,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} @@ -3020,6 +3104,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} @@ -3038,6 +3128,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} @@ -3050,6 +3146,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} @@ -3068,6 +3170,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} @@ -3086,6 +3194,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} @@ -3104,6 +3218,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} @@ -3122,6 +3242,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3422,6 +3548,13 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -3504,53 +3637,39 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} - '@opentelemetry/api@1.9.1': - resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + '@opentelemetry/api@1.10.0': + resolution: {integrity: sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==} engines: {node: '>=8.0.0'} - '@opentelemetry/context-async-hooks@2.12.0': - resolution: {integrity: sha512-iK/1nD7mmZVkuYjVjkdFBiCvzntJdFny1S3CYbUYyOq7V5enL6XX+gJ7eh965L1LnS11VA8mQ7Ej/ck04SB0MQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/core@2.12.0': - resolution: {integrity: sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/resources@2.12.0': - resolution: {integrity: sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==} - engines: {node: ^18.19.0 || >=20.6.0} + '@opentelemetry/context-async-hooks@3.0.0': + resolution: {integrity: sha512-qDZeKSgTT+qTaL99eRbpvWO5+oZ0MKvMH4pqvzi6xYKyhiMTDBxBeTLI6BpdeMt58T5TpxHJvFl9P52i6CDWGA==} + engines: {node: '>=22.15.0'} peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/api': '>=1.0.0 <1.11.0' - '@opentelemetry/sdk-metrics@2.12.0': - resolution: {integrity: sha512-mVMaEHwCUJuY3S4Esn+nT0yQA3qCEGh+SOPJsXygU0Gl453WcsDNjtEa8EfdewMwoLyr1tlofB4fXytVpiXuow==} - engines: {node: ^18.19.0 || >=20.6.0} + '@opentelemetry/core@3.0.0': + resolution: {integrity: sha512-whOLlww0R/RLtCWGBXhK1fdvL9t2/TL0fqdjZvcaoUUuNilRPjXecqQdzyVwV68s6XEDA5oYbm7/bZT62GXF4A==} + engines: {node: '>=22.15.0'} peerDependencies: - '@opentelemetry/api': '>=1.9.0 <1.10.0' + '@opentelemetry/api': '>=1.10.0 <1.11.0' - '@opentelemetry/sdk-trace-base@2.12.0': - resolution: {integrity: sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==} - engines: {node: ^18.19.0 || >=20.6.0} - deprecated: Use @opentelemetry/sdk-trace package + '@opentelemetry/resources@3.0.0': + resolution: {integrity: sha512-4762KVmlDroRIt+4Ra8AnwcQqdXlkAR55jDrhzna8bzhJx1mUzvHW/Lh/OemFuY9+ouM4xaDc6CbUmoJmM6BFg==} + engines: {node: '>=22.15.0'} peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/api': '>=1.10.0 <1.11.0' - '@opentelemetry/sdk-trace-node@2.12.0': - resolution: {integrity: sha512-9DufH1IlmQMoKAfot5y/FhTP1RnmhLb5K6eKmJO/bvzPjYwPAazoiAUbTZFdPbqxOXN0FEbhZx/e6SB1Og4jmg==} - engines: {node: ^18.19.0 || >=20.6.0} - deprecated: Use @opentelemetry/sdk-trace package + '@opentelemetry/sdk-metrics@3.0.0': + resolution: {integrity: sha512-YozyS2rIlizvUNLZwcWxH0x2FhX02/mcZwzM8hsl8yvQk+xeKmAc7vcSieWiO4oSwIB20B8eS/zwdopAd/J5zQ==} + engines: {node: '>=22.15.0'} peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/api': '>=1.10.0 <1.11.0' - '@opentelemetry/sdk-trace@2.12.0': - resolution: {integrity: sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA==} - engines: {node: ^18.19.0 || >=20.6.0} + '@opentelemetry/sdk-trace@3.0.0': + resolution: {integrity: sha512-4T0DbDGdtsjNmYK5/lNalHro4fsMZ0UZ3JE+C8fcqYP25xaJC22FHg8kdJNJ21srjJY5XoT7WNFCtj88NNTl4Q==} + engines: {node: '>=22.15.0'} peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/api': '>=1.10.0 <1.11.0' '@opentelemetry/semantic-conventions@1.40.0': resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} @@ -3794,139 +3913,277 @@ packages: cpu: [arm] os: [android] + '@rollup/rollup-android-arm-eabi@4.64.12': + resolution: {integrity: sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm64@4.60.0': resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} cpu: [arm64] os: [android] + '@rollup/rollup-android-arm64@4.64.12': + resolution: {integrity: sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.60.0': resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.64.12': + resolution: {integrity: sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.60.0': resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.64.12': + resolution: {integrity: sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw==} + cpu: [x64] + os: [darwin] + '@rollup/rollup-freebsd-arm64@4.60.0': resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} cpu: [arm64] os: [freebsd] + '@rollup/rollup-freebsd-arm64@4.64.12': + resolution: {integrity: sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ==} + cpu: [arm64] + os: [freebsd] + '@rollup/rollup-freebsd-x64@4.60.0': resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} cpu: [x64] os: [freebsd] + '@rollup/rollup-freebsd-x64@4.64.12': + resolution: {integrity: sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ==} + cpu: [x64] + os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] libc: [glibc] + '@rollup/rollup-linux-arm-gnueabihf@4.64.12': + resolution: {integrity: sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] libc: [musl] + '@rollup/rollup-linux-arm-musleabihf@4.64.12': + resolution: {integrity: sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA==} + cpu: [arm] + os: [linux] + libc: [musl] + '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] libc: [glibc] + '@rollup/rollup-linux-arm64-gnu@4.64.12': + resolution: {integrity: sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] libc: [musl] + '@rollup/rollup-linux-arm64-musl@4.64.12': + resolution: {integrity: sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] libc: [glibc] + '@rollup/rollup-linux-loong64-gnu@4.64.12': + resolution: {integrity: sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] libc: [musl] + '@rollup/rollup-linux-loong64-musl@4.64.12': + resolution: {integrity: sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] libc: [glibc] + '@rollup/rollup-linux-ppc64-gnu@4.64.12': + resolution: {integrity: sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] libc: [musl] + '@rollup/rollup-linux-ppc64-musl@4.64.12': + resolution: {integrity: sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@rollup/rollup-linux-riscv64-gnu@4.64.12': + resolution: {integrity: sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] libc: [musl] + '@rollup/rollup-linux-riscv64-musl@4.64.12': + resolution: {integrity: sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A==} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] libc: [glibc] + '@rollup/rollup-linux-s390x-gnu@4.64.12': + resolution: {integrity: sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] libc: [glibc] + '@rollup/rollup-linux-x64-gnu@4.64.12': + resolution: {integrity: sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] libc: [musl] + '@rollup/rollup-linux-x64-musl@4.64.12': + resolution: {integrity: sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw==} + cpu: [x64] + os: [linux] + libc: [musl] + '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} cpu: [x64] os: [openbsd] + '@rollup/rollup-openbsd-x64@4.64.12': + resolution: {integrity: sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg==} + cpu: [x64] + os: [openbsd] + '@rollup/rollup-openharmony-arm64@4.60.0': resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} cpu: [arm64] os: [openharmony] + '@rollup/rollup-openharmony-arm64@4.64.12': + resolution: {integrity: sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw==} + cpu: [arm64] + os: [openharmony] + '@rollup/rollup-win32-arm64-msvc@4.60.0': resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.64.12': + resolution: {integrity: sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.60.0': resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.64.12': + resolution: {integrity: sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw==} + cpu: [ia32] + os: [win32] + '@rollup/rollup-win32-x64-gnu@4.60.0': resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-gnu@4.64.12': + resolution: {integrity: sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg==} + cpu: [x64] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.60.0': resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.64.12': + resolution: {integrity: sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw==} + cpu: [x64] + os: [win32] + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -4012,6 +4269,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/eventsource@1.1.15': resolution: {integrity: sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==} @@ -5040,6 +5300,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -5990,6 +6255,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanostores@1.2.0: resolution: {integrity: sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==} engines: {node: ^20.0.0 || >=22.0.0} @@ -6189,6 +6459,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -6219,6 +6493,10 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -6412,6 +6690,11 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rollup@4.64.12: + resolution: {integrity: sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} @@ -6551,6 +6834,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -6681,6 +6968,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} @@ -7320,7 +7611,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0)': + '@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -7332,38 +7623,38 @@ snapshots: nanostores: 1.2.0 zod: 4.3.6 optionalDependencies: - '@opentelemetry/api': 1.9.1 + '@opentelemetry/api': 1.10.0 - '@better-auth/drizzle-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': + '@better-auth/drizzle-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) '@better-auth/utils': 0.4.2 - '@better-auth/kysely-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)': + '@better-auth/kysely-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)': dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.2 - '@better-auth/memory-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -7609,6 +7900,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.4': optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true @@ -7618,6 +7912,9 @@ snapshots: '@esbuild/android-arm64@0.27.4': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + '@esbuild/android-arm@0.21.5': optional: true @@ -7627,6 +7924,9 @@ snapshots: '@esbuild/android-arm@0.27.4': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + '@esbuild/android-x64@0.21.5': optional: true @@ -7636,6 +7936,9 @@ snapshots: '@esbuild/android-x64@0.27.4': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true @@ -7645,6 +7948,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.4': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true @@ -7654,6 +7960,9 @@ snapshots: '@esbuild/darwin-x64@0.27.4': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true @@ -7663,6 +7972,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.4': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true @@ -7672,6 +7984,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.4': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true @@ -7681,6 +7996,9 @@ snapshots: '@esbuild/linux-arm64@0.27.4': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true @@ -7690,6 +8008,9 @@ snapshots: '@esbuild/linux-arm@0.27.4': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true @@ -7699,6 +8020,9 @@ snapshots: '@esbuild/linux-ia32@0.27.4': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true @@ -7708,6 +8032,9 @@ snapshots: '@esbuild/linux-loong64@0.27.4': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true @@ -7717,6 +8044,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.4': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true @@ -7726,6 +8056,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.4': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true @@ -7735,6 +8068,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.4': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true @@ -7744,6 +8080,9 @@ snapshots: '@esbuild/linux-s390x@0.27.4': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true @@ -7753,12 +8092,18 @@ snapshots: '@esbuild/linux-x64@0.27.4': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + '@esbuild/netbsd-arm64@0.27.3': optional: true '@esbuild/netbsd-arm64@0.27.4': optional: true + '@esbuild/netbsd-arm64@0.27.7': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true @@ -7768,12 +8113,18 @@ snapshots: '@esbuild/netbsd-x64@0.27.4': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + '@esbuild/openbsd-arm64@0.27.3': optional: true '@esbuild/openbsd-arm64@0.27.4': optional: true + '@esbuild/openbsd-arm64@0.27.7': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true @@ -7783,12 +8134,18 @@ snapshots: '@esbuild/openbsd-x64@0.27.4': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + '@esbuild/openharmony-arm64@0.27.3': optional: true '@esbuild/openharmony-arm64@0.27.4': optional: true + '@esbuild/openharmony-arm64@0.27.7': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true @@ -7798,6 +8155,9 @@ snapshots: '@esbuild/sunos-x64@0.27.4': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true @@ -7807,6 +8167,9 @@ snapshots: '@esbuild/win32-arm64@0.27.4': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true @@ -7816,6 +8179,9 @@ snapshots: '@esbuild/win32-ia32@0.27.4': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true @@ -7825,6 +8191,9 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: eslint: 9.39.4 @@ -8119,6 +8488,9 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.9.1 @@ -8214,49 +8586,34 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 - '@opentelemetry/api@1.9.1': {} - - '@opentelemetry/context-async-hooks@2.12.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 + '@opentelemetry/api@1.10.0': {} - '@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/context-async-hooks@3.0.0(@opentelemetry/api@1.10.0)': dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/api': 1.10.0 - '@opentelemetry/resources@2.12.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/core@3.0.0(@opentelemetry/api@1.10.0)': dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/api': 1.10.0 '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-metrics@2.12.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/resources@3.0.0(@opentelemetry/api@1.10.0)': dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/api': 1.10.0 + '@opentelemetry/core': 3.0.0(@opentelemetry/api@1.10.0) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-trace-node@2.12.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/sdk-metrics@3.0.0(@opentelemetry/api@1.10.0)': dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/context-async-hooks': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/api': 1.10.0 + '@opentelemetry/core': 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/resources': 3.0.0(@opentelemetry/api@1.10.0) - '@opentelemetry/sdk-trace@2.12.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/sdk-trace@3.0.0(@opentelemetry/api@1.10.0)': dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/api': 1.10.0 + '@opentelemetry/core': 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/resources': 3.0.0(@opentelemetry/api@1.10.0) '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/semantic-conventions@1.40.0': {} @@ -8402,78 +8759,153 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.60.0': optional: true + '@rollup/rollup-android-arm-eabi@4.64.12': + optional: true + '@rollup/rollup-android-arm64@4.60.0': optional: true + '@rollup/rollup-android-arm64@4.64.12': + optional: true + '@rollup/rollup-darwin-arm64@4.60.0': optional: true + '@rollup/rollup-darwin-arm64@4.64.12': + optional: true + '@rollup/rollup-darwin-x64@4.60.0': optional: true + '@rollup/rollup-darwin-x64@4.64.12': + optional: true + '@rollup/rollup-freebsd-arm64@4.60.0': optional: true + '@rollup/rollup-freebsd-arm64@4.64.12': + optional: true + '@rollup/rollup-freebsd-x64@4.60.0': optional: true + '@rollup/rollup-freebsd-x64@4.64.12': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.64.12': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.60.0': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.64.12': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.60.0': optional: true + '@rollup/rollup-linux-arm64-gnu@4.64.12': + optional: true + '@rollup/rollup-linux-arm64-musl@4.60.0': optional: true + '@rollup/rollup-linux-arm64-musl@4.64.12': + optional: true + '@rollup/rollup-linux-loong64-gnu@4.60.0': optional: true + '@rollup/rollup-linux-loong64-gnu@4.64.12': + optional: true + '@rollup/rollup-linux-loong64-musl@4.60.0': optional: true + '@rollup/rollup-linux-loong64-musl@4.64.12': + optional: true + '@rollup/rollup-linux-ppc64-gnu@4.60.0': optional: true + '@rollup/rollup-linux-ppc64-gnu@4.64.12': + optional: true + '@rollup/rollup-linux-ppc64-musl@4.60.0': optional: true + '@rollup/rollup-linux-ppc64-musl@4.64.12': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.60.0': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.64.12': + optional: true + '@rollup/rollup-linux-riscv64-musl@4.60.0': optional: true + '@rollup/rollup-linux-riscv64-musl@4.64.12': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.60.0': optional: true + '@rollup/rollup-linux-s390x-gnu@4.64.12': + optional: true + '@rollup/rollup-linux-x64-gnu@4.60.0': optional: true + '@rollup/rollup-linux-x64-gnu@4.64.12': + optional: true + '@rollup/rollup-linux-x64-musl@4.60.0': optional: true + '@rollup/rollup-linux-x64-musl@4.64.12': + optional: true + '@rollup/rollup-openbsd-x64@4.60.0': optional: true + '@rollup/rollup-openbsd-x64@4.64.12': + optional: true + '@rollup/rollup-openharmony-arm64@4.60.0': optional: true + '@rollup/rollup-openharmony-arm64@4.64.12': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.60.0': optional: true + '@rollup/rollup-win32-arm64-msvc@4.64.12': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.60.0': optional: true + '@rollup/rollup-win32-ia32-msvc@4.64.12': + optional: true + '@rollup/rollup-win32-x64-gnu@4.60.0': optional: true + '@rollup/rollup-win32-x64-gnu@4.64.12': + optional: true + '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true + '@rollup/rollup-win32-x64-msvc@4.64.12': + optional: true + '@rtsao/scc@1.1.0': {} '@shikijs/core@2.5.0': @@ -8585,6 +9017,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/eventsource@1.1.15': {} '@types/express-serve-static-core@5.1.1': @@ -9181,15 +9615,15 @@ snapshots: before-after-hook@4.0.0: {} - better-auth@1.6.20(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)))(vue@3.5.38(typescript@5.9.3)): + better-auth@1.6.20(@opentelemetry/api@1.10.0)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)))(vue@3.5.38(typescript@5.9.3)): dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) - '@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) - '@better-auth/kysely-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(kysely@0.29.2) - '@better-auth/memory-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(kysely@0.29.2) + '@better-auth/memory-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.1.1 @@ -9202,7 +9636,7 @@ snapshots: zod: 4.3.6 optionalDependencies: better-sqlite3: 12.8.0 - vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) vue: 3.5.38(typescript@5.9.3) transitivePeerDependencies: - '@cloudflare/workers-types' @@ -9704,6 +10138,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -10030,6 +10493,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -10737,6 +11204,8 @@ snapshots: nanoid@3.3.15: {} + nanoid@3.3.20: {} + nanostores@1.2.0: {} napi-build-utils@2.0.0: {} @@ -10914,6 +11383,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.7: {} + pify@4.0.1: {} pino-abstract-transport@3.0.0: @@ -10948,6 +11419,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.31: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -11202,6 +11679,38 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.0 fsevents: 2.3.3 + rollup@4.64.12: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.64.12 + '@rollup/rollup-android-arm64': 4.64.12 + '@rollup/rollup-darwin-arm64': 4.64.12 + '@rollup/rollup-darwin-x64': 4.64.12 + '@rollup/rollup-freebsd-arm64': 4.64.12 + '@rollup/rollup-freebsd-x64': 4.64.12 + '@rollup/rollup-linux-arm-gnueabihf': 4.64.12 + '@rollup/rollup-linux-arm-musleabihf': 4.64.12 + '@rollup/rollup-linux-arm64-gnu': 4.64.12 + '@rollup/rollup-linux-arm64-musl': 4.64.12 + '@rollup/rollup-linux-loong64-gnu': 4.64.12 + '@rollup/rollup-linux-loong64-musl': 4.64.12 + '@rollup/rollup-linux-ppc64-gnu': 4.64.12 + '@rollup/rollup-linux-ppc64-musl': 4.64.12 + '@rollup/rollup-linux-riscv64-gnu': 4.64.12 + '@rollup/rollup-linux-riscv64-musl': 4.64.12 + '@rollup/rollup-linux-s390x-gnu': 4.64.12 + '@rollup/rollup-linux-x64-gnu': 4.64.12 + '@rollup/rollup-linux-x64-musl': 4.64.12 + '@rollup/rollup-openbsd-x64': 4.64.12 + '@rollup/rollup-openharmony-arm64': 4.64.12 + '@rollup/rollup-win32-arm64-msvc': 4.64.12 + '@rollup/rollup-win32-ia32-msvc': 4.64.12 + '@rollup/rollup-win32-x64-gnu': 4.64.12 + '@rollup/rollup-win32-x64-msvc': 4.64.12 + fsevents: 2.3.3 + rou3@0.7.12: {} router@2.2.0: @@ -11406,6 +11915,8 @@ snapshots: source-map-js@1.2.1: {} + source-map-js@1.2.2: {} + space-separated-tokens@2.0.2: {} spawndamnit@3.0.1: @@ -11544,6 +12055,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + tinyrainbow@3.1.0: {} to-regex-range@5.0.1: @@ -11834,12 +12350,12 @@ snapshots: vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: - esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 - rollup: 4.60.0 - tinyglobby: 0.2.15 + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.31 + rollup: 4.64.12 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.12.0 fsevents: 2.3.3 @@ -11848,19 +12364,19 @@ snapshots: vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: - esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 - rollup: 4.60.0 - tinyglobby: 0.2.15 + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.31 + rollup: 4.64.12 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.5.0 fsevents: 2.3.3 tsx: 4.21.0 yaml: 2.8.3 - vitepress@1.6.4(@algolia/client-search@5.55.0)(@types/node@24.12.0)(change-case@5.4.4)(postcss@8.5.15)(search-insights@2.17.3)(typescript@5.9.3): + vitepress@1.6.4(@algolia/client-search@5.55.0)(@types/node@24.12.0)(change-case@5.4.4)(postcss@8.5.31)(search-insights@2.17.3)(typescript@5.9.3): dependencies: '@docsearch/css': 3.8.2 '@docsearch/js': 3.8.2(@algolia/client-search@5.55.0)(search-insights@2.17.3) @@ -11881,7 +12397,7 @@ snapshots: vite: 5.4.21(@types/node@24.12.0) vue: 3.5.38(typescript@5.9.3) optionalDependencies: - postcss: 8.5.15 + postcss: 8.5.31 transitivePeerDependencies: - '@algolia/client-search' - '@types/node' @@ -11909,7 +12425,7 @@ snapshots: - typescript - universal-cookie - vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.2(@opentelemetry/api@1.10.0)(@types/node@24.12.0)(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.2 '@vitest/mocker': 4.1.2(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -11932,12 +12448,12 @@ snapshots: vite: 7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: - '@opentelemetry/api': 1.9.1 + '@opentelemetry/api': 1.10.0 '@types/node': 24.12.0 transitivePeerDependencies: - msw - vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.2 '@vitest/mocker': 4.1.2(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -11960,7 +12476,7 @@ snapshots: vite: 7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: - '@opentelemetry/api': 1.9.1 + '@opentelemetry/api': 1.10.0 '@types/node': 25.5.0 transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index de95dcae38..8361dde6a4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -48,11 +48,10 @@ catalogs: raw-body: ^3.0.0 otel: '@opentelemetry/api': ^1.9.0 - '@opentelemetry/context-async-hooks': ^2.12.0 - '@opentelemetry/core': ^2.12.0 - '@opentelemetry/sdk-metrics': ^2.12.0 - '@opentelemetry/sdk-trace-base': ^2.12.0 - '@opentelemetry/sdk-trace-node': ^2.12.0 + '@opentelemetry/context-async-hooks': ^3.0.0 + '@opentelemetry/core': ^3.0.0 + '@opentelemetry/sdk-metrics': ^3.0.0 + '@opentelemetry/sdk-trace': ^3.0.0 runtimeShared: '@cfworker/json-schema': ^4.1.1 ajv: ^8.17.1 diff --git a/test/e2e/package.json b/test/e2e/package.json index 2b052482e2..fde46e74cd 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -43,6 +43,10 @@ "@modelcontextprotocol/test-helpers": "workspace:^", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/context-async-hooks": "catalog:otel", + "@opentelemetry/core": "catalog:otel", + "@opentelemetry/sdk-trace": "catalog:otel", "@types/cors": "catalog:devTools", "@types/express": "catalog:devTools", "@valibot/to-json-schema": "catalog:devTools", diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 4f0b9ed052..d26f6524d2 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1390,10 +1390,10 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta', behavior: "_meta sent in a request's params by the client is delivered intact to the server-side request handler." }, - 'protocol:meta:trace-context-instrumentation': { + 'protocol:meta:trace-context-otel': { source: 'https://modelcontextprotocol.io/community/seps/414-request-meta', behavior: - "With instrumentation configured on both sides, the trace context the client's instrumentation writes for a request reaches the server's instrumentation and the request handler in params._meta, and the handler runs inside the scope the server's instrumentation opened for that request." + "With an OpenTelemetry SDK registered and no other configuration, a client's tools/call span carries its W3C trace context to the server in params._meta, the server's span for the call is its child, and the tool handler runs with the server span active." }, 'protocol:meta:result-to-client': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta', diff --git a/test/e2e/scenarios/observability.test.ts b/test/e2e/scenarios/observability.test.ts new file mode 100644 index 0000000000..c3819bf594 --- /dev/null +++ b/test/e2e/scenarios/observability.test.ts @@ -0,0 +1,58 @@ +/** + * OpenTelemetry tests. They register a global OpenTelemetry SDK, so they live in + * their own file: every other scenario runs with the no-op API and an untouched wire. + */ + +import { Client } from '@modelcontextprotocol/client'; +import { McpServer } from '@modelcontextprotocol/server'; +import { context, propagation, SpanKind, trace } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; +import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace'; +import { afterAll, beforeEach, expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { wire } from '../helpers/index'; +import { verifies } from '../helpers/verifies'; +import type { TestArgs } from '../types'; + +const exporter = new InMemorySpanExporter(); +const tracerProvider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] }); +context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +propagation.setGlobalPropagator(new W3CTraceContextPropagator()); +trace.setGlobalTracerProvider(tracerProvider); + +beforeEach(() => exporter.reset()); +afterAll(async () => { + await tracerProvider.shutdown(); + trace.disable(); + propagation.disable(); + context.disable(); +}); + +verifies('protocol:meta:trace-context-otel', async ({ transport }: TestArgs) => { + const handlerSaw: { traceparent?: unknown; activeSpanId?: string }[] = []; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('traced', { inputSchema: z.object({}) }, (_args, ctx) => { + handlerSaw.push({ traceparent: ctx.mcpReq._meta?.traceparent, activeSpanId: trace.getActiveSpan()?.spanContext().spanId }); + return { content: [{ type: 'text', text: 'ok' }] }; + }); + return s; + }; + const client = new Client({ name: 'c', version: '0' }); + await using _ = await wire(transport, makeServer, client); + exporter.reset(); + + await client.callTool({ name: 'traced', arguments: {} }); + + const spans = exporter.getFinishedSpans(); + const clientSpan = spans.find(s => s.name === 'tools/call traced' && s.kind === SpanKind.CLIENT); + const serverSpan = spans.find(s => s.name === 'tools/call traced' && s.kind === SpanKind.SERVER); + expect(clientSpan).toBeDefined(); + expect(serverSpan).toBeDefined(); + const { traceId, spanId } = clientSpan!.spanContext(); + expect(serverSpan!.spanContext().traceId).toBe(traceId); + expect(serverSpan!.parentSpanContext?.spanId).toBe(spanId); + expect(handlerSaw).toEqual([{ traceparent: `00-${traceId}-${spanId}-01`, activeSpanId: serverSpan!.spanContext().spanId }]); +}); diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index f65265e8c5..4abcde078d 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -6,8 +6,6 @@ * via the requirement's tier map. */ -import { AsyncLocalStorage } from 'node:async_hooks'; - import { Client } from '@modelcontextprotocol/client'; import type { CallToolRequest, @@ -18,7 +16,6 @@ import type { MessageExtraInfo, Notification, Progress, - ProtocolInstrumentation, RequestId, Result, Transport, @@ -1117,45 +1114,6 @@ verifies('protocol:meta:request-to-handler', async ({ transport }: TestArgs) => expect(handlerExtraMeta).toEqual([requestMeta]); }); -verifies('protocol:meta:trace-context-instrumentation', async ({ transport }: TestArgs) => { - const active = new AsyncLocalStorage(); - const serverSeen: { meta?: Readonly>; handlerMeta?: unknown; activeInHandler?: string }[] = []; - const serverInstrumentation: ProtocolInstrumentation = { - receive(operation) { - if (operation.method === 'tools/call') serverSeen.push({ meta: operation.meta }); - return { run: fn => active.run(`server ${operation.method}`, fn), end: () => {} }; - } - }; - const makeServer = () => { - const s = new McpServer({ name: 's', version: '0' }, { instrumentation: serverInstrumentation }); - s.registerTool('traced', { inputSchema: z.object({}) }, (_args, ctx) => { - serverSeen.at(-1)!.handlerMeta = ctx.mcpReq._meta?.traceparent; - serverSeen.at(-1)!.activeInHandler = active.getStore(); - return { content: [{ type: 'text', text: 'ok' }] }; - }); - return s; - }; - const traceparent = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; - const client = new Client( - { name: 'c', version: '0' }, - { - instrumentation: { - send(operation) { - if (operation.method === 'tools/call') operation.carrier.traceparent = traceparent; - return { run: fn => fn(), end: () => {} }; - } - } - } - ); - await using _ = await wire(transport, makeServer, client); - - await client.callTool({ name: 'traced', arguments: {} }); - - expect(serverSeen).toEqual([ - { meta: expect.objectContaining({ traceparent }), handlerMeta: traceparent, activeInHandler: 'server tools/call' } - ]); -}); - verifies('protocol:meta:result-to-client', async ({ transport }: TestArgs) => { const resultMeta = { 'example.com/cost-tokens': 42, 'example.com/cache': { hit: true, region: 'eu-west-1' } }; const makeServer = () => { diff --git a/test/integration/package.json b/test/integration/package.json index df35ac13a5..d7ffce0f5d 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -42,6 +42,11 @@ "@modelcontextprotocol/test-helpers": "workspace:^", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/context-async-hooks": "catalog:otel", + "@opentelemetry/core": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-trace": "catalog:otel", "@valibot/to-json-schema": "catalog:devTools", "@typescript/native-preview": "catalog:devTools", "arktype": "catalog:devTools", diff --git a/test/integration/test/instrumentation.test.ts b/test/integration/test/instrumentation.test.ts deleted file mode 100644 index 4109cb31ec..0000000000 --- a/test/integration/test/instrumentation.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; -import type { Server as HttpServer } from 'node:http'; -import { createServer } from 'node:http'; - -import type { OperationOutcome, ProtocolInstrumentation } from '@modelcontextprotocol/client'; -import { Client, InMemoryTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { toNodeHandler } from '@modelcontextprotocol/node'; -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; -import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; -import { afterEach, describe, expect, it } from 'vitest'; -import * as z from 'zod/v4'; - -interface Observed { - side: string; - direction: 'send' | 'receive'; - method: string; - params?: Readonly>; - meta?: Readonly>; - protocolVersion?: string; - httpRequest?: Request; - /** The scope active where the operation started. */ - parent?: string; - outcomes: OperationOutcome[]; -} - -/** A minimal tracer: every operation gets an id, propagated through `traceparent` and made active with `AsyncLocalStorage`. */ -function tracer(side: string, als: AsyncLocalStorage, observed: Observed[]): ProtocolInstrumentation { - let next = 0; - return { - send(operation) { - const id = `${side}:${next++}`; - const entry: Observed = { - side, - direction: 'send', - method: operation.method, - params: operation.params, - parent: als.getStore(), - outcomes: [] - }; - observed.push(entry); - operation.carrier.traceparent = id; - return { run: fn => als.run(id, fn), end: outcome => entry.outcomes.push(outcome) }; - }, - receive(operation) { - const id = `${side}:${next++}`; - const entry: Observed = { - side, - direction: 'receive', - method: operation.method, - params: operation.params, - meta: operation.meta, - protocolVersion: operation.protocolVersion, - httpRequest: operation.httpRequest, - parent: operation.meta?.traceparent as string | undefined, - outcomes: [] - }; - observed.push(entry); - return { run: fn => als.run(id, fn), end: outcome => entry.outcomes.push(outcome) }; - } - }; -} - -describe('instrumentation on Client and McpServer', () => { - const cleanups: Array<() => Promise | void> = []; - afterEach(async () => { - while (cleanups.length > 0) await cleanups.pop()!(); - }); - - it('follows a tool call from the client into the tool and back out through a nested elicitation', async () => { - const als = new AsyncLocalStorage(); - const observed: Observed[] = []; - const server = new McpServer({ name: 'traced', version: '1.0.0' }, { instrumentation: tracer('server', als, observed) }); - let activeInTool: string | undefined; - server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, async ({ name }, ctx) => { - activeInTool = als.getStore(); - const answer = await ctx.mcpReq.elicitInput({ - mode: 'form', - message: 'Greeting?', - requestedSchema: { type: 'object', properties: { word: { type: 'string' } } } - }); - return { content: [{ type: 'text', text: `${answer.content?.word} ${name}` }] }; - }); - const client = new Client( - { name: 'traced-client', version: '1.0.0' }, - { capabilities: { elicitation: {} }, instrumentation: tracer('client', als, observed) } - ); - let activeInElicitation: string | undefined; - client.setRequestHandler('elicitation/create', () => { - activeInElicitation = als.getStore(); - return { action: 'accept', content: { word: 'hello' } }; - }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - cleanups.push(() => client.close()); - - const result = await als.run('agent', () => client.callTool({ name: 'greet', arguments: { name: 'Ada' } })); - expect(result.content).toEqual([{ type: 'text', text: 'hello Ada' }]); - - const clientCall = observed.find(o => o.side === 'client' && o.direction === 'send' && o.method === 'tools/call')!; - expect(clientCall).toMatchObject({ parent: 'agent', params: { name: 'greet', arguments: { name: 'Ada' } } }); - expect(clientCall.outcomes).toEqual([{ type: 'result', result: { content: [{ type: 'text', text: 'hello Ada' }] } }]); - - const serverCall = observed.find(o => o.side === 'server' && o.direction === 'receive' && o.method === 'tools/call')!; - const clientCallId = serverCall.parent!; - expect(clientCallId).toMatch(/^client:/); - expect(serverCall.outcomes).toHaveLength(1); - - const serverElicit = observed.find(o => o.side === 'server' && o.direction === 'send' && o.method === 'elicitation/create')!; - expect(serverElicit.parent).toBe(activeInTool); - const clientElicit = observed.find(o => o.side === 'client' && o.direction === 'receive' && o.method === 'elicitation/create')!; - expect(clientElicit.parent).toMatch(/^server:/); - expect(activeInElicitation).toMatch(/^client:/); - expect(clientElicit.outcomes).toEqual([{ type: 'result', result: { action: 'accept', content: { word: 'hello' } } }]); - }); - - it('observes each request served by createMcpHandler with one shared instrumentation', async () => { - const als = new AsyncLocalStorage(); - const observed: Observed[] = []; - const instrumentation = tracer('server', als, observed); - const handler = createMcpHandler(() => { - const server = new McpServer({ name: 'traced-http', version: '1.0.0' }, { instrumentation }); - server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ - content: [{ type: 'text', text: `hello ${name}` }] - })); - return server; - }); - const httpServer: HttpServer = createServer(toNodeHandler(handler)); - const baseUrl = await listenOnRandomPort(httpServer); - cleanups.push(async () => { - await handler.close(); - httpServer.close(); - }); - const client = new Client( - { name: 'traced-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' }, instrumentation: tracer('client', als, observed) } - ); - await client.connect(new StreamableHTTPClientTransport(baseUrl)); - cleanups.push(() => client.close()); - - await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); - - const serverCall = observed.find(o => o.side === 'server' && o.direction === 'receive' && o.method === 'tools/call')!; - expect(serverCall.protocolVersion).toBe('2026-07-28'); - expect(serverCall.httpRequest).toBeInstanceOf(Request); - expect(serverCall.parent).toMatch(/^client:/); - expect(serverCall.outcomes).toMatchObject([{ type: 'result' }]); - }); -}); diff --git a/packages/otel/test/openTelemetry.test.ts b/test/integration/test/otel/openTelemetry.test.ts similarity index 62% rename from packages/otel/test/openTelemetry.test.ts rename to test/integration/test/otel/openTelemetry.test.ts index 8fca4e8e34..3fac46af2f 100644 --- a/packages/otel/test/openTelemetry.test.ts +++ b/test/integration/test/otel/openTelemetry.test.ts @@ -1,26 +1,27 @@ -import type { Server as HttpServer } from 'node:http'; -import { createServer } from 'node:http'; - import type { ClientOptions } from '@modelcontextprotocol/client'; import { Client, InMemoryTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { createMcpHandler, McpServer, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server'; -import { context, propagation, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; -import { afterEach, describe, expect, it } from 'vitest'; +import { context, propagation, SpanKind, SpanStatusCode } from '@opentelemetry/api'; +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as z from 'zod/v4'; -import { openTelemetry } from '../src/index'; -import { attributesOf, telemetry } from './helpers'; +import { registerTestSdk } from './sdk'; + +// The application's OpenTelemetry SDK. Client and McpServer need nothing else. +const sdk = registerTestSdk(); +beforeEach(() => sdk.reset()); +afterAll(() => sdk.unregister()); const cleanups: Array<() => Promise | unknown> = []; afterEach(async () => { while (cleanups.length > 0) await cleanups.pop()!(); }); -/** A client and server over in-memory transports, both instrumented. */ -async function connect(t: ReturnType, buildServer: (server: McpServer) => void, clientOptions: ClientOptions = {}) { - const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation: t.instrumentation }); +/** A client and server over in-memory transports, with no telemetry configuration at all. */ +async function connect(buildServer: (server: McpServer) => void, clientOptions: ClientOptions = {}) { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); buildServer(server); - const client = new Client({ name: 'agent', version: '1.0.0' }, { instrumentation: t.instrumentation, ...clientOptions }); + const client = new Client({ name: 'agent', version: '1.0.0' }, clientOptions); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); cleanups.push(() => client.close()); @@ -34,25 +35,26 @@ function registerGreet(server: McpServer) { })); } -describe('openTelemetry() spans', () => { +describe('OpenTelemetry spans', () => { it('emits a CLIENT span and a SERVER child span for a tool call, named after the tool', async () => { - const t = telemetry(); - const { client } = await connect(t, registerGreet); + const { client } = await connect(registerGreet); await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); - const clientSpan = t.span('tools/call greet', SpanKind.CLIENT); - const serverSpan = t.span('tools/call greet', SpanKind.SERVER); + const clientSpan = sdk.span('tools/call greet', SpanKind.CLIENT); + const serverSpan = sdk.span('tools/call greet', SpanKind.SERVER); expect(serverSpan.spanContext().traceId).toBe(clientSpan.spanContext().traceId); expect(serverSpan.parentSpanContext?.spanId).toBe(clientSpan.spanContext().spanId); - expect(attributesOf(clientSpan)).toEqual({ + expect(clientSpan.instrumentationScope.name).toBe('@modelcontextprotocol/client'); + expect(serverSpan.instrumentationScope.name).toBe('@modelcontextprotocol/server'); + expect({ ...clientSpan.attributes }).toEqual({ 'mcp.method.name': 'tools/call', 'gen_ai.operation.name': 'execute_tool', 'gen_ai.tool.name': 'greet', 'jsonrpc.request.id': expect.any(String), 'mcp.protocol.version': '2025-11-25' }); - expect(attributesOf(serverSpan)).toMatchObject({ + expect({ ...serverSpan.attributes }).toMatchObject({ 'mcp.method.name': 'tools/call', 'gen_ai.tool.name': 'greet', 'jsonrpc.request.id': clientSpan.attributes['jsonrpc.request.id'] @@ -62,31 +64,29 @@ describe('openTelemetry() spans', () => { }); it('parents the client span on the active span and makes the server span active inside the tool', async () => { - const t = telemetry(); - const { client } = await connect(t, server => { + const { client } = await connect(server => { server.registerTool('lookup', { inputSchema: z.object({}) }, async () => { - await t.tracer.startActiveSpan('db.query', async span => span.end()); + await sdk.tracer.startActiveSpan('db.query', async span => span.end()); return { content: [] }; }); }); - await t.tracer.startActiveSpan('invoke_agent', async agent => { + await sdk.tracer.startActiveSpan('invoke_agent', async agent => { await client.callTool({ name: 'lookup', arguments: {} }); agent.end(); }); - const agent = t.span('invoke_agent', SpanKind.INTERNAL); - const clientSpan = t.span('tools/call lookup', SpanKind.CLIENT); - const serverSpan = t.span('tools/call lookup', SpanKind.SERVER); - const dbSpan = t.span('db.query', SpanKind.INTERNAL); + const agent = sdk.span('invoke_agent', SpanKind.INTERNAL); + const clientSpan = sdk.span('tools/call lookup', SpanKind.CLIENT); + const serverSpan = sdk.span('tools/call lookup', SpanKind.SERVER); + const dbSpan = sdk.span('db.query', SpanKind.INTERNAL); expect(clientSpan.parentSpanContext?.spanId).toBe(agent.spanContext().spanId); expect(serverSpan.parentSpanContext?.spanId).toBe(clientSpan.spanContext().spanId); expect(dbSpan.parentSpanContext?.spanId).toBe(serverSpan.spanContext().spanId); }); it('traces the handshake and notifications, and skips log notifications', async () => { - const t = telemetry(); - const { client } = await connect(t, server => { + const { client } = await connect(server => { server.server.registerCapabilities({ logging: {} }); server.registerTool('noisy', { inputSchema: z.object({}) }, async (_args, ctx) => { await ctx.mcpReq.log('info', 'working'); @@ -97,16 +97,15 @@ describe('openTelemetry() spans', () => { await client.callTool({ name: 'noisy', arguments: {} }); - expect(t.span('initialize', SpanKind.CLIENT).attributes['mcp.protocol.version']).toBe('2025-11-25'); - expect(t.span('initialize', SpanKind.SERVER)).toBeDefined(); - expect(t.span('notifications/initialized', SpanKind.CLIENT).attributes['jsonrpc.request.id']).toBeUndefined(); - expect(t.span('notifications/initialized', SpanKind.SERVER)).toBeDefined(); - expect(t.spans().filter(s => s.name === 'notifications/message')).toEqual([]); + expect(sdk.span('initialize', SpanKind.CLIENT).attributes['mcp.protocol.version']).toBe('2025-11-25'); + expect(sdk.span('initialize', SpanKind.SERVER)).toBeDefined(); + expect(sdk.span('notifications/initialized', SpanKind.CLIENT).attributes['jsonrpc.request.id']).toBeUndefined(); + expect(sdk.span('notifications/initialized', SpanKind.SERVER)).toBeDefined(); + expect(sdk.spans().filter(s => s.name === 'notifications/message')).toEqual([]); }); it('marks a tool result with isError as tool_error on both sides', async () => { - const t = telemetry(); - const { client } = await connect(t, server => { + const { client } = await connect(server => { server.registerTool('fail', { inputSchema: z.object({}) }, () => ({ content: [{ type: 'text', text: 'secret detail' }], isError: true @@ -116,30 +115,28 @@ describe('openTelemetry() spans', () => { await client.callTool({ name: 'fail', arguments: {} }); for (const kind of [SpanKind.CLIENT, SpanKind.SERVER]) { - const span = t.span('tools/call fail', kind); + const span = sdk.span('tools/call fail', kind); expect(span.attributes['error.type']).toBe('tool_error'); expect(span.status).toEqual({ code: SpanStatusCode.ERROR }); } }); it('treats an unknown tool as a caller error: an error on the client, not on the server, and no tool name in the server span name', async () => { - const t = telemetry(); - const { client } = await connect(t, registerGreet); + const { client } = await connect(registerGreet); await expect(client.callTool({ name: 'nope', arguments: {} })).rejects.toThrow(/not found/); - const clientSpan = t.span('tools/call nope', SpanKind.CLIENT); + const clientSpan = sdk.span('tools/call nope', SpanKind.CLIENT); expect(clientSpan.attributes).toMatchObject({ 'error.type': '-32602', 'rpc.response.status_code': '-32602' }); expect(clientSpan.status.code).toBe(SpanStatusCode.ERROR); - const serverSpan = t.span('tools/call', SpanKind.SERVER); + const serverSpan = sdk.span('tools/call', SpanKind.SERVER); expect(serverSpan.attributes['rpc.response.status_code']).toBe('-32602'); expect(serverSpan.attributes['error.type']).toBeUndefined(); expect(serverSpan.status.code).toBe(SpanStatusCode.UNSET); }); it('records an exception event when a handler throws something other than a protocol error', async () => { - const t = telemetry(); - const { client } = await connect(t, server => { + const { client } = await connect(server => { server.server.setRequestHandler('acme/boom', { params: z.object({}) }, () => { throw new Error('database unreachable'); }); @@ -151,30 +148,28 @@ describe('openTelemetry() spans', () => { await expect(client.request({ method: 'acme/boom', params: {} }, z.object({}))).rejects.toThrow('database unreachable'); await expect(client.request({ method: 'acme/refuse', params: {} }, z.object({}))).rejects.toThrow('quota exceeded'); - const boom = t.span('acme/boom', SpanKind.SERVER); + const boom = sdk.span('acme/boom', SpanKind.SERVER); expect(boom.attributes['error.type']).toBe(String(ProtocolErrorCode.InternalError)); expect(boom.status).toEqual({ code: SpanStatusCode.ERROR, message: 'database unreachable' }); expect(boom.events.map(e => [e.name, e.attributes?.['exception.message']])).toEqual([['exception', 'database unreachable']]); - const refuse = t.span('acme/refuse', SpanKind.SERVER); + const refuse = sdk.span('acme/refuse', SpanKind.SERVER); expect(refuse.attributes['error.type']).toBe('-32001'); expect(refuse.events).toEqual([]); }); it('records a method the server does not handle as _OTHER', async () => { - const t = telemetry(); - const { client } = await connect(t, registerGreet); + const { client } = await connect(registerGreet); await expect(client.request({ method: 'attacker/chosen-name', params: {} }, z.object({}))).rejects.toThrow(); - const serverSpan = t.span('_OTHER', SpanKind.SERVER); + const serverSpan = sdk.span('_OTHER', SpanKind.SERVER); expect(serverSpan.attributes['mcp.method.name']).toBe('_OTHER'); expect(serverSpan.attributes['rpc.response.status_code']).toBe('-32601'); - expect(t.span('attacker/chosen-name', SpanKind.CLIENT).attributes['error.type']).toBe('-32601'); + expect(sdk.span('attacker/chosen-name', SpanKind.CLIENT).attributes['error.type']).toBe('-32601'); }); it('names prompt spans after the prompt and records resource URIs on resource spans', async () => { - const t = telemetry(); - const { client } = await connect(t, server => { + const { client } = await connect(server => { server.registerPrompt('review', { argsSchema: z.object({ language: z.string() }) }, ({ language }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `review this ${language}` } }] })); @@ -184,45 +179,23 @@ describe('openTelemetry() spans', () => { await client.getPrompt({ name: 'review', arguments: { language: 'TypeScript' } }); await client.readResource({ uri: 'file:///readme.md' }); - expect(t.span('prompts/get review', SpanKind.CLIENT).attributes['gen_ai.prompt.name']).toBe('review'); - expect(t.span('prompts/get review', SpanKind.SERVER).attributes['gen_ai.prompt.variable.language']).toBeUndefined(); - expect(t.span('resources/read', SpanKind.SERVER).attributes['mcp.resource.uri']).toBe('file:///readme.md'); + expect(sdk.span('prompts/get review', SpanKind.CLIENT).attributes['gen_ai.prompt.name']).toBe('review'); + expect(sdk.span('prompts/get review', SpanKind.SERVER).attributes['gen_ai.prompt.variable.language']).toBeUndefined(); + expect(sdk.span('resources/read', SpanKind.SERVER).attributes['mcp.resource.uri']).toBe('file:///readme.md'); }); it('records no tool arguments, tool results or prompt arguments by default', async () => { - const t = telemetry(); - const { client } = await connect(t, registerGreet); + const { client } = await connect(registerGreet); await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); - const recorded = JSON.stringify(t.spans().map(s => s.attributes)); + const recorded = JSON.stringify(sdk.spans().map(s => s.attributes)); expect(recorded).not.toContain('Ada'); expect(recorded).not.toContain('gen_ai.tool.call'); }); - it('records tool arguments, tool results and prompt arguments with captureContent', async () => { - const t = telemetry({ captureContent: true }); - const { client } = await connect(t, server => { - registerGreet(server); - server.registerPrompt('review', { argsSchema: z.object({ language: z.string() }) }, () => ({ messages: [] })); - }); - - await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); - await client.getPrompt({ name: 'review', arguments: { language: 'TypeScript' } }); - - for (const kind of [SpanKind.CLIENT, SpanKind.SERVER]) { - expect(t.span('tools/call greet', kind).attributes).toMatchObject({ - 'gen_ai.tool.call.arguments': '{"name":"Ada"}', - 'gen_ai.tool.call.result': '{"greeting":"hello Ada"}' - }); - expect(t.span('prompts/get review', kind).attributes['gen_ai.prompt.variable.language']).toBe('TypeScript'); - } - }); - it('follows a 2025-era elicitation from inside a tool back to the client', async () => { - const t = telemetry(); const { client } = await connect( - t, server => { server.registerTool('confirm', { inputSchema: z.object({}) }, async (_args, ctx) => { const answer = await ctx.mcpReq.elicitInput({ @@ -239,44 +212,41 @@ describe('openTelemetry() spans', () => { await client.callTool({ name: 'confirm', arguments: {} }); - const toolSpan = t.span('tools/call confirm', SpanKind.SERVER); - const askSpan = t.span('elicitation/create', SpanKind.CLIENT); - const answerSpan = t.span('elicitation/create', SpanKind.SERVER); + const toolSpan = sdk.span('tools/call confirm', SpanKind.SERVER); + const askSpan = sdk.span('elicitation/create', SpanKind.CLIENT); + const answerSpan = sdk.span('elicitation/create', SpanKind.SERVER); expect(askSpan.parentSpanContext?.spanId).toBe(toolSpan.spanContext().spanId); expect(answerSpan.parentSpanContext?.spanId).toBe(askSpan.spanContext().spanId); }); it('ends a timed-out request with error.type timeout', async () => { - const t = telemetry(); - const { client } = await connect(t, server => { + const { client } = await connect(server => { server.registerTool('slow', { inputSchema: z.object({}) }, () => new Promise(() => {})); }); await expect(client.callTool({ name: 'slow', arguments: {} }, { timeout: 20 })).rejects.toThrow(/timed out/); - const span = t.span('tools/call slow', SpanKind.CLIENT); + const span = sdk.span('tools/call slow', SpanKind.CLIENT); expect(span.attributes['error.type']).toBe('timeout'); expect(span.status.code).toBe(SpanStatusCode.ERROR); - expect(t.span('notifications/cancelled', SpanKind.CLIENT)).toBeDefined(); + expect(sdk.span('notifications/cancelled', SpanKind.CLIENT)).toBeDefined(); }); it('parents the client span on trace context the caller put in _meta', async () => { - const t = telemetry(); - const { client } = await connect(t, registerGreet); + const { client } = await connect(registerGreet); const upstream = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; await client.callTool({ name: 'greet', arguments: { name: 'Ada' }, _meta: { traceparent: upstream } }); - const clientSpan = t.span('tools/call greet', SpanKind.CLIENT); + const clientSpan = sdk.span('tools/call greet', SpanKind.CLIENT); expect(clientSpan.spanContext().traceId).toBe('0af7651916cd43dd8448eb211c80319c'); expect(clientSpan.parentSpanContext?.spanId).toBe('b7ad6b7169203331'); - expect(t.span('tools/call greet', SpanKind.SERVER).parentSpanContext?.spanId).toBe(clientSpan.spanContext().spanId); + expect(sdk.span('tools/call greet', SpanKind.SERVER).parentSpanContext?.spanId).toBe(clientSpan.spanContext().spanId); }); it('propagates baggage through _meta', async () => { - const t = telemetry(); let baggageInTool: string | undefined; - const { client } = await connect(t, server => { + const { client } = await connect(server => { server.registerTool('whoami', { inputSchema: z.object({}) }, () => { baggageInTool = propagation.getActiveBaggage()?.getEntry('tenant')?.value; return { content: [] }; @@ -290,10 +260,9 @@ describe('openTelemetry() spans', () => { }); }); -describe('openTelemetry() metrics', () => { +describe('OpenTelemetry metrics', () => { it('records operation durations with the semconv unit, buckets and low-cardinality attributes', async () => { - const t = telemetry(); - const { client } = await connect(t, server => { + const { client } = await connect(server => { registerGreet(server); server.registerResource('readme', 'file:///readme.md', {}, uri => ({ contents: [{ uri: uri.href, text: '# hi' }] })); }); @@ -302,7 +271,7 @@ describe('openTelemetry() metrics', () => { await client.readResource({ uri: 'file:///readme.md' }); await expect(client.callTool({ name: 'nope', arguments: {} })).rejects.toThrow(); - const clientDuration = await t.histogram('mcp.client.operation.duration'); + const clientDuration = await sdk.histogram('mcp.client.operation.duration'); expect(clientDuration.unit).toBe('s'); expect(clientDuration.boundaries).toEqual([0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300]); expect(clientDuration.points.map(p => p.attributes)).toEqual( @@ -325,7 +294,7 @@ describe('openTelemetry() metrics', () => { ]) ); - const serverDuration = await t.histogram('mcp.server.operation.duration'); + const serverDuration = await sdk.histogram('mcp.server.operation.duration'); const serverToolCalls = serverDuration.points.filter(p => p.attributes['mcp.method.name'] === 'tools/call').map(p => p.attributes); // The rejected tool name stays out of server metrics. expect(serverToolCalls).toEqual( @@ -349,30 +318,28 @@ describe('openTelemetry() metrics', () => { }); }); -describe('openTelemetry() over Streamable HTTP', () => { +describe('OpenTelemetry over Streamable HTTP', () => { it('serves a modern-era call through createMcpHandler: HTTP attributes, and a link to the ambient HTTP span', async () => { - const t = telemetry(); - const instrumentation = t.instrumentation; const handler = createMcpHandler(() => { - const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation }); + const server = new McpServer({ name: 'weather', version: '1.0.0' }); registerGreet(server); return server; }); cleanups.push(() => handler.close()); // Stands in for HTTP instrumentation: each POST gets a span, active while the server handles it. const fetchWithHttpSpan = (url: string | URL, init?: RequestInit) => - t.tracer.startActiveSpan('POST', { kind: SpanKind.SERVER }, span => + sdk.tracer.startActiveSpan('POST', { kind: SpanKind.SERVER }, span => handler.fetch(new Request(url, init)).finally(() => span.end()) ); - const client = new Client({ name: 'agent', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' }, instrumentation }); + const client = new Client({ name: 'agent', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(new StreamableHTTPClientTransport(new URL('http://mcp.test/mcp'), { fetch: fetchWithHttpSpan })); cleanups.push(() => client.close()); await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); - const clientSpan = t.span('tools/call greet', SpanKind.CLIENT); - const serverSpan = t.span('tools/call greet', SpanKind.SERVER); - const httpSpan = t.spans().find(s => s.name === 'POST' && s.parentSpanContext?.spanId === clientSpan.spanContext().spanId)!; + const clientSpan = sdk.span('tools/call greet', SpanKind.CLIENT); + const serverSpan = sdk.span('tools/call greet', SpanKind.SERVER); + const httpSpan = sdk.spans().find(s => s.name === 'POST' && s.parentSpanContext?.spanId === clientSpan.spanContext().spanId)!; expect(httpSpan).toBeDefined(); expect(serverSpan.parentSpanContext?.spanId).toBe(clientSpan.spanContext().spanId); expect(serverSpan.links.map(l => l.context.spanId)).toEqual([httpSpan.spanContext().spanId]); @@ -382,34 +349,4 @@ describe('openTelemetry() over Streamable HTTP', () => { 'network.transport': 'tcp' }); }); - - it('works over a real HTTP server with the global OpenTelemetry API left unconfigured', async () => { - const handler = createMcpHandler(() => { - const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation: openTelemetry() }); - server.registerTool('echo-meta', { inputSchema: z.object({}) }, (_args, ctx) => ({ - content: [{ type: 'text', text: JSON.stringify(ctx.mcpReq._meta?.traceparent ?? null) }] - })); - return server; - }); - const { toNodeHandler } = await import('@modelcontextprotocol/node'); - const httpServer: HttpServer = createServer(toNodeHandler(handler)); - await new Promise(resolve => httpServer.listen(0, '127.0.0.1', resolve)); - cleanups.push(async () => { - await handler.close(); - httpServer.close(); - }); - const address = httpServer.address() as { port: number }; - const client = new Client({ name: 'agent', version: '1.0.0' }, { instrumentation: openTelemetry() }); - await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp`))); - cleanups.push(() => client.close()); - const upstream = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; - - const plain = await client.callTool({ name: 'echo-meta', arguments: {} }); - const passthrough = await client.callTool({ name: 'echo-meta', arguments: {}, _meta: { traceparent: upstream } }); - - // No SDK and no propagator registered: nothing is injected, and caller-supplied context passes through untouched. - expect(plain.content).toEqual([{ type: 'text', text: 'null' }]); - expect(passthrough.content).toEqual([{ type: 'text', text: JSON.stringify(upstream) }]); - expect(trace.getActiveSpan()).toBeUndefined(); - }); }); diff --git a/test/integration/test/otel/sdk.ts b/test/integration/test/otel/sdk.ts new file mode 100644 index 0000000000..ff22b407ed --- /dev/null +++ b/test/integration/test/otel/sdk.ts @@ -0,0 +1,67 @@ +import type { Tracer } from '@opentelemetry/api'; +import { context, metrics, propagation, trace } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator } from '@opentelemetry/core'; +import type { DataPoint, Histogram, ResourceMetrics } from '@opentelemetry/sdk-metrics'; +import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace'; +import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace'; + +class CollectingMetricReader extends MetricReader { + protected async onShutdown(): Promise {} + protected async onForceFlush(): Promise {} +} + +/** + * Registers an OpenTelemetry SDK globally, exactly as an application would, but keeping spans + * and metrics in memory. Call it once per test file; `unregister` puts the no-op API back. + */ +export function registerTestSdk() { + const exporter = new InMemorySpanExporter(); + const tracerProvider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] }); + const reader = new CollectingMetricReader(); + const meterProvider = new MeterProvider({ readers: [reader] }); + context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); + propagation.setGlobalPropagator( + new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()] }) + ); + trace.setGlobalTracerProvider(tracerProvider); + metrics.setGlobalMeterProvider(meterProvider); + const tracer: Tracer = trace.getTracer('test'); + + const spans = (): ReadableSpan[] => exporter.getFinishedSpans(); + /** The one finished span with this name and kind; fails when there is not exactly one. */ + const span = (name: string, kind: number): ReadableSpan => { + const matches = spans().filter(s => s.name === name && s.kind === kind); + if (matches.length !== 1) { + throw new Error( + `expected one ${name} span of kind ${kind}, found ${matches.length} in [${spans() + .map(s => `${s.name}/${s.kind}`) + .join(', ')}]` + ); + } + return matches[0]!; + }; + /** Data points recorded so far for a histogram, across every instrumentation scope (client and server both record). */ + const histogram = async (name: string): Promise<{ unit: string; boundaries: number[]; points: DataPoint[] }> => { + const { resourceMetrics } = await reader.collect(); + const found = findMetrics(resourceMetrics, name); + if (found.length === 0) throw new Error(`no ${name} metric was recorded`); + const points = found.flatMap(metric => metric.dataPoints as DataPoint[]); + return { unit: found[0]!.descriptor.unit, boundaries: points[0]?.value.buckets.boundaries ?? [], points }; + }; + const unregister = async () => { + await tracerProvider.shutdown(); + await meterProvider.shutdown(); + trace.disable(); + metrics.disable(); + propagation.disable(); + context.disable(); + }; + + return { tracer, spans, span, histogram, reset: () => exporter.reset(), unregister }; +} + +function findMetrics(resourceMetrics: ResourceMetrics, name: string) { + return resourceMetrics.scopeMetrics.flatMap(scope => scope.metrics.filter(m => m.descriptor.name === name)); +} diff --git a/test/integration/test/otel/withoutSdk.test.ts b/test/integration/test/otel/withoutSdk.test.ts new file mode 100644 index 0000000000..aefaa61258 --- /dev/null +++ b/test/integration/test/otel/withoutSdk.test.ts @@ -0,0 +1,44 @@ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { trace } from '@opentelemetry/api'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +const cleanups: Array<() => Promise | unknown> = []; +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); +}); + +describe('OpenTelemetry with no SDK registered', () => { + it('records nothing, injects nothing, and passes caller-supplied trace context through untouched', async () => { + const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + server.registerTool('echo-meta', { inputSchema: z.object({}) }, (_args, ctx) => ({ + content: [{ type: 'text', text: JSON.stringify(ctx.mcpReq._meta ?? {}) }] + })); + return server; + }); + const httpServer: HttpServer = createServer(toNodeHandler(handler)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await handler.close(); + httpServer.close(); + }); + const client = new Client({ name: 'agent', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + const upstream = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + + const plain = await client.callTool({ name: 'echo-meta', arguments: {} }); + const passthrough = await client.callTool({ name: 'echo-meta', arguments: {}, _meta: { traceparent: upstream } }); + + expect(plain.content).toEqual([{ type: 'text', text: '{}' }]); + expect(passthrough.content).toEqual([{ type: 'text', text: JSON.stringify({ traceparent: upstream }) }]); + expect(trace.getActiveSpan()).toBeUndefined(); + }); +}); From 5cfd53ba79ad800a42637c59dd84f9900443863f Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 20:12:04 +0000 Subject: [PATCH 4/6] fix: inject MCP trace context with the client span active 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). --- packages/core-internal/src/shared/openTelemetry.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core-internal/src/shared/openTelemetry.ts b/packages/core-internal/src/shared/openTelemetry.ts index b75359c567..e23f82744e 100644 --- a/packages/core-internal/src/shared/openTelemetry.ts +++ b/packages/core-internal/src/shared/openTelemetry.ts @@ -152,7 +152,8 @@ export function openTelemetryInstrumentation(scopeName: string): ProtocolInstrum const span = tracer.startSpan(spanName(operation.method, target), { kind: SpanKind.CLIENT, attributes }, parent); const spanContext = trace.setSpan(parent, span); const injected: Record = {}; - propagation.inject(spanContext, injected, defaultTextMapSetter); + // Injected with the span's context active: some propagators (Sentry's) only inject the active context. + context.with(spanContext, () => propagation.inject(context.active(), injected, defaultTextMapSetter)); Object.assign(operation.carrier, injected); const metricAttributes = pickMetricAttributes(attributes); const started = performance.now(); From 28b2264d6cea1ac526dcb5674bf9f0912a2a56fe Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 23:28:56 +0000 Subject: [PATCH 5/6] fix: bound OpenTelemetry cardinality, peer-depend on @opentelemetry/api - 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. --- .changeset/protocol-instrumentation.md | 12 +- docs/_meta/specs/otel.md | 567 ------------------ docs/advanced/opentelemetry.md | 12 +- packages/client/package.json | 5 +- .../src/shared/instrumentation.ts | 70 +-- .../core-internal/src/shared/openTelemetry.ts | 73 ++- packages/core-internal/src/shared/protocol.ts | 19 +- .../test/shared/instrumentation.test.ts | 8 +- packages/server/package.json | 5 +- pnpm-lock.yaml | 12 +- .../integration/test/otel/cardinality.test.ts | 133 ++++ .../test/otel/sdkSetupOrder.test.ts | 62 ++ .../test/server/cloudflareWorkers.test.ts | 16 +- 13 files changed, 305 insertions(+), 689 deletions(-) delete mode 100644 docs/_meta/specs/otel.md create mode 100644 test/integration/test/otel/cardinality.test.ts create mode 100644 test/integration/test/otel/sdkSetupOrder.test.ts diff --git a/.changeset/protocol-instrumentation.md b/.changeset/protocol-instrumentation.md index 74723cc3ac..98bf519f6d 100644 --- a/.changeset/protocol-instrumentation.md +++ b/.changeset/protocol-instrumentation.md @@ -4,4 +4,14 @@ '@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, 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. Spans never carry tool arguments, tool results or prompt arguments. `@opentelemetry/api` is a new dependency of `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. The span and attribute names are experimental while the MCP semantic conventions are in development. +`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. diff --git a/docs/_meta/specs/otel.md b/docs/_meta/specs/otel.md deleted file mode 100644 index 2a01f02425..0000000000 --- a/docs/_meta/specs/otel.md +++ /dev/null @@ -1,567 +0,0 @@ -# OpenTelemetry support: design spec - -- **Status:** draft for maintainer review. A simpler version is implemented in [#3115](https://github.com/modelcontextprotocol/typescript-sdk/pull/3115); see [Implementation status](#implementation-status). -- **Tracking issue:** [#1264](https://github.com/modelcontextprotocol/typescript-sdk/issues/1264) ("OpenTelemetry integration out of the box", part of v2). -- **Related:** [#2370](https://github.com/modelcontextprotocol/typescript-sdk/pull/2370) (injectable `SdkLogger`), [#2820](https://github.com/modelcontextprotocol/typescript-sdk/pull/2820) (extensions and request middleware), [#2270](https://github.com/modelcontextprotocol/typescript-sdk/pull/2270) (SEP-414 `_meta` key constants), SEP-414, SEP-2577, SEP-2028. -- **Base:** `origin/main` at `b415e60b`. File and line references below are against that commit. - -This file lives under `docs/_meta/`, which `docs/.vitepress/config.mts` excludes from the published site. It is a design document, not a user page, so the docs register in `docs/_meta/CONVENTIONS.md` does not apply to it. - -## Implementation status - -[#3115](https://github.com/modelcontextprotocol/typescript-sdk/pull/3115) implements a simpler version of this spec, after feedback from Cloudflare's Workers observability team: the OpenTelemetry API is a no-op that looks for an SDK on `globalThis`, so the library can call it directly, and a runtime supports it by providing that interface. The PR differs from the sections below in these places: - -- **Built in and on by default, with no option.** `@opentelemetry/api` is a dependency of `client` and `server`, and the MCP mapping lives in `core-internal` (`shared/openTelemetry.ts`). Registering an OpenTelemetry SDK is the whole setup. There is no `instrumentation` option, no `captureContent`, and no separate `@modelcontextprotocol/otel` package. Users who want MCP spans dropped (for example because Sentry's MCP integration already traces them) filter the instrumentation scopes in their SDK. -- **The hook is internal.** `ProtocolInstrumentation` and its call sites in `Protocol` are unchanged, but `Client` and `Server` attach OpenTelemetry through a package-internal setter. Exporting the hook later, for vendors or alternative backends, is non-breaking. -- **Instrumentation scopes** are `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. -- **No Workers backend.** Workers will provide the OpenTelemetry API itself; until then a Workers OpenTelemetry SDK that registers a global tracer provider works. -- **No session hook or session metrics** yet (open question 7). -- **Tool arguments, results and prompt arguments are never recorded.** Opt-in content capture can come back as an option later without a break. - -## Summary - -## Summary - -1. Add one experimental, dependency-free option to `ProtocolOptions`: `instrumentation?: ProtocolInstrumentation`. `Protocol` calls it at four points (outbound message, inbound message, connect, close). Nothing runs and nothing changes on the wire when the option is absent. -2. Ship the OpenTelemetry mapping as a new package, `@modelcontextprotocol/otel`, with `@opentelemetry/api` as a peer dependency. It implements the MCP semantic conventions (spans, metrics, `_meta` propagation) on top of that option. Users opt in per instance with `{ instrumentation: openTelemetry() }`. -3. Telemetry is opt-in, unlike the Python SDK. Turning it on by default would add `_meta` keys to existing traffic and duplicate the spans Sentry already creates for every v2 `McpServer`. -4. Do not build this on `use` middleware or on extensions from #2820. Middleware only sees inbound requests for one exact method, and an extension advertises a capability on the wire. -5. #2370 is unaffected. `SdkLogger` stays the only path for SDK diagnostics. The OTel package does not ship a logs bridge in its first release. - -## Background - -### What the SDK does today - -- `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY` and `BAGGAGE_META_KEY` are public constants (`packages/core/src/constants.ts`, from #2270). The SDK passes these `_meta` keys through untouched. `packages/core-internal/test/shared/traceContextMeta.test.ts` pins the passthrough. -- The SEP-2577 deprecation JSDoc on `ServerContext.mcpReq.log`, `sendLoggingMessage` and friends already tells users to "migrate to stderr logging (STDIO servers) or OpenTelemetry". `docs/servers/logging-progress-cancellation.md` says the same. Today there is nothing in the SDK to migrate to. -- There is no span, metric or trace-context code anywhere in `packages/*/src`. - -### The semantic conventions - -The MCP conventions moved out of `open-telemetry/semantic-conventions` into [`open-telemetry/semantic-conventions-genai`](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md). I read them at commit `51a0f580` (2026-10-27). The whole document, every MCP attribute and every MCP metric is **Development** status. Names can still change in a minor semconv release, and they have: `mcp.request.id` has been dropped from the registry and `rpc.response.status_code` was added. - -The parts this spec relies on: - -- **Span name** `{mcp.method.name} {target}`, where target is `gen_ai.tool.name` or `gen_ai.prompt.name`. Plain `{mcp.method.name}` when there is no low-cardinality target. Resource URIs stay out of span names by default. -- **Span kind** `CLIENT` on the side that sends a request or notification, `SERVER` on the side that receives it. This holds in both directions, so a server sending `sampling/createMessage` emits a `CLIENT` span. -- **Attributes:** `mcp.method.name` (required); `error.type`, `rpc.response.status_code`, `gen_ai.tool.name`, `gen_ai.prompt.name`, `jsonrpc.request.id`, `mcp.resource.uri` (conditionally required); `gen_ai.operation.name` (`execute_tool` on tool calls only), `mcp.protocol.version`, `mcp.session.id`, `network.transport`, `network.protocol.name`, `network.protocol.version`, `server.address`, `server.port`, `client.address`, `client.port` (recommended); `gen_ai.tool.call.arguments`, `gen_ai.tool.call.result`, `gen_ai.prompt.variable.` (opt-in, sensitive). -- **Errors:** a JSON-RPC error sets `rpc.response.status_code` and `error.type` to the code as a string, and status `ERROR` with the JSON-RPC message as description. On the receiver, `-32700`, `-32600`, `-32601`, `-32602` and `-32002` are not errors. On the sender, every code is an error. A `CallToolResult` with `isError: true` sets `error.type` to `tool_error`. -- **Propagation:** inject with the configured OTel propagators into `params._meta` of every request and notification, using the unprefixed keys SEP-414 reserves. The receiver uses the extracted context as the parent of its `SERVER` span and links the ambient context (for example the HTTP server span). MCP and HTTP contexts are independent. -- **Metrics:** histograms `mcp.client.operation.duration`, `mcp.server.operation.duration`, `mcp.client.session.duration` and `mcp.server.session.duration`, unit `s`, explicit buckets `[0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300]`. -- **Outer GenAI span:** if the instrumentation can reliably tell that an outer `execute_tool` span already covers the tool call, it SHOULD add MCP attributes to that span instead of creating one. - -The well-known `mcp.method.name` values stop at the 2025-era methods. `server/discover`, `subscriptions/listen` and the Tasks extension methods are allowed as custom values but have no entries yet. - -### The MCP specification - -[SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/414-request-meta.md) (Final) reserves `traceparent`, `tracestate` and `baggage` in `_meta` with W3C formats. It says nothing about which messages carry them, about server-to-client traffic, or about HTTP headers. [SEP-2028](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2028), which would forward these keys to downstream HTTP headers, is still an open PR. This repo already rejected HTTP header forwarding without protocol support in [#666](https://github.com/modelcontextprotocol/typescript-sdk/pull/666). - -### Other official SDKs - -| SDK | How it ships | Default | What it emits | -| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Python (`src/mcp/shared/_otel.py`, `src/mcp/server/_otel.py`) | Hard dependency on `opentelemetry-api` | On for every server and client | `SERVER` spans from an `OpenTelemetryMiddleware` that is first in `Server.middleware`. `CLIENT` spans named `MCP send {method} {target}` from the dispatcher, with a TODO to move them to an outbound middleware. `_meta` inject and extract. No metrics. | -| C# (`src/ModelContextProtocol.Core/Diagnostics.cs`, `McpSessionHandler.cs`) | `System.Diagnostics` `ActivitySource` and `Meter`, both named `Experimental.ModelContextProtocol`. No extra dependency. | Free until a listener subscribes | All four metrics with the semconv buckets, spans in both directions, links to the ambient activity, reuse of an outer `execute_tool ` activity. Skips `notifications/message`. | -| Go | Nothing built in | n/a | n/a | - -Two things C# does that this spec deliberately does not copy. It records `mcp.resource.uri` as a metric attribute, which the conventions list as opt-in for metrics. It also puts a failed tool's `content` JSON into the span status description, which can leak tool output. - -### Third-party instrumentation of this SDK - -| Instrumentation | Targets | Mechanism | `_meta` propagation | -| ------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -| Sentry JS 11.1+ (`packages/core/src/integrations/mcp-server`) | v1 `@modelcontextprotocol/sdk` and v2 `@modelcontextprotocol/server` | Patches `McpServer.connect`, then `transport.onmessage` and `transport.send`, and every registered handler. Auto-wraps every `McpServer` when tracing is on. | No. Uses its own attribute names (`mcp.tool.name`, `mcp.prompt.name`) and records inputs and outputs by default. | -| OpenInference (`@arizeai/openinference-instrumentation-mcp`) | v1 module paths only (`@modelcontextprotocol/sdk/client/stdio.js` and so on) | Patches transport `send` and `onmessage` | Yes, inject and extract. Creates no spans of its own. | -| OpenLLMetry (`@traceloop/instrumentation-mcp`) | v1 `sdk/client/index.js`, `sdk/server/index.js` | Patches `Client` and `Server` | No | -| Logfire | Python only | n/a | n/a | - -All of them monkey-patch. That is why they lag the SDK's module layout (only Sentry knows about the v2 packages) and why ESM loaders crash in cases like [#1020](https://github.com/modelcontextprotocol/typescript-sdk/issues/1020). A supported hook removes the need to patch. - -## Goals - -1. Traces and metrics that follow the MCP semantic conventions, for `Client`, `McpServer` and low-level `Server`, on every transport, in both protocol eras (2025-11-25 and 2026-07-28). -2. Client-to-server and server-to-client trace context through `params._meta`, interoperable with the Python and C# SDKs. -3. Zero cost when unused. No instrumentation configured means no allocation, no extra branch beyond one `undefined` check per message, and byte-identical wire output. -4. Works in Node, Bun, Deno, Cloudflare Workers and browsers. -5. Safe defaults. No arguments, results, prompt variables or tokens recorded unless the user asks. -6. A hook other vendors (Sentry, Arize, Traceloop) can implement instead of patching. - -## Non-goals - -- Spans for transport work: HTTP requests, SSE streams, reconnects, stdio process lifecycle. HTTP and fetch instrumentations already cover these, and the conventions treat them as separate. -- Writing `traceparent` HTTP headers from MCP `_meta`, or reading MCP context from HTTP headers. That is SEP-2028's job if it lands. -- An OTel logs signal in the first release (see [Logs and #2370](#logs-and-2370)). -- Converting `ctx.mcpReq.log` or `notifications/message` into OTel log records. -- Configuring OTel SDKs, exporters or context managers. That stays the application's job. -- Detecting an outer `execute_tool` span. `@opentelemetry/api` exposes no span name, so the instrumentation cannot do it reliably, and the conventions only ask for it when it can. -- A v1.x backport (see [Rollout](#rollout)). - -## Dependency strategy - -All four options have to work in Node, Bun, Deno, Workers and browsers. `@opentelemetry/api` itself does: it is plain JavaScript with no dependencies and keeps its global state on `globalThis`. - -| Option | For | Against | -| ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A. `@opentelemetry/api` as a regular dependency of `core-internal`, bundled into `client` and `server` (Python's choice) | Zero setup. One package to install. | A new runtime dependency in every published package, which `DEPENDENCY_POLICY.md` treats as significant. Semconv is Development, so every rename would ship as a change in `client` and `server`. On-by-default would change the wire for existing users. | -| B. `@opentelemetry/api` as an optional peer of `client` and `server` | No install for users who don't want it | A static import breaks when the peer is missing. A dynamic `import()` makes setup async and confuses bundlers, Workers included. | -| C. No dependency. Core accepts the OTel API object (`{ api: otel }`) and implements the conventions against structural copies of the OTel interfaces | No new package | Core then owns a structural mirror of `Tracer`, `Span`, `Context`, `TextMapPropagator` and `Meter`, plus the whole semconv mapping. Semconv churn still lands in `client` and `server` releases. | -| D. **Recommended.** A small OTel-free hook in core, and a separate `@modelcontextprotocol/otel` with `@opentelemetry/api` as a peer | Core gains one option and a handful of types, and no dependency. The semconv mapping versions on its own line while it is Development. Vendors implement the same hook. | One more package to publish and document. | - -D matches what the ecosystem has converged on: `@hono/otel` and `@fastify/otel` sit outside their frameworks. The AI SDK is the closest precedent. Its v7 dropped the direct `@opentelemetry/api` dependency from `ai` in favour of a `Telemetry` interface in core, with spans moved into `@ai-sdk/otel` (which depends on `@opentelemetry/api`). Its interface has an `executeTool({ execute })` method that runs work inside the integration's context, which is the same job `OperationScope.run` does below. - -The peer range for `@opentelemetry/api` is `^1.9.0`. Histogram bucket advice (`advice.explicitBucketBoundaries`) needs 1.7 or later. - -## Public API - -### Core: `instrumentation` on `ProtocolOptions` - -`ProtocolOptions` (`packages/core-internal/src/shared/protocol.ts:72`) gets one optional field. `ClientOptions`, `ServerOptions` and `McpServerOptions` all extend it, so `Client`, `Server` and `McpServer` accept it with no further change. This differs from #2370, which moved `logger` off `ProtocolOptions` because low-level `Server` emits no diagnostics. Low-level `Server` does send and receive messages, so it should be instrumentable. - -```ts -export type ProtocolOptions = { - // ...existing fields... - - /** - * Observes every message this instance sends and receives, for tracing and metrics. - * - * @experimental The shape may change in a minor release. - */ - instrumentation?: ProtocolInstrumentation; -}; -``` - -The types, exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server` the same way `SdkLogger` is: - -```ts -/** @experimental */ -export interface ProtocolInstrumentation { - /** A request or notification is about to be sent. Write trace context into `operation.carrier`. */ - send?(operation: OutboundOperation): OperationScope | undefined; - /** A request or notification arrived and is about to be dispatched. */ - receive?(operation: InboundOperation): OperationScope | undefined; - /** The instance connected to a transport. The returned callback runs once when it closes. */ - connect?(session: SessionStart): ((end: SessionEnd) => void) | undefined; -} - -/** @experimental */ -export interface OperationBase { - readonly method: string; - /** Absent for notifications. */ - readonly id?: RequestId; - /** The params as they go on (or came off) the wire. Read-only. */ - readonly params?: Readonly>; - readonly sessionId?: string; - readonly protocolVersion?: string; -} - -/** @experimental */ -export interface OutboundOperation extends OperationBase { - /** Keys written here are copied into `params._meta`. Prefilled with caller-supplied `traceparent`, `tracestate` and `baggage`. */ - readonly carrier: Record; -} - -/** @experimental */ -export interface InboundOperation extends OperationBase { - /** The request or notification `_meta`, including `traceparent`, `tracestate` and `baggage` when present. */ - readonly meta?: Readonly>; - /** Whether a handler (or fallback handler) is registered for `method`. */ - readonly handled: boolean; - /** The HTTP request, when the transport supplied one. */ - readonly httpRequest?: Request; -} - -/** @experimental */ -export interface OperationScope { - /** Runs the operation's work in the instrumentation's context: the transport send, or the handler. Must call `fn` once. */ - run(fn: () => T): T; - /** Called exactly once when the operation settles. */ - end(outcome: OperationOutcome): void; -} - -/** @experimental */ -export type OperationOutcome = { type: 'result'; result: Result } | { type: 'error'; error: { code: number; message: string }; cause?: unknown } | { type: 'delivered' } | { type: 'timeout' } | { type: 'cancelled' } | { type: 'closed' } | { type: 'failed'; error: unknown }; - -/** @experimental */ -export interface SessionStart { - readonly role: 'client' | 'server'; -} - -/** @experimental */ -export interface SessionEnd { - readonly sessionId?: string; - readonly protocolVersion?: string; - readonly error?: unknown; -} -``` - -Rules `Protocol` follows when calling it: - -- It calls `send` or `receive` once per JSON-RPC request or notification, never for responses. Responses settle the scope of the request they answer. -- It calls `end` exactly once per scope, on every exit path. A `settled` flag guards it, the same way `cleanupMessageId` guards cleanup today. -- It never mutates caller-owned objects. The carrier is merged into a copy of `params._meta`. -- A throwing instrumentation method never fails the operation. `Protocol` catches the error, reports it through `onerror`, and carries on without that scope. If `run` throws before calling `fn`, `Protocol` calls `fn` itself. This is the same guarantee #2370's `guardLogger` gives for loggers. -- When `instrumentation` is undefined, each hook point is one property read and an `undefined` check. - -The hook is intentionally not OTel-shaped. Here is a non-OTel use, an audit log: - -```ts -const audit: ProtocolInstrumentation = { - receive(operation) { - const started = performance.now(); - return { - run: fn => fn(), - end: outcome => auditLog.write({ method: operation.method, outcome: outcome.type, ms: performance.now() - started }) - }; - } -}; -``` - -### `@modelcontextprotocol/otel` - -```ts -export interface OpenTelemetryOptions { - /** @default the global tracer provider */ - tracerProvider?: TracerProvider; - /** @default the global meter provider */ - meterProvider?: MeterProvider; - /** @default the global propagator (`propagation` from `@opentelemetry/api`) */ - propagator?: TextMapPropagator; - /** Record tool arguments and results and prompt arguments. Off by default. */ - captureContent?: boolean; -} - -export function openTelemetry(options?: OpenTelemetryOptions): ProtocolInstrumentation; -``` - -`openTelemetry()` creates its tracer and instruments once, with instrumentation scope name `@modelcontextprotocol/otel` and the package version. Create it once and share it across instances. That matters for `createMcpHandler`, whose factory builds a new `McpServer` for every HTTP request. - -There is no environment-variable configuration. Workers and browsers have no `process.env`, and `captureContent` is a privacy switch that belongs in code. - -Client: - -```ts -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { openTelemetry } from '@modelcontextprotocol/otel'; - -const client = new Client({ name: 'agent', version: '1.0.0' }, { instrumentation: openTelemetry() }); -await client.connect(new StreamableHTTPClientTransport(new URL('https://mcp.example.com/mcp'))); - -// Emits "tools/call get-weather" (CLIENT), a child of the span active here, -// and puts its traceparent in params._meta. -await client.callTool({ name: 'get-weather', arguments: { city: 'Lisbon' } }); -``` - -Server over HTTP: - -```ts -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; -import { openTelemetry } from '@modelcontextprotocol/otel'; -import * as z from 'zod/v4'; - -const instrumentation = openTelemetry(); - -export const handler = createMcpHandler(() => { - const server = new McpServer({ name: 'weather', version: '1.0.0' }, { instrumentation }); - server.registerTool('get-weather', { inputSchema: z.object({ city: z.string() }) }, async ({ city }) => ({ - // Spans created here are children of "tools/call get-weather" (SERVER). - content: [{ type: 'text', text: await forecast(city) }] - })); - return server; -}); -``` - -Server over stdio uses the same option on the instance the `serveStdio` factory returns. `invoke` and the legacy stateless fallback in `createMcpHandler` also connect the factory's instance, so all entry points pick up the option without changes to `createMcpHandler.ts`, `serveStdio.ts` or `invoke.ts`. - -## Where the hooks go - -| Hook | Location (`packages/core-internal/src/shared/protocol.ts` unless noted) | What happens | -| --------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Outbound request | `_requestWithSchemaViaCodec` (L1379), after `outbound` is built and before `this._transport.send` (L1588) | `send()`. Merge the carrier into `outbound.params._meta`. Wrap `transport.send` in `scope.run` so fetch instrumentation parents the HTTP span under the MCP span. | -| Request settles | Response handler (L1509), `cancel()` (L1465), the `.catch` on send, and `_onclose` (L836) | `end()` with `result` after schema validation, `error` for a JSON-RPC error, `failed` for `InvalidResult` or a send failure, `timeout` when `timeoutHandler` fires, `cancelled` when `options.signal` aborts, `closed` when the connection drops. `cancel()` already has separate call sites for timeout and abort, so the outcome is exact even though both reject with `RequestTimeout` today. | -| Multi-round-trip legs | `flow.retry` and `Client._resolveNonCompleteResult` (`packages/client/src/client/client.ts:743`) | Each retry leg goes through `_requestWithSchemaViaCodec`, so each leg gets its own scope. The client runs the embedded elicitation and sampling handlers inside the scope of the leg that returned `input_required`, so user spans in those handlers nest under that leg. | -| Outbound notification | `_notificationViaCodec` (L1620) | `send()`, merge the carrier, `scope.run` around `transport.send`, `end()` with `delivered` or `failed`. A debounced notification starts its scope inside the microtask (L1659) that writes it, so coalesced notifications produce one scope. | -| Inbound request | Top of `_onrequest` (L938) | `receive()` before the era, method and envelope checks, so rejections the protocol answers itself still get a scope. `sendErrorResponse` ends it with `error`. The handler call (L1115) runs inside `scope.run`, which covers #2820's middleware too. After the result is encoded, `end()` with `result`, or `error` with the thrown value as `cause`. A request aborted by `notifications/cancelled` ends with `cancelled`. | -| Inbound notification | `_onnotification` (L877) | `receive()`, `scope.run` around the handler, `end()` with `delivered`, or `failed` if the handler throws. | -| Session | `connect` (L794) and `_onclose` (L836) | `connect()` when the transport starts. The returned callback runs from `_onclose` with the session id and negotiated version as they were at close. | - -`McpServer`, `Client` and the transports need no telemetry code. The tool or prompt name and the resource URI come from `params`. Negotiated version comes from `_negotiatedProtocolVersion`, session id from `transport.sessionId`, and the HTTP request from `MessageExtraInfo.request`. - -Rejections `createMcpHandler` produces before any instance exists (header ladder, unsupported version, body too large, 405 on GET) never reach `Protocol`. They produce no MCP span. The HTTP server span from the user's HTTP instrumentation already covers them. - -## Spans - -### What gets a span - -| Operation | Sender | Receiver | Notes | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Any request (`initialize`, `server/discover`, `ping`, `tools/*`, `resources/*`, `prompts/*`, `completion/complete`, `logging/setLevel`, `subscriptions/listen`, custom methods) | `CLIENT` | `SERVER` | One span per JSON-RPC request on the wire. Version probing during `Client.connect` (`server/discover`, then a fallback `initialize`) yields one span per probe, which is what happened on the wire. | -| 2025-era server-to-client requests (`sampling/createMessage`, `elicitation/create`, `roots/list`) | `CLIENT` on the server | `SERVER` on the client | Sent through `ctx.mcpReq.send`, so the `CLIENT` span is a child of the handler's `SERVER` span. | -| 2026-era input requests embedded in `input_required` results | none | none | Not wire messages. Each retry leg is a normal `CLIENT`/`SERVER` pair. | -| Notifications (`notifications/initialized`, `progress`, `cancelled`, `*/list_changed`, `resources/updated`, custom) | `CLIENT` | `SERVER` | The span ends when the transport accepts the write (sender) or the handler returns (receiver). | -| `notifications/message` | none | none | The hook still sees it. The OTel package skips it, following C#. It is deprecated by SEP-2577 and usually carries log traffic users want to see as logs, not spans. | -| Responses | none | none | A response settles its request's span. | - -### Name and kind - -- Name: `{method} {target}` for `tools/call` (target `params.name`) and `prompts/get` (target `params.name`), else `{method}`. Never the resource URI. -- Receiver-side unknown methods: when `handled` is false, the name and `mcp.method.name` are `_OTHER`, borrowing the HTTP conventions' rule for unknown methods. A peer can otherwise send any string as a method name and blow up cardinality. -- Receiver-side unknown tools and prompts: `McpServer` answers an unknown or disabled tool or prompt with `-32602`. When a `tools/call` or `prompts/get` span ends with `-32602`, the OTel package renames it to the bare method with `updateName` and drops `gen_ai.tool.name` or `gen_ai.prompt.name` from metric attributes. A rejected name stays on the span as an attribute only. That also covers a prompt whose arguments fail validation, which costs us the prompt name on those metrics. I think that trade is fine. -- `CLIENT` for outbound, `SERVER` for inbound, whatever the instance's role. - -### Attributes - -| Attribute | Set when | Source | -| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| `mcp.method.name` | Always | `operation.method`, or `_OTHER` as above | -| `jsonrpc.request.id` | Requests | `String(operation.id)` | -| `mcp.protocol.version` | Once negotiated | `operation.protocolVersion` | -| `mcp.session.id` | When the transport has a session id (legacy stateful Streamable HTTP) | `operation.sessionId`. The 2026-07-28 era and stdio have none. | -| `gen_ai.operation.name` | `tools/call` only | `execute_tool` | -| `gen_ai.tool.name` | `tools/call` | `params.name` | -| `gen_ai.prompt.name` | `prompts/get` | `params.name` | -| `mcp.resource.uri` | `resources/read`, `resources/subscribe`, `resources/unsubscribe`, `notifications/resources/updated` | `params.uri`. Spans only. | -| `rpc.response.status_code` | A JSON-RPC error response | `String(error.code)` | -| `error.type` | Failure (see below) | | -| `network.transport`, `network.protocol.name` | Receiver, when `httpRequest` is present | `tcp` and `http`. Left unset otherwise; see open question 6. | -| `gen_ai.tool.call.arguments`, `gen_ai.tool.call.result`, `gen_ai.prompt.variable.` | Only with `captureContent: true` | See [Privacy](#privacy) | - -`server.address`, `server.port`, `client.address`, `client.port` and `network.protocol.version` are left unset. `Protocol` can't see the URL or socket, and fetch instrumentation records them on the HTTP span anyway. - -The OTel package computes attributes only when `span.isRecording()` is true. A sampled-out or no-op span costs one `startSpan` and one `end`. - -### Status and `error.type` - -| Outcome | `error.type` | `rpc.response.status_code` | Status | -| -------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `result`, `tools/call` with `isError: true` | `tool_error` | unset | `ERROR`, no description (the content stays out of telemetry) | -| `result`, anything else (including `input_required`) | unset | unset | `UNSET` | -| `error`, sender side | code as string | code as string | `ERROR`, description = JSON-RPC message | -| `error`, receiver side, code in `-32700`, `-32600`, `-32601`, `-32602`, `-32002` | unset | code as string | `UNSET` | -| `error`, receiver side, any other code | code as string | code as string | `ERROR`, description = JSON-RPC message. If `cause` is an exception that is not a `ProtocolError`, also `recordException(cause)`. | -| `timeout` | `timeout` | unset | `ERROR` | -| `cancelled` | `cancelled` | unset | `ERROR` (open question 4) | -| `closed` | `connection_closed` | unset | `ERROR` | -| `failed` with an `SdkError` | its `code` (`SEND_FAILED`, `INVALID_RESULT`, ...) | unset | `ERROR` | -| `failed`, anything else | `error.name` when it is a standard `Error` subclass name, else `_OTHER` | unset | `ERROR` | - -The package docs list every `error.type` value it can emit, as the conventions ask. - -### Feature notes - -- **`initialize` and `server/discover`.** Ordinary request spans. `mcp.protocol.version` is unset on the first `initialize` because nothing is negotiated yet. The conventions' examples set it from the request. Doing the same means reading `params.protocolVersion`, which is cheap, so the package does it for these two methods. -- **Progress.** `notifications/progress` sent from a handler inherits the handler's `SERVER` context and injects it, so on the requester the progress span is a child of the remote handler span. That is accurate but chatty on long jobs. Progress callbacks registered with `onprogress` run inside that inbound span. -- **Cancellation.** The `notifications/cancelled` the SDK sends on abort gets its own `CLIENT` span. The 2026-07-28 Streamable HTTP path cancels by aborting the request stream and sends nothing, so there is no notification span there, just `cancelled` on the request. `initialize` is never cancelled on the wire, so it only ends `cancelled` locally. -- **Tasks extension (SEP-2663, #2782).** `tools/call` answered with `resultType: "task"` ends its span when that response is sent. `tasks/get`, `tasks/result` and `tasks/cancel` are ordinary request spans. The background work behind a task outlives the `tools/call` span. The Tasks extension should capture the active context when it creates the task and start the work's spans as children of it. That belongs in the Tasks PR, not here. There is no task id attribute in the conventions, and the package will not invent one (open question 10). -- **`subscriptions/listen`.** The span covers the whole subscription, which can last hours and lands every observation in the top bucket of `mcp.client.operation.duration` (open question 11). -- **Custom methods.** Spans like any other method. The method name is recorded when a handler is registered for it. -- **Middleware (#2820).** `use` middleware runs inside `scope.run`, so spans it creates are children of the `SERVER` span, and an answer it returns without calling `next` still ends the span with that result. - -### Transport-level spans - -The SDK creates protocol spans only. On Streamable HTTP the expected shape matches the conventions' example. The client's `transport.send` runs inside the `CLIENT` span's context, so fetch or undici instrumentation makes the `POST` a child of the MCP span. On the server, the HTTP server span is ambient when the message arrives, and the MCP `SERVER` span links to it. [#1231](https://github.com/modelcontextprotocol/typescript-sdk/issues/1231) (undici marks successful POSTs as aborted on `close()`) makes those HTTP spans look wrong today. It is a separate bug, but it will be more visible once MCP spans sit on top. - -## Context propagation - -### Outbound - -1. `Protocol` prefills `operation.carrier` with any `traceparent`, `tracestate` and `baggage` strings the caller already put in `params._meta`. -2. The OTel package extracts the carrier. If it holds a valid context, that becomes the `CLIENT` span's parent. Otherwise the active context is the parent. -3. It starts the span, then injects the span's context with the configured propagator into the carrier, replacing what was there. -4. `Protocol` writes the carrier's keys into a copy of `params._meta`. - -A caller who set `traceparent` by hand keeps the same trace and gets a more precise parent on the server, the SDK's `CLIENT` span instead of their own. With no instrumentation configured, nothing changes and the passthrough test keeps passing as it does today. - -Both requests and notifications get injected, as the conventions say. Server-to-client traffic does the same thing in reverse. - -### Inbound - -1. The OTel package extracts `operation.meta`. A missing, malformed or invalid `traceparent` yields no context, and the request is served normally. The SDK never rejects a message over its trace context. -2. With a valid extracted context, it is the `SERVER` span's parent and the active span at arrival (the HTTP server span, say) becomes a link. -3. Without one, the active context is the parent and there is no link. That matches Python and keeps the MCP span inside the HTTP trace instead of starting an orphan. -4. `scope.run` makes the `SERVER` span active for the handler and everything it awaits. - -### HTTP headers - -The SDK neither writes nor reads `traceparent` HTTP headers for MCP context. On the client, fetch instrumentation adds the HTTP-level header for the HTTP span, which is already a child of the MCP span. On the server, HTTP instrumentation continues that header's trace for the HTTP span, and the MCP span follows rule 2 or 3 above. A peer that sends only the HTTP header still ends up in one trace through rule 3. Forwarding `_meta` to downstream HTTP calls is SEP-2028's business. - -### Peers without support - -`_meta` is open, SEP-414 reserves these keys, and every conforming peer ignores keys it doesn't know. Nothing needs negotiating. A peer that sends no trace context gets rule 3. The SDK neither advertises nor requires anything. - -### Context managers by runtime - -Parent extraction passes contexts explicitly, so it works everywhere. Making the handler's span active for user code needs a context manager registered by the application: - -| Runtime | Context manager | -| ------------------ | ------------------------------------------------------------------------------------------------------------------ | -| Node, Bun, Deno | `AsyncLocalStorageContextManager` from `@opentelemetry/context-async-hooks` | -| Cloudflare Workers | An `AsyncLocalStorage`-based manager, with `nodejs_compat` | -| Browsers | `ZoneContextManager`, or none. Without one, MCP spans are still correct but user spans inside handlers won't nest. | - -`scope.run` wraps the call that creates the handler's promise chain (`Promise.resolve().then(() => handler(...))`). `AsyncLocalStorage` carries the context into every continuation of that chain, including the response `send`. - -## Metrics - -| Instrument | Type | Unit | Recorded | -| ------------------------------- | --------- | ---- | -------------------------------------------------- | -| `mcp.client.operation.duration` | Histogram | `s` | When an outbound scope ends | -| `mcp.server.operation.duration` | Histogram | `s` | When an inbound scope ends | -| `mcp.client.session.duration` | Histogram | `s` | When a `Client` connection closes | -| `mcp.server.session.duration` | Histogram | `s` | When a server connection with a `sessionId` closes | - -All four pass `advice: { explicitBucketBoundaries: [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300] }`. Durations come from `performance.now()`. On Workers that clock only advances across I/O, so durations there measure I/O time. The docs will say so. - -Operation metric attributes come from a fixed allowlist: `mcp.method.name`, `error.type`, `rpc.response.status_code`, `gen_ai.operation.name`, `gen_ai.tool.name`, `gen_ai.prompt.name`, `mcp.protocol.version`, and `network.transport` and `network.protocol.name` when known. Session metric attributes are `error.type`, `mcp.protocol.version` and the network pair. - -Never on metrics: `mcp.session.id`, `jsonrpc.request.id`, `mcp.resource.uri` (opt-in in the conventions, not offered in this version), and content attributes. - -Cardinality on the receiver is bounded by what the server has registered. Unknown methods become `_OTHER`, rejected tool and prompt names are dropped, `error.type` comes from the documented list plus JSON-RPC codes, and protocol versions are a short list. The OTel metrics SDK's per-instrument cardinality limit (2000 by default) is the backstop. On the sender, tool and prompt names come from the server's listing, which the client chose to connect to. - -Session duration is only recorded where a session means something. A `Client` connection always counts, from `connect` to close. On the server, `createMcpHandler` builds one instance per HTTP request on the 2026-07-28 era and the stateless legacy path. Recording each of those as a session would fill the histogram with millisecond "sessions". So the server metric only records when the transport had a `sessionId`, which means legacy stateful Streamable HTTP. A stdio server is a real session without a session id, and this rule misses it (open question 7). - -## Logs and #2370 - -#2370 adds `SdkLogger` (optional `debug` and `warn`) on `Client`, `McpServer`, `createMcpHandler` and `OAuthClientProvider`, wrapped by `guardLogger` so a throwing logger never fails an SDK call. This spec leaves it alone: - -- **#2370 lands as it is.** Neither change depends on the other, and they can merge in either order. `ProtocolOptions` gains `instrumentation` while `logger` stays on `ClientOptions` and `McpServerOptions`. -- **`SdkLogger` stays the path for SDK diagnostics.** The instrumentation hook does not carry diagnostics. Some diagnostics come from the OAuth flow, which runs in `auth()` and the auth providers, outside any `Protocol`. #2370 put the logger on `OAuthClientProvider` for exactly that reason, and a protocol hook can't reach that code. -- **No logs bridge in the first release.** `@opentelemetry/api-logs` is still 0.x. Users who want SDK diagnostics as OTel log records write a small adapter, which the docs page will show: - -```ts -import { logs, SeverityNumber } from '@opentelemetry/api-logs'; -import type { SdkLogger } from '@modelcontextprotocol/server'; - -const otelLogger = logs.getLogger('@modelcontextprotocol/server'); -const logger: SdkLogger = { - debug: (...args) => otelLogger.emit({ severityNumber: SeverityNumber.DEBUG, body: args.map(String).join(' ') }), - warn: (...args) => otelLogger.emit({ severityNumber: SeverityNumber.WARN, body: args.map(String).join(' ') }) -}; -``` - -- **Later, if asked for:** `@modelcontextprotocol/otel` can export `openTelemetryLogger(): SdkLogger`. That is additive and changes nothing in #2370's API. -- **MCP protocol logging** (`notifications/message`) is a separate thing. The SDK does not turn it into OTel logs. The deprecated-logging docs will link the new OpenTelemetry page as the migration target SEP-2577 names. - -On KKonstantinov's review comment about RFC 5424 levels: OTel severity numbers cover that mapping if a bridge ever ships (`debug` is `DEBUG` 5, `warn` is `WARN` 13). It doesn't affect this spec. - -## Privacy - -Never recorded by default: - -- Tool arguments and results, prompt arguments, completion values -- Resource contents, sampling messages, elicitation requests and responses -- The `content` of a failed tool result (C# records it, this does not) -- JSON-RPC error `data` -- HTTP headers, `authInfo`, access tokens, OAuth state -- `requestState` and `inputResponses` (2026-07-28 multi-round-trip material) -- Baggage values as attributes - -Recorded by default, and worth knowing about: - -- **`mcp.resource.uri`** on spans. The conventions make it conditionally required, but URIs can carry user names, paths or database names. Open question 9 asks whether to keep the default. -- **`mcp.session.id`.** The spec says session ids must be unguessable and must not be used for authentication, so recording one doesn't grant access. It is still an identifier that lands in telemetry storage. -- **JSON-RPC error messages** as status descriptions. The message is already on the wire to the peer. On the receiver it can echo client input, such as the tool name in "Tool X not found". -- **Exception message and stack** for handler failures that are not `ProtocolError`. These are developer-written strings and stay in the operator's backend. -- **Baggage propagation.** The default global propagator usually includes W3C Baggage, which sends baggage entries to the MCP server, possibly a third party. `openTelemetry({ propagator: new W3CTraceContextPropagator() })` sends trace context only (open question 8). - -With `captureContent: true`, the package adds: - -- `gen_ai.tool.call.arguments`: `params.arguments` as a JSON string. `@opentelemetry/api` span attributes can't hold structured values, and the conventions allow a JSON string on spans in that case. -- `gen_ai.tool.call.result`: `structuredContent` when present, otherwise `content`, as a JSON string, on success only. -- `gen_ai.prompt.variable.` for each `prompts/get` argument. - -Serialization happens only when the span is recording. Length limits come from the OTel SDK's `attributeValueLengthLimit`. - -## Coexistence with other instrumentations - -- **Sentry.** Sentry 11.1+ auto-wraps every v2 `McpServer` by patching `connect` and the transport. With `openTelemetry()` also on, the server gets two spans per message, and they won't nest, because ours parents to the client's `_meta` context. The docs should tell users to pick one. The real fix is Sentry implementing `ProtocolInstrumentation` instead of patching, and the experimental period is the time to ask them (open question 12). -- **OpenInference, OpenLLMetry.** They patch v1 module paths only, so they don't touch v2 packages. Someone mixing a v1 client with OpenInference and a v2 server with this package gets working propagation, because both use the same `_meta` keys. -- **Python and C# peers.** Same keys, same span names (except Python's `MCP send` client prefix) and same attributes, so cross-language traces join. - -## Why not #2820's middleware or extensions - -#2820 adds `Protocol.use(method, middleware)` and extensions shaped `{ id, setup(ctx) }`. I checked both against what telemetry needs. - -| Need | `use` middleware | Extension | -| -------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------- | -| Every inbound request | No. Method names are exact and there is no wildcard, by design. | Only through `ctx.use`, same limit | -| Inbound notifications | No | No | -| Outbound requests and notifications, `_meta` injection | No | No | -| Errors the protocol answers before dispatch (`-32601`, era mismatch, missing envelope) | No. Middleware runs after those checks. | No | -| Timeouts, local cancel, connection close on the sender | No | No | -| Leaves the wire alone | Yes | No. Every extension is advertised under `capabilities.extensions[id]`. | -| Status | `@experimental` | `@experimental` | - -Python built its server spans on middleware because its server middleware already sees every message, and it still had to hardcode client spans in the dispatcher with a TODO to move them. Telemetry is not a protocol extension, and it shouldn't show up in capabilities. So the hook is separate. It composes with middleware: the scope wraps the resolved handler, middleware included. If `use` later gets a wildcard and an outbound side, moving the OTel package onto it is an internal change to the package that users never see. - -## Testing - -Unit tests for the hook in `packages/core-internal/test/shared/instrumentation.test.ts`, using a fake `ProtocolInstrumentation` over `InMemoryTransport.createLinkedPair()`: - -- `end` runs exactly once on every exit path: result, JSON-RPC error, handler throw, timeout, abort, abort before send, connection close, send failure, `InvalidResult`, era mismatch, method not found, missing envelope, `notifications/cancelled` on an in-flight handler, debounced notification. -- The carrier lands in `_meta` without mutating the caller's params. Caller-supplied trace keys are prefilled. -- A throwing `send`, `receive`, `run`, `end` or `connect` never changes the operation's result, and reaches `onerror`. -- With no instrumentation, outbound messages are byte-identical to today. Extend `traceContextMeta.test.ts` with this. - -The OTel package tests use `BasicTracerProvider` with `InMemorySpanExporter`, `MeterProvider` with an in-memory `MetricReader`, and `AsyncLocalStorageContextManager`: - -- Table-driven checks of name, kind, parent, links, attributes and status for every row in the span and status tables, on both eras. -- Client and server in one process: `CLIENT` to `SERVER` parenting through `_meta`, and a user span inside a tool handler nested under the `SERVER` span. -- A 2025-era `elicitation/create` from inside a tool: the server's `CLIENT` span is a child of its `SERVER` span, and the client's `SERVER` span is a child of that. -- A 2026-era `input_required` round trip: two `tools/call` legs, embedded handler spans under the first leg. -- Privacy: no argument, result or tool-error content without `captureContent`. -- Cardinality: an unknown method and an unknown tool name appear in neither span names nor metric attributes. -- Metrics: bucket advice, attribute allowlist, no session metric for per-request instances. -- Optional CI step: run OTel Weaver's `registry live-check` against the emitted telemetry, pinned to a semconv-genai commit. - -End-to-end and runtime tests: - -- New entries in `test/e2e/requirements.ts`, for example `observability:trace-context:client-to-server`, `observability:trace-context:server-to-client` and `observability:no-wire-change-when-disabled`, with scenarios across the stdio, Streamable HTTP and in-memory cells. -- `createMcpHandler` behind an instrumented HTTP server: the MCP `SERVER` span links to the HTTP server span. -- The existing runtime suites (`test/integration/test/server/bun.test.ts`, `deno.test.ts`, `cloudflareWorkers.test.ts`) import the package and serve one traced call. -- Conformance: the MCP conformance suite has no SEP-414 scenario today. Contributing one belongs upstream (open question 13). - -## Docs - -- A new how-to page, `docs/servers/opentelemetry.md` or `docs/advanced/opentelemetry.md`, written to `docs/_meta/CONVENTIONS.md`. It covers install, client and server setup, what spans and metrics show up (quoting real output from a companion example), propagation, `captureContent`, the context-manager table, the Workers clock caveat, and using it with Sentry. -- The package README, including the full `error.type` list. -- `docs/servers/logging-progress-cancellation.md`: point "use OpenTelemetry instead" at the new page, and add the `SdkLogger` adapter example once #2370 is merged. -- `docs/clients/middleware.md`: one line noting that fetch middleware runs inside the MCP `CLIENT` span's context. -- A companion example under `examples/` (stdio client and server, console exporter) that produces the output the docs page quotes. - -## Rollout - -Stacked PRs that can each land alone: - -1. **Hook in core.** `instrumentation` on `ProtocolOptions`, the types, the call sites, and unit tests. Changeset: minor for `core-internal`, `client` and `server`, flagged `@experimental`. No new dependency. -2. **`@modelcontextprotocol/otel` with traces and propagation.** New package at `packages/otel/`, independently versioned like the framework integrations, peer dependency `@opentelemetry/api ^1.9.0`. -3. **Metrics.** -4. **`captureContent`.** -5. **Docs page and example.** -6. **E2E requirements and runtime smoke tests.** Can fold into 2 to 4 as they land. - -Before PR 1, maintainers sign off on #1264 per the discuss-before-you-code rule in `CONTRIBUTING.md`. That is what this document is for. - -Semver: - -- The hook is `@experimental`, so its shape can change in a minor release, matching `use`, extensions and `acceptResultType` in #2820. It drops the tag once a second implementation (Sentry or another vendor) has used it. -- The OTel package starts at `0.x` while the MCP conventions are Development. Semconv renames ship as minors in `0.x` with a changeset naming each renamed attribute. When the conventions go stable, the package goes to `1.0` and follows them. If GenAI semconv adopts an `OTEL_SEMCONV_STABILITY_OPT_IN` transition for MCP, the package follows it then. -- Nothing is breaking. Without the option, behaviour and wire output are unchanged. - -v1.x backport: no. `CONTRIBUTING.md` limits `v1.x` to bug fixes, and this is a feature. v1 users already have OpenInference, OpenLLMetry and Sentry, all of which target v1 module paths. If maintainers want v1 coverage later, the package's semconv mapping can sit behind a `Transport` decorator, since v1 and v2 share the transport shape. That stays out of scope here. - -## Open questions - -1. **New package.** Is a new published `@modelcontextprotocol/otel` acceptable, on its own `0.x` line? The alternative is option A, a direct `@opentelemetry/api` dependency in `client` and `server` (Python's approach). -2. **Opt-in.** Python traces every server by default. This spec is opt-in for wire-compatibility and Sentry reasons. Should the SDK instead turn on when a global tracer provider is registered? -3. **Where the hook lives.** `ProtocolOptions`, which includes low-level `Server`, or only `ClientOptions` and `McpServerOptions`? A single `instrumentation`, or an array so the OTel package and a vendor hook can both run? -4. **Cancelled requests.** Should a caller's abort set status `ERROR`, or record `error.type: cancelled` with status `UNSET`? The conventions don't say. -5. **Where the hook types come from.** The OTel package has to name `ProtocolInstrumentation` without depending on `client` or `server` (it serves both). The options are a structural copy plus a compile-time assignability test, or exporting the types from `@modelcontextprotocol/core`, which is schemas and constants today. -6. **`network.transport` on stdio and on the client.** `Protocol` can't tell stdio from in-memory, or HTTP on the client side. Add an optional self-describing field to `Transport`, or leave these recommended attributes unset? -7. **Server session duration.** Only legacy stateful HTTP qualifies under the current rule. Should `serveStdio` and plain `connect(stdioTransport)` count as sessions, and how should `Protocol` know? -8. **Baggage.** Should the default propagator send W3C Baggage to MCP servers, which may be third parties, or should the default be trace context only? -9. **`mcp.resource.uri` on spans.** Keep it on by default because the conventions make it conditionally required, or make it opt-in for privacy? -10. **Semconv gaps.** `server/discover`, `subscriptions/listen`, the Tasks methods and a task id attribute have no conventions yet. Who takes these to `semantic-conventions-genai`? -11. **`subscriptions/listen` durations.** Leave long-lived subscriptions in `mcp.*.operation.duration` per the conventions, or exclude them? -12. **Vendors.** Should Sentry and Arize be invited to implement the hook during the experimental period, before it is stabilized? -13. **Conformance.** Should someone propose a SEP-414 propagation scenario to the MCP conformance suite, so all SDKs are tested the same way? -14. **Remote parents on public servers.** Should there be an option to start a new trace and only link the client's context, for servers that don't trust callers' trace ids? The conventions say to use the remote parent by default. diff --git a/docs/advanced/opentelemetry.md b/docs/advanced/opentelemetry.md index 822f1e155c..c62a782a0b 100644 --- a/docs/advanced/opentelemetry.md +++ b/docs/advanced/opentelemetry.md @@ -4,7 +4,7 @@ shape: how-to # OpenTelemetry -`Client`, `McpServer` and `Server` emit OpenTelemetry spans and metrics for every MCP request and notification, 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. +`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'; @@ -85,6 +85,10 @@ The server span comes from the `@modelcontextprotocol/server` instrumentation sc } ``` +::: 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. ::: @@ -102,7 +106,7 @@ mcp.client.operation.duration (s) { } ``` -Metrics never carry session ids, request ids or resource URIs. A server records a method it has no handler for as `_OTHER`, and leaves an unknown tool or prompt name out, so a client can't grow your metric cardinality. +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 @@ -113,7 +117,7 @@ Failed operations set `error.type` and an `ERROR` status: - 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 never carry tool arguments, tool results or prompt arguments. +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 @@ -131,4 +135,4 @@ The `examples/opentelemetry` story runs a traced client and server in separate p - 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 never carry tool arguments, tool results or prompt arguments. +- Spans don't record tool arguments, tool results or prompt arguments. diff --git a/packages/client/package.json b/packages/client/package.json index 626562d5d3..636456879d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -147,7 +147,6 @@ }, "dependencies": { "@modelcontextprotocol/core": "workspace:*", - "@opentelemetry/api": "catalog:otel", "cross-spawn": "catalog:runtimeClientOnly", "eventsource": "catalog:runtimeClientOnly", "eventsource-parser": "catalog:runtimeClientOnly", @@ -155,7 +154,11 @@ "pkce-challenge": "catalog:runtimeShared", "zod": "catalog:runtimeShared" }, + "peerDependencies": { + "@opentelemetry/api": "catalog:otel" + }, "devDependencies": { + "@opentelemetry/api": "catalog:otel", "@modelcontextprotocol/core-internal": "workspace:^", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", diff --git a/packages/core-internal/src/shared/instrumentation.ts b/packages/core-internal/src/shared/instrumentation.ts index 49e8bba028..abaecaf3c8 100644 --- a/packages/core-internal/src/shared/instrumentation.ts +++ b/packages/core-internal/src/shared/instrumentation.ts @@ -2,94 +2,42 @@ import { SdkError, SdkErrorCode } from '../errors/sdkErrors'; import type { RequestId, Result } from '../types/index'; import { BAGGAGE_META_KEY, ProtocolError, TRACEPARENT_META_KEY, TRACESTATE_META_KEY } from '../types/index'; -/** - * Observes the requests and notifications a {@linkcode Protocol} instance sends - * and receives, for tracing and metrics. Pass it as - * {@linkcode ProtocolOptions.instrumentation}. - * - * The SDK calls `send` or `receive` once per JSON-RPC request or notification - * (never for responses) and calls `end` on the returned scope exactly once when - * that message settles. An instrumentation that throws never fails the - * operation: the error goes to `onerror` and the operation carries on. - * - * @experimental The shape may change in a minor release. - */ +// Observes the requests and notifications a Protocol instance sends and receives. `end` runs exactly once per scope. export interface ProtocolInstrumentation { - /** A request or notification is about to be sent. Trace-context keys written to `operation.carrier` go into `params._meta`. */ send?(operation: OutboundOperation): OperationScope | undefined; - /** A request or notification arrived and is about to be handled. */ receive?(operation: InboundOperation): OperationScope | undefined; } -/** - * Fields shared by {@linkcode OutboundOperation} and {@linkcode InboundOperation}. - * - * @experimental - */ export interface OperationBase { readonly method: string; - /** The JSON-RPC request id; absent for notifications. */ + // Absent for notifications. readonly id?: RequestId; - /** The params as they go on, or came off, the wire. Do not mutate. */ + // As the params go on, or came off, the wire. Do not mutate. readonly params?: Readonly>; - /** The transport's session id, when it has one. */ readonly sessionId?: string; - /** The negotiated protocol version, once there is one. */ + // The negotiated protocol version, once there is one. readonly protocolVersion?: string; } -/** - * A request or notification this instance is sending. - * - * @experimental - */ export interface OutboundOperation extends OperationBase { - /** - * Keys written here are copied into the outgoing `params._meta`. Prefilled - * with any `traceparent`, `tracestate` and `baggage` the caller already set. - */ + // Keys written here go into `params._meta`; prefilled with caller-supplied trace-context keys. readonly carrier: Record; } -/** - * A request or notification this instance received. - * - * @experimental - */ export interface InboundOperation extends OperationBase { - /** The message's `params._meta`, including `traceparent`, `tracestate` and `baggage` when present. */ readonly meta?: Readonly>; - /** Whether a handler (or fallback handler) is registered for `method`. */ - readonly handled: boolean; - /** The HTTP request the message arrived in, when the transport supplied one. */ + // A spec method, or one with its own handler. Fallback handlers don't count: the name is client-chosen. + readonly knownMethod: boolean; readonly httpRequest?: Request; } -/** - * The lifetime of one observed operation. - * - * @experimental - */ export interface OperationScope { - /** Runs the operation's work (the transport send, or the handler) in the instrumentation's context. Must call `fn` once. */ + // Runs the operation's work (the transport send, or the handler) in the instrumentation's context; must call `fn` once. run(fn: () => T): T; - /** Called exactly once when the operation settles. */ end(outcome: OperationOutcome): void; } -/** - * How an observed operation settled. - * - * - `result`: a request answered with a result, as it went on or came off the wire. - * - `error`: a request answered with a JSON-RPC error; `cause` is what the local handler threw, if anything. - * - `delivered`: a notification was written to the transport, or its handler returned. - * - `timeout`: the request timed out locally. - * - `cancelled`: the request was cancelled, by the caller's signal or by the peer. - * - `closed`: the connection closed before the operation settled. - * - `failed`: any other local failure, such as a failed send or a result that fails validation. - * - * @experimental - */ +// `error` is a JSON-RPC error answer; `delivered` a sent or handled notification; `failed` any other local failure. export type OperationOutcome = | { type: 'result'; result: Result } | { type: 'error'; error: { code: number; message: string }; cause?: unknown } diff --git a/packages/core-internal/src/shared/openTelemetry.ts b/packages/core-internal/src/shared/openTelemetry.ts index e23f82744e..707d30103a 100644 --- a/packages/core-internal/src/shared/openTelemetry.ts +++ b/packages/core-internal/src/shared/openTelemetry.ts @@ -1,4 +1,4 @@ -import type { Attributes, Context, Histogram, Link, Span, TextMapGetter } from '@opentelemetry/api'; +import type { Attributes, Context, Histogram, Link, MeterProvider, Span, TextMapGetter } from '@opentelemetry/api'; import { context, defaultTextMapSetter, @@ -10,7 +10,9 @@ import { trace } from '@opentelemetry/api'; +import { SUPPORTED_PROTOCOL_VERSIONS } from '../types/index'; import type { InboundOperation, OperationBase, OperationOutcome, OutboundOperation, ProtocolInstrumentation } from './instrumentation'; +import { SUPPORTED_MODERN_PROTOCOL_VERSIONS } from './protocolEras'; /** Bucket boundaries the MCP semantic conventions specify for every MCP duration histogram. */ const DURATION_BUCKETS = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300]; @@ -24,6 +26,9 @@ const RESOURCE_URI_METHODS = new Set(['resources/read', 'resources/subscribe', ' /** Log notifications are deprecated (SEP-2577) and belong in logs, not spans. */ const SKIPPED_METHODS = new Set(['notifications/message']); +/** Protocol versions a receiver may record before negotiation; anything else a client sends is unbounded. */ +const KNOWN_PROTOCOL_VERSIONS = new Set([...SUPPORTED_PROTOCOL_VERSIONS, ...SUPPORTED_MODERN_PROTOCOL_VERSIONS]); + const STANDARD_ERROR_NAMES = new Set(['Error', 'TypeError', 'RangeError', 'SyntaxError', 'AbortError', 'TimeoutError']); /** Attributes that may go on metrics; everything else is span-only. */ @@ -47,20 +52,18 @@ const metaGetter: TextMapGetter>> = { type Side = 'client' | 'server'; -/** - * OpenTelemetry instrumentation for MCP traffic, following the OpenTelemetry semantic conventions - * for MCP: one span per request and notification, the `mcp.client.operation.duration` and - * `mcp.server.operation.duration` histograms, and trace context in `params._meta`. It goes through - * the global OpenTelemetry API, so nothing is recorded until the application registers an SDK. - */ +// MCP spans, duration histograms and `_meta` trace context through the global OpenTelemetry API. export function openTelemetryInstrumentation(scopeName: string): ProtocolInstrumentation { const tracer = trace.getTracer(scopeName); - // Created on first use: the global meter provider has no proxy, so instruments made before the SDK starts would stay no-ops. + // Recreated when the global meter provider changes: the metrics API has no proxy, so instruments from an earlier provider stay no-ops. let durations: Record | undefined; + let durationsProvider: MeterProvider | undefined; const duration = (side: Side): Histogram => { - if (durations === undefined) { - const meter = metrics.getMeter(scopeName); + const provider = metrics.getMeterProvider(); + if (durations === undefined || durationsProvider !== provider) { + durationsProvider = provider; + const meter = provider.getMeter(scopeName); const histogram = (name: string, description: string) => meter.createHistogram(name, { unit: 's', description, advice: { explicitBucketBoundaries: DURATION_BUCKETS } }); durations = { @@ -100,18 +103,15 @@ export function openTelemetryInstrumentation(scopeName: string): ProtocolInstrum case 'error': { const { code, message } = outcome.error; span.setAttribute('rpc.response.status_code', String(code)); - metricAttributes['rpc.response.status_code'] = String(code); + // A server can answer with any code; outside the JSON-RPC reserved range it is bucketed on the client's metrics. + const metricCode = side === 'client' && !isReservedErrorCode(code) ? '_OTHER' : String(code); + metricAttributes['rpc.response.status_code'] = metricCode; if (side === 'client' || !NOT_RECEIVER_ERRORS.has(code)) { errorType = String(code); + metricAttributes['error.type'] = metricCode; span.setStatus({ code: SpanStatusCode.ERROR, message }); if (side === 'server' && isUnexpectedException(outcome.cause)) span.recordException(outcome.cause); } - // An unknown or disabled tool or prompt: keep the client-chosen name out of the span name and metrics. - if (side === 'server' && code === INVALID_PARAMS && target !== undefined) { - span.updateName(method); - delete metricAttributes['gen_ai.tool.name']; - delete metricAttributes['gen_ai.prompt.name']; - } break; } case 'timeout': @@ -136,7 +136,13 @@ export function openTelemetryInstrumentation(scopeName: string): ProtocolInstrum } if (errorType !== undefined) { span.setAttribute('error.type', errorType); - metricAttributes['error.type'] = errorType; + metricAttributes['error.type'] ??= errorType; + } + // On the server a tool or prompt name is client-chosen: keep it only once the handler has recognised it. + if (side === 'server' && target !== undefined && !recognisedTarget(outcome)) { + span.updateName(method); + delete metricAttributes['gen_ai.tool.name']; + delete metricAttributes['gen_ai.prompt.name']; } span.end(); duration(side).record((performance.now() - started) / 1000, metricAttributes); @@ -146,14 +152,14 @@ export function openTelemetryInstrumentation(scopeName: string): ProtocolInstrum send(operation: OutboundOperation) { if (SKIPPED_METHODS.has(operation.method)) return; const target = targetOf(operation); - const attributes = startAttributes(operation, operation.method, target); + const attributes = startAttributes(operation, operation.method, target, 'client'); // Trace context the caller already put in `_meta` parents the span; otherwise the active context does. const parent = extract(context.active(), operation.carrier); const span = tracer.startSpan(spanName(operation.method, target), { kind: SpanKind.CLIENT, attributes }, parent); const spanContext = trace.setSpan(parent, span); const injected: Record = {}; // Injected with the span's context active: some propagators (Sentry's) only inject the active context. - context.with(spanContext, () => propagation.inject(context.active(), injected, defaultTextMapSetter)); + context.with(spanContext, () => propagation.inject(spanContext, injected, defaultTextMapSetter)); Object.assign(operation.carrier, injected); const metricAttributes = pickMetricAttributes(attributes); const started = performance.now(); @@ -165,10 +171,10 @@ export function openTelemetryInstrumentation(scopeName: string): ProtocolInstrum receive(operation: InboundOperation) { if (SKIPPED_METHODS.has(operation.method)) return; - // A method nobody handles is a client-chosen string: record it as `_OTHER` to bound cardinality. - const method = operation.handled ? operation.method : '_OTHER'; - const target = operation.handled ? targetOf(operation) : undefined; - const attributes = startAttributes(operation, method, target); + // Any other method name is client-chosen: record it as `_OTHER` to bound cardinality. + const method = operation.knownMethod ? operation.method : '_OTHER'; + const target = operation.knownMethod ? targetOf(operation) : undefined; + const attributes = startAttributes(operation, method, target, 'server'); if (operation.httpRequest !== undefined) { attributes['network.protocol.name'] = 'http'; attributes['network.transport'] = 'tcp'; @@ -209,11 +215,14 @@ function spanName(method: string, target: string | undefined): string { return target === undefined ? method : `${method} ${target}`; } -function startAttributes(operation: OperationBase, method: string, target: string | undefined): Attributes { +function startAttributes(operation: OperationBase, method: string, target: string | undefined, side: Side): Attributes { const attributes: Attributes = { 'mcp.method.name': method }; if (operation.id !== undefined) attributes['jsonrpc.request.id'] = String(operation.id); - const requestedVersion = operation.method === 'initialize' ? operation.params?.protocolVersion : undefined; - const protocolVersion = operation.protocolVersion ?? (typeof requestedVersion === 'string' ? requestedVersion : undefined); + // Before negotiation `initialize` names a version; a receiver only records one it supports. + const requested = operation.method === 'initialize' ? operation.params?.protocolVersion : undefined; + const requestedVersion = + typeof requested === 'string' && (side === 'client' || KNOWN_PROTOCOL_VERSIONS.has(requested)) ? requested : undefined; + const protocolVersion = operation.protocolVersion ?? requestedVersion; if (protocolVersion !== undefined) attributes['mcp.protocol.version'] = protocolVersion; if (operation.sessionId !== undefined) attributes['mcp.session.id'] = operation.sessionId; if (method === 'tools/call') { @@ -236,6 +245,16 @@ function pickMetricAttributes(attributes: Attributes): Attributes { return picked; } +/** Whether the outcome shows the server's handler recognised the requested tool or prompt (unknown ones answer -32602). */ +function recognisedTarget(outcome: OperationOutcome): boolean { + return outcome.type === 'result' || (outcome.type === 'error' && outcome.error.code !== INVALID_PARAMS); +} + +/** JSON-RPC reserves -32768 to -32000; codes outside it are application-defined and unbounded. */ +function isReservedErrorCode(code: number): boolean { + return Number.isInteger(code) && code >= -32_768 && code <= -32_000; +} + /** A thrown value that is not a protocol error (those carry a numeric `code`) and is worth an exception event. */ function isUnexpectedException(cause: unknown): cause is Error { return cause instanceof Error && typeof (cause as { code?: unknown }).code !== 'number'; diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index fad0052c0e..3707a19450 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -559,11 +559,7 @@ export function setNegotiatedProtocolVersion( let writeInstrumentation: (instance: Protocol, instrumentation: ProtocolInstrumentation) => void; -/** - * Package-internal: attaches the instrumentation that observes a {@linkcode Protocol} - * instance's traffic. `Client` and `Server` attach OpenTelemetry in their constructors. - * Exported on the core internal barrel only — never public API. - */ +// Package-internal: `Client` and `Server` attach OpenTelemetry through this. Not public API. export function setProtocolInstrumentation( instance: Protocol, instrumentation: ProtocolInstrumentation @@ -608,7 +604,6 @@ export abstract class Protocol { }; } - /** Observes this instance's traffic; attached package-internally via `setProtocolInstrumentation`. */ private _instrumentation?: ProtocolInstrumentation; protected _supportedProtocolVersions: string[]; @@ -951,7 +946,11 @@ export abstract class Protocol { return; } - const scope = this._startInbound(rawNotification, true, extra); + const scope = this._startInbound( + rawNotification, + this._notificationHandlers.has(notification.method) || isSpecNotificationMethod(notification.method), + extra + ); // Starting with Promise.resolve() puts any synchronous errors into the monad as well. runInScope(scope, () => @@ -993,7 +992,7 @@ export abstract class Protocol { const scope = this._startInbound( rawRequest, - this._requestHandlers.has(request.method) || this.fallbackRequestHandler !== undefined, + this._requestHandlers.has(request.method) || isSpecRequestMethod(request.method), extra ); @@ -1795,7 +1794,7 @@ export abstract class Protocol { /** Starts the instrumentation scope for an inbound request or notification. */ private _startInbound( message: JSONRPCRequest | JSONRPCNotification, - handled: boolean, + knownMethod: boolean, extra?: MessageExtraInfo ): GuardedScope | undefined { const instrumentation = this._instrumentation; @@ -1810,7 +1809,7 @@ export abstract class Protocol { ...(params !== undefined && { params }), ...(isPlainObject(meta) && { meta }), ...this._operationSession(), - handled, + knownMethod, ...(extra?.request !== undefined && { httpRequest: extra.request }) }), error => this._onerror(error) diff --git a/packages/core-internal/test/shared/instrumentation.test.ts b/packages/core-internal/test/shared/instrumentation.test.ts index 2cb20d1660..dce510b66e 100644 --- a/packages/core-internal/test/shared/instrumentation.test.ts +++ b/packages/core-internal/test/shared/instrumentation.test.ts @@ -104,7 +104,7 @@ describe('Protocol instrumentation', () => { expect(sent.outcomes).toEqual([{ type: 'result', result: { ok: true } }]); const received = server.find('receive', 'acme/echo')!; - expect(received.operation).toMatchObject({ method: 'acme/echo', id: 0, handled: true, meta: { traceparent: INJECTED } }); + expect(received.operation).toMatchObject({ method: 'acme/echo', id: 0, knownMethod: true, meta: { traceparent: INJECTED } }); expect(received.outcomes).toEqual([{ type: 'result', result: { ok: true } }]); expect(metaInHandler).toEqual({ traceparent: INJECTED }); expect(activeInHandler).toBe('receive acme/echo'); @@ -169,14 +169,14 @@ describe('Protocol instrumentation', () => { ]); }); - it('reports an unhandled method with handled: false and a method-not-found error', async () => { + it('reports an unknown method with knownMethod: false and a method-not-found error', async () => { const server = recorder(); const { a } = await pair({ b: server.instrumentation }); await expect(a.request({ method: 'acme/missing', params: {} }, Ok)).rejects.toThrow(); const received = server.find('receive', 'acme/missing')!; - expect(received.operation.handled).toBe(false); + expect(received.operation.knownMethod).toBe(false); expect(received.outcomes).toEqual([ { type: 'error', error: { code: ProtocolErrorCode.MethodNotFound, message: 'Method not found' } } ]); @@ -278,7 +278,7 @@ describe('Protocol instrumentation', () => { expect(client.find('send', 'acme/event')!.outcomes).toEqual([{ type: 'delivered' }]); const received = server.find('receive', 'acme/event')!; - expect(received.operation).toMatchObject({ method: 'acme/event', handled: true, meta: { traceparent: INJECTED } }); + expect(received.operation).toMatchObject({ method: 'acme/event', knownMethod: true, meta: { traceparent: INJECTED } }); expect(received.operation.id).toBeUndefined(); expect(received.outcomes).toEqual([{ type: 'delivered' }]); expect(activeInHandler).toBe('receive acme/event'); diff --git a/packages/server/package.json b/packages/server/package.json index db576e34cc..b61acddc68 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -160,10 +160,13 @@ }, "dependencies": { "@modelcontextprotocol/core": "workspace:*", - "@opentelemetry/api": "catalog:otel", "zod": "catalog:runtimeShared" }, + "peerDependencies": { + "@opentelemetry/api": "catalog:otel" + }, "devDependencies": { + "@opentelemetry/api": "catalog:otel", "@cfworker/json-schema": "catalog:runtimeShared", "ajv": "catalog:runtimeShared", "ajv-formats": "catalog:runtimeShared", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0afcc93b2c..a095e50e80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1389,9 +1389,6 @@ importers: '@modelcontextprotocol/core': specifier: workspace:* version: link:../core - '@opentelemetry/api': - specifier: catalog:otel - version: 1.10.0 cross-spawn: specifier: catalog:runtimeClientOnly version: 7.0.6 @@ -1432,6 +1429,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 '@types/content-type': specifier: catalog:devTools version: 1.1.9 @@ -1889,9 +1889,6 @@ importers: '@modelcontextprotocol/core': specifier: workspace:* version: link:../core - '@opentelemetry/api': - specifier: catalog:otel - version: 1.10.0 zod: specifier: catalog:runtimeShared version: 4.3.6 @@ -1917,6 +1914,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 '@types/eventsource': specifier: catalog:devTools version: 1.1.15 diff --git a/test/integration/test/otel/cardinality.test.ts b/test/integration/test/otel/cardinality.test.ts new file mode 100644 index 0000000000..ff843f357b --- /dev/null +++ b/test/integration/test/otel/cardinality.test.ts @@ -0,0 +1,133 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/client'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { McpServer, Server } from '@modelcontextprotocol/server'; +import { SpanKind } from '@opentelemetry/api'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { registerTestSdk } from './sdk'; + +const sdk = registerTestSdk(); +beforeEach(() => sdk.reset()); +afterAll(() => sdk.unregister()); + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +/** Connects a raw transport to `server`, so a test can send exactly the messages a hostile peer would. */ +async function rawPeer(server: McpServer | Server) { + const [peer, serverSide] = InMemoryTransport.createLinkedPair(); + const received: JSONRPCMessage[] = []; + peer.onmessage = message => received.push(message); + await server.connect(serverSide); + await peer.start(); + return { peer, received }; +} + +const initialize = (protocolVersion: string) => + ({ + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { protocolVersion, capabilities: {}, clientInfo: { name: 'peer', version: '1' } } + }) as const; + +/** Metric attribute sets recorded for a histogram. */ +const metricAttributes = async (name: string) => { + const { points } = await sdk.histogram(name); + return points.map(point => point.attributes); +}; + +describe('client-chosen strings stay out of span names and metrics', () => { + it('records methods served only by a fallback handler as _OTHER, but keeps spec method names', async () => { + const server = new Server({ name: 'gateway', version: '1.0.0' }, { capabilities: { completions: {} } }); + server.fallbackRequestHandler = async () => ({}); + server.fallbackNotificationHandler = async () => {}; + const client = new Client({ name: 'agent', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + await client.request({ method: 'x/random-1234', params: {} }, z.object({})); + await client.notification({ method: 'x/random-5678', params: {} }); + await client.request( + { method: 'completion/complete', params: { ref: { type: 'ref/prompt', name: 'p' }, argument: { name: 'a', value: 'b' } } }, + z.object({}) + ); + await tick(); + + const serverSpans = sdk.spans().filter(span => span.kind === SpanKind.SERVER); + expect(serverSpans.filter(span => span.name === '_OTHER')).toHaveLength(2); + expect(serverSpans.map(span => span.name)).toContain('completion/complete'); + expect(serverSpans.map(span => span.name).join(',')).not.toContain('random'); + expect(JSON.stringify(await metricAttributes('mcp.server.operation.duration'))).not.toContain('random'); + await client.close(); + }); + + it('drops an unknown tool name when the call is cancelled before the server answers', async () => { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + server.registerTool('get-weather', { inputSchema: z.object({}) }, () => ({ content: [] })); + const { peer } = await rawPeer(server); + await peer.send(initialize('2025-11-25')); + await peer.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + // The cancellation lands before the -32602 the unknown tool would have produced. + await peer.send({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'random-tool-9876', arguments: {} } }); + await peer.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1, reason: 'never mind' } }); + await tick(); + + const span = sdk.span('tools/call', SpanKind.SERVER); + expect(span.attributes['error.type']).toBe('cancelled'); + expect(JSON.stringify(await metricAttributes('mcp.server.operation.duration'))).not.toContain('random-tool'); + await server.close(); + }); + + it('records the protocol version an initialize requests only when the server supports it', async () => { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + const { peer } = await rawPeer(server); + + await peer.send(initialize('bogus-version-4242')); + await tick(); + + const span = sdk.span('initialize', SpanKind.SERVER); + expect(span.attributes['mcp.protocol.version']).toBeUndefined(); + expect(JSON.stringify(await metricAttributes('mcp.server.operation.duration'))).not.toContain('bogus-version'); + await server.close(); + + sdk.reset(); + const supported = new McpServer({ name: 'weather', version: '1.0.0' }); + const second = await rawPeer(supported); + await second.peer.send(initialize('2025-11-25')); + await tick(); + expect(sdk.span('initialize', SpanKind.SERVER).attributes['mcp.protocol.version']).toBe('2025-11-25'); + await supported.close(); + }); + + it("buckets a server's application-defined error codes on the client's metrics, keeping the exact code on the span", async () => { + const [clientTransport, peer] = InMemoryTransport.createLinkedPair(); + peer.onmessage = message => { + if (!('id' in message) || !('method' in message)) return; + if (message.method === 'initialize') { + void peer.send({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 'hostile', version: '1' } } + }); + } else { + void peer.send({ jsonrpc: '2.0', id: message.id, error: { code: 918_273, message: 'made up' } }); + } + }; + await peer.start(); + const client = new Client({ name: 'agent', version: '1.0.0' }); + await client.connect(clientTransport); + + await expect(client.request({ method: 'x/anything', params: {} }, z.object({}))).rejects.toThrow('made up'); + + expect(sdk.span('x/anything', SpanKind.CLIENT).attributes).toMatchObject({ + 'error.type': '918273', + 'rpc.response.status_code': '918273' + }); + const clientMetrics = await metricAttributes('mcp.client.operation.duration'); + expect(clientMetrics).toContainEqual(expect.objectContaining({ 'error.type': '_OTHER', 'rpc.response.status_code': '_OTHER' })); + expect(JSON.stringify(clientMetrics)).not.toContain('918273'); + await client.close(); + }); +}); diff --git a/test/integration/test/otel/sdkSetupOrder.test.ts b/test/integration/test/otel/sdkSetupOrder.test.ts new file mode 100644 index 0000000000..cab3166744 --- /dev/null +++ b/test/integration/test/otel/sdkSetupOrder.test.ts @@ -0,0 +1,62 @@ +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { McpServer } from '@modelcontextprotocol/server'; +import { metrics, propagation, trace } from '@opentelemetry/api'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; +import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; +import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace'; +import { afterAll, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +class CollectingMetricReader extends MetricReader { + protected async onShutdown(): Promise {} + protected async onForceFlush(): Promise {} +} + +// Deliberately incomplete setups: no context manager, and a meter provider registered after the first MCP traffic. +const exporter = new InMemorySpanExporter(); +const tracerProvider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] }); +trace.setGlobalTracerProvider(tracerProvider); +propagation.setGlobalPropagator(new W3CTraceContextPropagator()); + +afterAll(async () => { + await tracerProvider.shutdown(); + trace.disable(); + propagation.disable(); + metrics.disable(); +}); + +async function callTool(onMeta: (meta: unknown) => void) { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + server.registerTool('get-weather', { inputSchema: z.object({}) }, (_args, ctx) => { + onMeta(ctx.mcpReq._meta); + return { content: [] }; + }); + const client = new Client({ name: 'agent', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + await client.callTool({ name: 'get-weather', arguments: {} }); + await client.close(); +} + +describe('OpenTelemetry setups the SDK has to tolerate', () => { + it('injects trace context without a registered context manager', async () => { + let meta: unknown; + await callTool(m => (meta = m)); + + const clientSpan = exporter.getFinishedSpans().find(span => span.name === 'tools/call get-weather' && span.kind === 2)!; + const { traceId, spanId } = clientSpan.spanContext(); + expect(meta).toEqual({ traceparent: `00-${traceId}-${spanId}-01` }); + }); + + it('records duration metrics once a meter provider is registered after the first MCP traffic', async () => { + await callTool(() => {}); + const reader = new CollectingMetricReader(); + metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] })); + + await callTool(() => {}); + + const { resourceMetrics } = await reader.collect(); + const names = resourceMetrics.scopeMetrics.flatMap(scope => scope.metrics.map(metric => metric.descriptor.name)); + expect(names).toEqual(expect.arrayContaining(['mcp.client.operation.duration', 'mcp.server.operation.duration'])); + }); +}); diff --git a/test/integration/test/server/cloudflareWorkers.test.ts b/test/integration/test/server/cloudflareWorkers.test.ts index a30831ff67..a3366919af 100644 --- a/test/integration/test/server/cloudflareWorkers.test.ts +++ b/test/integration/test/server/cloudflareWorkers.test.ts @@ -82,16 +82,18 @@ function packWorkspacePackage(tempDir: string, packageDirName: string): string { scripts?: unknown; devDependencies?: unknown; dependencies?: Record; + peerDependencies?: Record; }; delete manifest.scripts; delete manifest.devDependencies; - const dependencies = manifest.dependencies ?? {}; - for (const [name, spec] of Object.entries(dependencies)) { - if (spec.startsWith('catalog:') || spec.startsWith('workspace:')) { - const installed = JSON.parse(fs.readFileSync(path.join(pkgPath, 'node_modules', name, 'package.json'), 'utf8')) as { - version: string; - }; - dependencies[name] = installed.version; + for (const dependencies of [manifest.dependencies ?? {}, manifest.peerDependencies ?? {}]) { + for (const [name, spec] of Object.entries(dependencies)) { + if (spec.startsWith('catalog:') || spec.startsWith('workspace:')) { + const installed = JSON.parse(fs.readFileSync(path.join(pkgPath, 'node_modules', name, 'package.json'), 'utf8')) as { + version: string; + }; + dependencies[name] = installed.version; + } } } fs.writeFileSync(path.join(stagingDir, 'package.json'), JSON.stringify(manifest, null, 2)); From 3e2aefa2f6f60a8567cafa47b8e4ba83d23c2bcb Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 28 Oct 2026 09:11:26 +0000 Subject: [PATCH 6/6] fix: keep a server tool name only when a handler answered 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. --- .changeset/protocol-instrumentation.md | 2 +- .../core-internal/src/shared/openTelemetry.ts | 4 ++-- test/integration/test/otel/cardinality.test.ts | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.changeset/protocol-instrumentation.md b/.changeset/protocol-instrumentation.md index 98bf519f6d..efe97b4abd 100644 --- a/.changeset/protocol-instrumentation.md +++ b/.changeset/protocol-instrumentation.md @@ -6,7 +6,7 @@ `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. +`@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: diff --git a/packages/core-internal/src/shared/openTelemetry.ts b/packages/core-internal/src/shared/openTelemetry.ts index 707d30103a..d9b735f7f0 100644 --- a/packages/core-internal/src/shared/openTelemetry.ts +++ b/packages/core-internal/src/shared/openTelemetry.ts @@ -245,9 +245,9 @@ function pickMetricAttributes(attributes: Attributes): Attributes { return picked; } -/** Whether the outcome shows the server's handler recognised the requested tool or prompt (unknown ones answer -32602). */ +/** Whether a handler answered and recognised the tool or prompt: errors sent before dispatch carry no cause, and unknown names answer -32602. */ function recognisedTarget(outcome: OperationOutcome): boolean { - return outcome.type === 'result' || (outcome.type === 'error' && outcome.error.code !== INVALID_PARAMS); + return outcome.type === 'result' || (outcome.type === 'error' && outcome.cause !== undefined && outcome.error.code !== INVALID_PARAMS); } /** JSON-RPC reserves -32768 to -32000; codes outside it are application-defined and unbounded. */ diff --git a/test/integration/test/otel/cardinality.test.ts b/test/integration/test/otel/cardinality.test.ts index ff843f357b..87aca2ab92 100644 --- a/test/integration/test/otel/cardinality.test.ts +++ b/test/integration/test/otel/cardinality.test.ts @@ -80,6 +80,23 @@ describe('client-chosen strings stay out of span names and metrics', () => { await server.close(); }); + it('drops a tool name sent to a server that has no tool handler at all', async () => { + const server = new McpServer({ name: 'docs', version: '1.0.0' }); + server.registerResource('readme', 'file:///readme.md', {}, uri => ({ contents: [{ uri: uri.href, text: '# hi' }] })); + const { peer, received } = await rawPeer(server); + await peer.send(initialize('2025-11-25')); + await peer.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + await peer.send({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'random-tool-5555', arguments: {} } }); + await tick(); + + expect(received).toContainEqual(expect.objectContaining({ id: 1, error: expect.objectContaining({ code: -32_601 }) })); + const span = sdk.span('tools/call', SpanKind.SERVER); + expect(span.attributes['rpc.response.status_code']).toBe('-32601'); + expect(JSON.stringify(await metricAttributes('mcp.server.operation.duration'))).not.toContain('random-tool'); + await server.close(); + }); + it('records the protocol version an initialize requests only when the server supports it', async () => { const server = new McpServer({ name: 'weather', version: '1.0.0' }); const { peer } = await rawPeer(server);