From e0e1545df5c403c201a316777449726ef350a931 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 21 Sep 2026 10:08:26 +0200 Subject: [PATCH 1/3] feat(server-utils): Auto-instrument MCP servers via orchestrion Add an `mcpServer` integration that wraps `McpServer` instances at construction through an orchestrion constructor hook, so users no longer need to call `wrapMcpServerWithSentry` by hand. Covers both `@modelcontextprotocol/server` (v2, matched in its content-hashed chunk) and `@modelcontextprotocol/sdk` (v1). `wrapMcpServerWithSentry` stays a supported, idempotent no-op when the instance was already auto-wrapped. Rename the existing manual node-integration-test suite to `mcp-server/manual-instrumentation` and add `mcp-server/v1` and `mcp-server/v2` suites that exercise the auto-wrapping path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../manual-instrumentation}/instrument.mjs | 0 .../scenario-start-v2.mjs | 0 .../manual-instrumentation}/scenario-v1.mjs | 0 .../manual-instrumentation}/scenario.mjs | 0 .../manual-instrumentation}/test.ts | 4 +- .../tracing/mcp-server/v1/instrument.mjs | 9 +++ .../suites/tracing/mcp-server/v1/scenario.mjs | 28 +++++++ .../suites/tracing/mcp-server/v1/test.ts | 79 +++++++++++++++++++ .../tracing/mcp-server/v2/instrument.mjs | 9 +++ .../suites/tracing/mcp-server/v2/scenario.mjs | 27 +++++++ .../suites/tracing/mcp-server/v2/test.ts | 72 +++++++++++++++++ .../core/src/integrations/mcp-server/index.ts | 5 ++ .../mcp-server/mcpServerWrapper.test.ts | 4 + packages/server-utils/src/index.ts | 1 + .../server-utils/src/integrations/index.ts | 2 + .../src/integrations/mcp-server.ts | 54 +++++++++++++ .../server-utils/src/orchestrion/channels.ts | 2 + .../config/channel-integration-definitions.ts | 1 + .../src/orchestrion/config/index.ts | 2 + .../src/orchestrion/config/mcp-server.ts | 51 ++++++++++++ 20 files changed, 348 insertions(+), 2 deletions(-) rename dev-packages/node-integration-tests/suites/tracing/{mcp-server-streamed => mcp-server/manual-instrumentation}/instrument.mjs (100%) rename dev-packages/node-integration-tests/suites/tracing/{mcp-server-streamed => mcp-server/manual-instrumentation}/scenario-start-v2.mjs (100%) rename dev-packages/node-integration-tests/suites/tracing/{mcp-server-streamed => mcp-server/manual-instrumentation}/scenario-v1.mjs (100%) rename dev-packages/node-integration-tests/suites/tracing/{mcp-server-streamed => mcp-server/manual-instrumentation}/scenario.mjs (100%) rename dev-packages/node-integration-tests/suites/tracing/{mcp-server-streamed => mcp-server/manual-instrumentation}/test.ts (97%) create mode 100644 dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/test.ts create mode 100644 packages/server-utils/src/integrations/mcp-server.ts create mode 100644 packages/server-utils/src/orchestrion/config/mcp-server.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/instrument.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/instrument.mjs rename to dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/instrument.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-start-v2.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/scenario-start-v2.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-start-v2.mjs rename to dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/scenario-start-v2.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-v1.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/scenario-v1.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-v1.mjs rename to dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/scenario-v1.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/scenario.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario.mjs rename to dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/scenario.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/test.ts similarity index 97% rename from dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts rename to dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/test.ts index d53f5756a4b6..ecf2f6e2ef7c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/test.ts @@ -1,6 +1,6 @@ import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; -import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; function mcpSpans(container: SerializedStreamedSpanContainer): SerializedStreamedSpanContainer['items'] { return container.items.filter(item => item.attributes['sentry.op']?.value === 'mcp.server'); @@ -20,7 +20,7 @@ function assertInitializeSpan(container: SerializedStreamedSpanContainer): void expect(initializeSpan.attributes['test.mcp.initialize_spans_started']).toEqual({ type: 'integer', value: 1 }); } -describe('MCP server spans (streamed)', () => { +describe('MCP server spans (streamed, manual instrumentation)', () => { afterAll(() => { cleanupChildProcesses(); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/scenario.mjs new file mode 100644 index 000000000000..03b2198132f2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/scenario.mjs @@ -0,0 +1,28 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; + +// Intentionally NOT wrapped with `wrapMcpServerWithSentry`: the `mcpServer` integration +// auto-instruments the `McpServer` constructor via orchestrion, so spans must appear anyway. +const server = new McpServer({ name: 'Echo', version: '1.0.0' }); + +server.registerResource('echo', new ResourceTemplate('echo://{message}', { list: undefined }), {}, async uri => ({ + contents: [{ uri: uri.href, text: 'Resource echo' }], +})); + +server.registerTool('echo', {}, async () => ({ content: [{ type: 'text', text: 'Tool echo' }] })); + +async function run() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + await client.readResource({ uri: 'echo://foobar' }); + await client.callTool({ name: 'echo', arguments: {} }); + + await client.close(); + await server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/test.ts new file mode 100644 index 000000000000..178858873426 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/test.ts @@ -0,0 +1,79 @@ +import type { SerializedStreamedSpanContainer } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +function mcpSpan( + container: SerializedStreamedSpanContainer, + method: string, +): SerializedStreamedSpanContainer['items'][number] { + const span = container.items.find( + item => + item.attributes['sentry.op']?.value === 'mcp.server' && item.attributes['mcp.method.name']?.value === method, + ); + // Throwing here makes the (unordered) runner treat this container as "not the one" and wait for + // the next — streaming batches several segments per container, so the target may be elsewhere. + expect(span, `expected an mcp.server span for ${method}`).toBeDefined(); + return span!; +} + +// The `McpServer` is never manually wrapped — these assertions only pass if the `mcpServer` +// integration auto-instrumented the constructor of the legacy (`@modelcontextprotocol/sdk` v1) SDK. +// Each span type is asserted in its own runner so batched span containers can't consume an envelope +// another assertion still needs. +describe('MCP server spans (streamed, auto-instrumentation, v1)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments the initialize handshake', async () => { + await createTestRunner() + .unordered() + .expect({ + span: container => { + const initialize = mcpSpan(container, 'initialize'); + expect(initialize.name).toBe('initialize'); + expect(initialize.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.function.mcp_server', + }); + }, + }) + .start() + .completed(); + }); + + test('auto-instruments a resource read', async () => { + await createTestRunner() + .unordered() + .expect({ + span: container => { + const resource = mcpSpan(container, 'resources/read'); + expect(resource.name).toBe('resources/read'); + expect(resource.attributes['mcp.resource.uri']?.value).toBe('echo://foobar'); + }, + }) + .start() + .completed(); + }); + + test('auto-instruments a tool call', async () => { + await createTestRunner() + .unordered() + .expect({ + span: container => { + const tool = mcpSpan(container, 'tools/call'); + expect(tool.name).toBe('tools/call echo'); + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { '@modelcontextprotocol/sdk': '1.30.0' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/scenario.mjs new file mode 100644 index 000000000000..8abcb9b54ac4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/scenario.mjs @@ -0,0 +1,27 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; + +// Intentionally NOT wrapped with `wrapMcpServerWithSentry`: the `mcpServer` integration +// auto-instruments the `McpServer` constructor via orchestrion, so spans must appear anyway. +const server = new McpServer({ name: 'Echo', version: '1.0.0' }); + +server.registerResource('echo', new ResourceTemplate('echo://{message}', { list: undefined }), {}, async uri => ({ + contents: [{ uri: uri.href, text: 'Resource echo' }], +})); + +server.registerTool('echo', {}, async () => ({ content: [{ type: 'text', text: 'Tool echo' }] })); + +async function run() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + await client.readResource({ uri: 'echo://foobar' }); + await client.callTool({ name: 'echo', arguments: {} }); + + await client.close(); + await server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/test.ts b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/test.ts new file mode 100644 index 000000000000..2b1a56785f5b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/test.ts @@ -0,0 +1,72 @@ +import type { SerializedStreamedSpanContainer } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +function mcpSpan( + container: SerializedStreamedSpanContainer, + method: string, +): SerializedStreamedSpanContainer['items'][number] { + const span = container.items.find( + item => + item.attributes['sentry.op']?.value === 'mcp.server' && item.attributes['mcp.method.name']?.value === method, + ); + // Throwing here makes the (unordered) runner treat this container as "not the one" and wait for + // the next — streaming batches several segments per container, so the target may be elsewhere. + expect(span, `expected an mcp.server span for ${method}`).toBeDefined(); + return span!; +} + +// The `McpServer` is never manually wrapped — these assertions only pass if the `mcpServer` +// integration auto-instrumented the constructor. Each span type is asserted in its own runner so +// batched span containers can't consume an envelope another assertion still needs. +describe('MCP server spans (streamed, auto-instrumentation, v2)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('auto-instruments the initialize handshake', async () => { + await createTestRunner() + .unordered() + .expect({ + span: container => { + const initialize = mcpSpan(container, 'initialize'); + expect(initialize.name).toBe('initialize'); + expect(initialize.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.function.mcp_server', + }); + }, + }) + .start() + .completed(); + }); + + test('auto-instruments a resource read', async () => { + await createTestRunner() + .unordered() + .expect({ + span: container => { + const resource = mcpSpan(container, 'resources/read'); + expect(resource.name).toBe('resources/read'); + expect(resource.attributes['mcp.resource.uri']?.value).toBe('echo://foobar'); + }, + }) + .start() + .completed(); + }); + + test('auto-instruments a tool call', async () => { + await createTestRunner() + .unordered() + .expect({ + span: container => { + const tool = mcpSpan(container, 'tools/call'); + expect(tool.name).toBe('tools/call echo'); + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/packages/core/src/integrations/mcp-server/index.ts b/packages/core/src/integrations/mcp-server/index.ts index 192372462a9e..0c466b69ad13 100644 --- a/packages/core/src/integrations/mcp-server/index.ts +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -95,6 +95,11 @@ function interceptTransportStart(transport: MCPTransport, beforeStart: () => voi * wraps any already-registered ones. Wrapping at construction time is recommended by * convention (consistent with other SDK integrations), but is not required. * + * Idempotent: calling this more than once on the same instance is a no-op that returns the + * instance untouched. This makes it safe to call manually even when the SDK already wrapped the + * server automatically at construction (via the `mcpServer` integration) — the manual call + * simply short-circuits. + * * @example * ```typescript * import * as Sentry from '@sentry/core'; diff --git a/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts b/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts index f67cf54b09a4..c2e653bbef2b 100644 --- a/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts @@ -62,9 +62,13 @@ describe('wrapMcpServerWithSentry', () => { const mockMcpServer = createMockMcpServer(); const wrappedOnce = wrapMcpServerWithSentry(mockMcpServer); + const connectAfterFirstWrap = wrappedOnce.connect; const wrappedTwice = wrapMcpServerWithSentry(wrappedOnce); expect(wrappedTwice).toBe(wrappedOnce); + // A second call (e.g. a manual `wrapMcpServerWithSentry` after auto-instrumentation + // already wrapped the instance at construction) must be a no-op, not re-patch `connect`. + expect(wrappedTwice.connect).toBe(connectAfterFirstWrap); }); it('should wrap the connect method to intercept transport', () => { diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 62f74e3675ef..e6f9e6c52e3d 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -45,6 +45,7 @@ export { langGraphIntegration } from './integrations/langgraph'; export { createFlueInstrumentation } from './ai/flue'; export type { FlueOptions } from './ai/flue'; export { mastraIntegration } from './integrations/mastra'; +export { mcpServerIntegration } from './integrations/mcp-server'; export { SentryMastraExporter } from './ai/mastra'; export { lruMemoizerIntegration } from './integrations/lru-memoizer'; export { mongoIntegration } from './integrations/mongodb'; diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts index fa6cf1759906..d120f473a1c4 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -16,6 +16,7 @@ import { lruMemoizerIntegration } from './lru-memoizer'; import { langChainIntegration } from './langchain'; import { langGraphIntegration } from './langgraph'; import { mastraIntegration } from './mastra'; +import { mcpServerIntegration } from './mcp-server'; import { vercelAIIntegration } from './vercel-ai'; import { openAIIntegration } from './openai'; import { anthropicAIIntegration } from './anthropic'; @@ -65,6 +66,7 @@ export function getTracingIntegrations(): Integration[] { togetherAIIntegration(), postgresJsIntegration(), firebaseIntegration(), + mcpServerIntegration(), ]; } diff --git a/packages/server-utils/src/integrations/mcp-server.ts b/packages/server-utils/src/integrations/mcp-server.ts new file mode 100644 index 000000000000..2095253a44d8 --- /dev/null +++ b/packages/server-utils/src/integrations/mcp-server.ts @@ -0,0 +1,54 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import { wrapMcpServerWithSentry } from '@sentry/core/server'; +import { CHANNELS } from '../orchestrion/channels'; +import { mcpServerModuleNames } from '../orchestrion/config/mcp-server'; +import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; +import { safeChannelCallback } from '../tracing-channel'; + +const INTEGRATION_NAME = 'McpServer'; + +interface ConstructorChannelContext { + arguments: unknown[]; + self?: unknown; +} + +const _mcpServerIntegration = (() => { + return { + name: INTEGRATION_NAME, + setup(client) { + // `wrapMcpServerWithSentry` only patches methods on the freshly-built instance; it opens no + // spans itself (those come later from the wrapped transport), so a missing async-context + // binding must not defer the subscription. + invokeOrchestrionInstrumentation(client, mcpServerModuleNames, subscribe, [], { + requiresTracingChannelBinding: false, + }); + }, + }; +}) satisfies IntegrationFn; + +function subscribe(): void { + // Wrap each newly-constructed `McpServer` the moment its constructor returns. This is the + // automatic equivalent of a user calling `wrapMcpServerWithSentry(new McpServer(...))`; the + // wrap's own `WeakSet` guard makes a later manual call on the same instance a no-op, so the two + // paths coexist safely. + for (const channel of [CHANNELS.MCP_SERVER_V2_CONSTRUCTOR, CHANNELS.MCP_SERVER_V1_CONSTRUCTOR]) { + diagnosticsChannel.tracingChannel(channel).end.subscribe(message => { + safeChannelCallback(() => { + const { self } = message as ConstructorChannelContext; + if (self) { + wrapMcpServerWithSentry(self); + } + }); + }); + } +} + +/** + * Auto-instruments `@modelcontextprotocol/server` (v2) and `@modelcontextprotocol/sdk` (v1) + * `McpServer` instances, so users no longer have to wrap them with `wrapMcpServerWithSentry` + * by hand. Enabled by default. Requires the runtime hook or a bundler plugin (orchestrion) to + * inject the constructor channel. + */ +export const mcpServerIntegration = defineIntegration(_mcpServerIntegration); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index a88495d6ed8c..5a640ecd1421 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -17,6 +17,7 @@ import { langchainChannels } from './config/langchain'; import { langgraphChannels } from './config/langgraph'; import { lruMemoizerChannels } from './config/lru-memoizer'; import { mastraChannels } from './config/mastra'; +import { mcpServerChannels } from './config/mcp-server'; import { mistralChannels } from './config/mistral'; import { mongodbChannels } from './config/mongodb'; import { mongooseChannels } from './config/mongoose'; @@ -68,6 +69,7 @@ export const CHANNELS = { ...langgraphChannels, ...lruMemoizerChannels, ...mastraChannels, + ...mcpServerChannels, ...mistralChannels, ...mongodbChannels, ...mongooseChannels, diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index a1c51f6008fa..aced987b246a 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -45,6 +45,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ }, { exportName: 'langGraphIntegration', modules: ['@langchain/langgraph'] }, { exportName: 'mastraIntegration', modules: ['@mastra/core'] }, + { exportName: 'mcpServerIntegration', modules: ['@modelcontextprotocol/server', '@modelcontextprotocol/sdk'] }, { exportName: 'awsIntegration', modules: ['@aws-sdk/smithy-client', '@smithy/core', '@smithy/smithy-client'] }, { exportName: 'firebaseIntegration', modules: ['@firebase/firestore', 'firebase-functions'] }, { exportName: 'amqplibIntegration', modules: ['amqplib'] }, diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index dced48dc622d..1ace6ac0a4c3 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -21,6 +21,7 @@ import { langchainConfig } from './langchain'; import { langgraphConfig } from './langgraph'; import { lruMemoizerConfig } from './lru-memoizer'; import { mastraConfig } from './mastra'; +import { mcpServerConfig } from './mcp-server'; import { mistralConfig } from './mistral'; import { mongodbConfig } from './mongodb'; import { mongooseConfig } from './mongoose'; @@ -71,6 +72,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...langgraphConfig, ...lruMemoizerConfig, ...mastraConfig, + ...mcpServerConfig, ...mistralConfig, ...mongodbConfig, ...mongooseConfig, diff --git a/packages/server-utils/src/orchestrion/config/mcp-server.ts b/packages/server-utils/src/orchestrion/config/mcp-server.ts new file mode 100644 index 000000000000..afa43f729878 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/mcp-server.ts @@ -0,0 +1,51 @@ +import type { InstrumentationConfig } from '../apmTypes'; + +import { getModuleNames } from './module-names'; + +// `@modelcontextprotocol/server` v2 ships pre-bundled (tsdown) in content-hashed chunks: +// `class McpServer` lives only in `dist/mcp-.{mjs,cjs}` (2.0.0: `mcp-DXXb3Vv3.mjs`, +// `mcp-D7GmuPnv.cjs`), while the stable `dist/index.mjs` is a pure re-export. An exact +// `filePath` therefore cannot target the constructor, so the pattern matches any `mcp-*` +// chunk in `dist/` and `className: 'McpServer'` stays the real selector — the same shape +// `mastra.ts` uses for its hashed `Mastra` chunk. `McpServer.connect()` delegates to the +// underlying `Server`/`Protocol`, so hooking the constructor is enough to reach everything. +const mcpServerV2Config: InstrumentationConfig[] = [ + { + channelName: 'mcpServerConstructor', + module: { + name: '@modelcontextprotocol/server', + versionRange: '>=2.0.0 <3', + filePath: /^dist\/mcp-[\w-]+\.(?:cjs|mjs)$/, + }, + // No `methodName` → class constructor. The `end` message's `self` is the new instance. + functionQuery: { className: 'McpServer' }, + }, +]; + +// `@modelcontextprotocol/sdk` v1 ships unbundled, so `McpServer` sits at a stable path — the +// subpath export `.../server/mcp.js` resolves to `dist/{cjs,esm}/server/mcp.js`. The regex +// matches both build flavors from the module root without hard-coding the `dist/esm` vs +// `dist/cjs` prefix, and `className: 'McpServer'` stays the real selector. +const mcpServerV1Config: InstrumentationConfig[] = [ + { + channelName: 'mcpServerConstructor', + module: { + name: '@modelcontextprotocol/sdk', + versionRange: '>=1.9.0 <2', + filePath: /(?:^|\/)server\/mcp\.js$/, + }, + functionQuery: { className: 'McpServer' }, + }, +]; + +export const mcpServerConfig = [...mcpServerV2Config, ...mcpServerV1Config] satisfies InstrumentationConfig[]; + +export const mcpServerModuleNames = getModuleNames(mcpServerConfig); + +export const mcpServerChannels = { + // Orchestrion prefixes each `channelName` with `orchestrion:${module.name}:`, so v1 and v2 + // publish to distinct channels even though the suffix is shared — the integration subscribes + // to both. + MCP_SERVER_V2_CONSTRUCTOR: 'orchestrion:@modelcontextprotocol/server:mcpServerConstructor', + MCP_SERVER_V1_CONSTRUCTOR: 'orchestrion:@modelcontextprotocol/sdk:mcpServerConstructor', +} as const; From e29044b3d9e80ad7679d8b70f75c2076d8651191 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 21 Sep 2026 10:39:01 +0200 Subject: [PATCH 2/3] fix(core): Apply later MCP wrap options; export mcpServerIntegration Auto-instrumentation wraps each `McpServer` at construction with no explicit capture options. The double-wrap guard previously ignored a later manual `wrapMcpServerWithSentry(server, { recordInputs, recordOutputs })` entirely, silently dropping the override so tool I/O could still be collected against a server the user meant to opt out. Track the mutable capture options per server (WeakMap) instead of a plain WeakSet: a re-wrap now fills in options the first wrap left unset, so an auto-wrap's defaults yield to a later manual override, while an explicit first wrap still wins (unchanged, per `snapshots explicit overrides from the first wrap`). The transport reads these options per message, so the update applies to subsequent requests. Also export `mcpServerIntegration` from the runtime packages that surface the other tracing integrations (node, bun, deno, elysia, aws/gcp serverless, astro), alongside `mastraIntegration`. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + .../core/src/integrations/mcp-server/index.ts | 40 +++++++++++++++---- .../mcp-server/capturePolicy.test.ts | 27 +++++++++++++ packages/deno/src/index.ts | 1 + packages/elysia/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/index.ts | 1 + 9 files changed, 66 insertions(+), 8 deletions(-) diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 116c06dab64a..c26f5de46e21 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -99,6 +99,7 @@ export { langGraphIntegration, createFlueInstrumentation, mastraIntegration, + mcpServerIntegration, SentryMastraExporter, parameterize, pinoIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 4f3b416066b5..399e21362045 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -66,6 +66,7 @@ export { langChainIntegration, langGraphIntegration, mastraIntegration, + mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, modulesIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index e412d01db9ae..31f7bec23a36 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -88,6 +88,7 @@ export { langChainIntegration, langGraphIntegration, mastraIntegration, + mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, modulesIntegration, diff --git a/packages/core/src/integrations/mcp-server/index.ts b/packages/core/src/integrations/mcp-server/index.ts index 0c466b69ad13..af02f2703462 100644 --- a/packages/core/src/integrations/mcp-server/index.ts +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -5,10 +5,31 @@ import type { MCPServerInstance, McpServerWrapperOptions, MCPTransport } from '. import { validateMcpServerInstance } from './validation'; /** - * Tracks wrapped MCP server instances to prevent double-wrapping + * Maps each wrapped MCP server instance to the mutable capture options that the transport + * instrumentation reads (per message). Prevents double-wrapping while still letting a later + * `wrapMcpServerWithSentry` fill in options left unset by an earlier wrap — e.g. an auto-wrap at + * construction installs no explicit options, so a manual override still applies. * @internal */ -const wrappedMcpServerInstances = new WeakSet(); +const wrappedMcpServerOptions = new WeakMap(); + +/** + * Fill in capture options not explicitly set by an earlier wrap. Only unset fields are written, so + * the first explicit `recordInputs`/`recordOutputs` wins, but an auto-wrap that set neither still + * yields to a later manual override. Mutating the stored object updates the live transport + * instrumentation, which reads it per message. + */ +function applyMissingMcpOptions(target: McpServerWrapperOptions, source: McpServerWrapperOptions | undefined): void { + if (!source) { + return; + } + if (target.recordInputs === undefined && source.recordInputs !== undefined) { + target.recordInputs = source.recordInputs; + } + if (target.recordOutputs === undefined && source.recordOutputs !== undefined) { + target.recordOutputs = source.recordOutputs; + } +} function instrumentTransport(transport: MCPTransport, options: McpServerWrapperOptions): void { wrapTransportOnMessage(transport, options); @@ -95,10 +116,11 @@ function interceptTransportStart(transport: MCPTransport, beforeStart: () => voi * wraps any already-registered ones. Wrapping at construction time is recommended by * convention (consistent with other SDK integrations), but is not required. * - * Idempotent: calling this more than once on the same instance is a no-op that returns the - * instance untouched. This makes it safe to call manually even when the SDK already wrapped the - * server automatically at construction (via the `mcpServer` integration) — the manual call - * simply short-circuits. + * Calling this more than once on the same instance never patches it twice. Options behave like a + * snapshot from the first *explicit* wrap: the first `recordInputs`/`recordOutputs` value set for a + * field wins, but a field left unset can still be filled by a later call. So when the SDK auto-wraps + * the server at construction (via the `mcpServer` integration) with no explicit options, a later + * manual `wrapMcpServerWithSentry(server, { recordInputs, recordOutputs })` still applies. * * @example * ```typescript @@ -128,7 +150,9 @@ function interceptTransportStart(transport: MCPTransport, beforeStart: () => voi * @returns Instrumented server instance (same reference) */ export function wrapMcpServerWithSentry(mcpServerInstance: S, options?: McpServerWrapperOptions): S { - if (wrappedMcpServerInstances.has(mcpServerInstance)) { + const existingOptions = wrappedMcpServerOptions.get(mcpServerInstance); + if (existingOptions) { + applyMissingMcpOptions(existingOptions, options); return mcpServerInstance; } @@ -138,6 +162,7 @@ export function wrapMcpServerWithSentry(mcpServerInstance: S, const serverInstance = mcpServerInstance as MCPServerInstance; const captureOptions: McpServerWrapperOptions = { ...options }; + wrappedMcpServerOptions.set(mcpServerInstance, captureOptions); fill(serverInstance, 'connect', originalConnect => { return async function (this: MCPServerInstance, transport: MCPTransport, ...restArgs: unknown[]) { @@ -172,6 +197,5 @@ export function wrapMcpServerWithSentry(mcpServerInstance: S, wrapExistingHandlers(serverInstance); - wrappedMcpServerInstances.add(mcpServerInstance); return mcpServerInstance; } diff --git a/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts b/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts index fd7ac8d055a2..3371ebed35d7 100644 --- a/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts @@ -389,4 +389,31 @@ describe('MCP Server Capture Policy', () => { ); expectToolResult(span); }); + + it('applies a later explicit override when the first wrap set no options (auto-instrumentation)', async () => { + // The `mcpServer` integration auto-wraps at construction with no options; a manual + // `wrapMcpServerWithSentry(server, { recordInputs: false, recordOutputs: false })` must still opt out, + // even against a client whose data-collection settings would otherwise capture both. + const server = createMockMcpServer(); + wrapMcpServerWithSentry(server); + wrapMcpServerWithSentry(server, { recordInputs: false, recordOutputs: false }); + const transport = await connectServer(server, 'capture-policy-auto-override'); + const recordingScope = createClientScope(true, true); + const span = queueInactiveSpan(); + + receiveToolCall(transport, recordingScope, { + id: 'auto-override-request', + location: 'Riga, Latvia', + }); + await sendToolResult(transport, recordingScope, { + id: 'auto-override-request', + text: 'Private forecast for Riga', + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + buildToolSpanConfig({ id: 'auto-override-request', sessionId: 'capture-policy-auto-override' }), + ); + expectToolResult(span); + }); }); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 187eb0b5cbf7..2c3d62070b83 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -137,6 +137,7 @@ export { langChainIntegration, langGraphIntegration, mastraIntegration, + mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, lruMemoizerIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 1f65611a6b6b..600374726de3 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -68,6 +68,7 @@ export { langGraphIntegration, createFlueInstrumentation, mastraIntegration, + mcpServerIntegration, SentryMastraExporter, modulesIntegration, contextLinesIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index a53893c979e9..39cf101bb108 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -66,6 +66,7 @@ export { langChainIntegration, langGraphIntegration, mastraIntegration, + mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, modulesIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index de280e57f11c..6442baee3ad6 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -23,6 +23,7 @@ export { lruMemoizerIntegration, createFlueInstrumentation, mastraIntegration, + mcpServerIntegration, SentryMastraExporter, mongoIntegration, mongooseIntegration, From 8286a1477636959f487b81bf03118b76c110e617 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 21 Sep 2026 10:58:49 +0200 Subject: [PATCH 3/3] chore(node): Bump size limits for default mcpServer integration Making `mcpServerIntegration` a default (tracing) integration pulls the `wrapMcpServerWithSentry` instrumentation tree (~8.7 KB gzipped) into the `init` path, which was previously tree-shaken out. Bump the two affected `@sentry/node` scenarios to their measured sizes: - `@sentry/node`: 135 -> 139 KB - `@sentry/node - without channel injection`: 114 -> 117 KB `@sentry/node - without tracing` is unchanged (the integration is tracing-only). Co-Authored-By: Claude Opus 4.8 (1M context) --- .size-limit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 06e1f6e30b8a..e03af0e11bfa 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '135 KB', + limit: '139 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -452,7 +452,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '114 KB', + limit: '117 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) {