diff --git a/.size-limit.js b/.size-limit.js index e682d8e1c8b7..3991fe66e743 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '139 KB', + limit: '142 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -452,7 +452,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '118 KB', + limit: '121 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 0211dc3ed71e..36411039a59e 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -101,7 +101,7 @@ const NO_AUTO_INSTRUMENTATION = [ 'suites/tracing/lru-memoizer/test.ts', 'suites/tracing/mastra/test.ts', 'suites/tracing/mcp-handler-exact-once/test.ts', - 'suites/tracing/mcp-server-streamed/test.ts', + 'suites/tracing/mcp-server/**', 'suites/tracing/mistral/test.ts', 'suites/tracing/mongodb-v4/test.ts', 'suites/tracing/mongodb-v5/test.ts', diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/instrument.mjs new file mode 100644 index 000000000000..f3dd95215d03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/package.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/package.json new file mode 100644 index 000000000000..d82b94ec77f4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/package.json @@ -0,0 +1,34 @@ +{ + "name": "node-express-mcp-v1-app", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node --import ./instrument.mjs dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "@types/express": "^4.17.21", + "@types/node": "^18.19.1", + "express": "^4.21.2", + "typescript": "~5.0.0", + "zod": "~3.25.0" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz" + }, + "type": "module", + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/src/app.ts new file mode 100644 index 000000000000..3ed8df4a040a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/src/app.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import { mcpRouter } from './mcp.js'; + +const app = express(); +const port = 3030; + +app.use(express.json()); +app.use(mcpRouter); + +app.get('/test-success', function (_req, res) { + res.send({ version: 'v1' }); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/src/mcp.ts new file mode 100644 index 000000000000..c1a9dfea3622 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/src/mcp.ts @@ -0,0 +1,120 @@ +import { randomUUID } from 'node:crypto'; +import express from 'express'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { z } from 'zod'; + +const mcpRouter = express.Router(); + +// Intentionally NOT wrapped with `wrapMcpServerWithSentry`: the `mcpServer` integration +// auto-instruments the `McpServer` constructor, so spans must be produced anyway. +const server = new McpServer({ + name: 'Echo-V1', + version: '1.0.0', +}); + +server.registerResource( + 'echo', + new ResourceTemplate('echo://{message}', { list: undefined }), + { title: 'Echo Resource' }, + async (uri, { message }) => ({ + contents: [ + { + uri: uri.href, + text: `Resource echo: ${message}`, + }, + ], + }), +); + +server.registerTool( + 'echo', + { description: 'Echo tool', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `Tool echo: ${message}` }], + }), +); + +server.registerPrompt('echo', { description: 'Echo prompt', argsSchema: { message: z.string() } }, ({ message }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}`, + }, + }, + ], +})); + +server.registerTool('always-error', {}, async () => { + throw new Error('intentional error for span status testing'); +}); + +const transports: Record = {}; + +mcpRouter.post('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && req.body?.method === 'initialize') { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: sid => { + transports[sid] = transport; + }, + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + delete transports[sid]; + } + }; + + await server.connect(transport); + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session ID provided' }, + id: null, + }); + return; + } + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: null, + }); + } + } +}); + +mcpRouter.get('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + await transports[sessionId].handleRequest(req, res); +}); + +mcpRouter.delete('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + await transports[sessionId].handleRequest(req, res); +}); + +export { mcpRouter }; diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/start-event-proxy.mjs new file mode 100644 index 000000000000..69bb0c645c0a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-mcp-v1', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/tests/mcp.test.ts new file mode 100644 index 000000000000..0e34722b1243 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/tests/mcp.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +// The server in this app is never wrapped with `wrapMcpServerWithSentry` — spans are produced +// solely by the auto-instrumenting `mcpServer` integration against the legacy v1 SDK. +test('auto-instruments a legacy MCP SDK v1 server (no manual wrap)', async ({ baseURL }) => { + const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`)); + + const client = new Client({ + name: 'test-client-v1', + version: '1.0.0', + }); + + const initializeSegmentPromise = waitForStreamedSpan( + 'node-express-mcp-v1', + segment => segment.is_segment && segment.name === 'initialize', + ); + + await client.connect(transport); + + await test.step('initialize handshake', async () => { + const initializeSegment = await initializeSegmentPromise; + expect(initializeSegment).toBeDefined(); + expect(getSpanOp(initializeSegment)).toEqual('mcp.server'); + expect(initializeSegment.attributes?.['mcp.method.name']?.value).toEqual('initialize'); + expect(initializeSegment.attributes?.['mcp.client.name']?.value).toEqual('test-client-v1'); + expect(initializeSegment.attributes?.['mcp.server.name']?.value).toEqual('Echo-V1'); + }); + + await test.step('tool call', async () => { + const toolSegmentPromise = waitForStreamedSpan( + 'node-express-mcp-v1', + segment => segment.is_segment && segment.name === 'tools/call echo', + ); + + const toolResult = await client.callTool({ + name: 'echo', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'Tool echo: foobar', + type: 'text', + }, + ], + }); + + const toolSegment = await toolSegmentPromise; + expect(toolSegment).toBeDefined(); + expect(getSpanOp(toolSegment)).toEqual('mcp.server'); + expect(toolSegment.attributes?.['mcp.method.name']?.value).toEqual('tools/call'); + expect(toolSegment.attributes?.['mcp.tool.name']?.value).toEqual('echo'); + }); + + await test.step('resource read', async () => { + const resourceSegmentPromise = waitForStreamedSpan( + 'node-express-mcp-v1', + segment => segment.is_segment && segment.name === 'resources/read', + ); + + const resourceResult = await client.readResource({ + uri: 'echo://foobar', + }); + + expect(resourceResult).toMatchObject({ + contents: [{ text: 'Resource echo: foobar', uri: 'echo://foobar' }], + }); + + const resourceSegment = await resourceSegmentPromise; + expect(resourceSegment).toBeDefined(); + expect(getSpanOp(resourceSegment)).toEqual('mcp.server'); + expect(resourceSegment.attributes?.['mcp.method.name']?.value).toEqual('resources/read'); + }); + + await test.step('error tool sets span status to error', async () => { + const toolSegmentPromise = waitForStreamedSpan( + 'node-express-mcp-v1', + segment => segment.is_segment && segment.name === 'tools/call always-error', + ); + + try { + await client.callTool({ name: 'always-error', arguments: {} }); + } catch { + // Expected: MCP SDK throws when the tool returns a JSON-RPC error + } + + const toolSegment = await toolSegmentPromise; + expect(toolSegment).toBeDefined(); + expect(getSpanOp(toolSegment)).toEqual('mcp.server'); + expect(toolSegment?.status).toEqual('error'); + }); + + await client.close(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/tsconfig.json new file mode 100644 index 000000000000..21ecf1357722 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2020"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/mcp.ts index 6034032b46df..86076f743d62 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/mcp.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/mcp.ts @@ -3,16 +3,15 @@ import express from 'express'; import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { z } from 'zod'; -import { wrapMcpServerWithSentry } from '@sentry/node'; const mcpRouter = express.Router(); -const server = wrapMcpServerWithSentry( - new McpServer({ - name: 'Echo-V2', - version: '2.0.0', - }), -); +// Intentionally NOT wrapped with `wrapMcpServerWithSentry`: the `mcpServer` integration +// auto-instruments the `McpServer` constructor, so spans must be produced anyway. +const server = new McpServer({ + name: 'Echo-V2', + version: '2.0.0', +}); server.registerResource( 'echo', diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts index 747ebda30062..c7e7e2dae2e2 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts @@ -3,7 +3,9 @@ import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; import { Client } from '@modelcontextprotocol/client'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -test('records spans for stable MCP SDK v2 handlers using the register API', async ({ baseURL }) => { +// The server in this app is never wrapped with `wrapMcpServerWithSentry` — spans are produced +// solely by the auto-instrumenting `mcpServer` integration. +test('auto-instruments a stable MCP SDK v2 server (no manual wrap)', async ({ baseURL }) => { const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`)); const client = new Client({ 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/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 3121d53e68e3..a791eac011a3 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -100,6 +100,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 09dc84610073..1687e2c16a10 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -67,6 +67,7 @@ export { langChainIntegration, langGraphIntegration, mastraIntegration, + mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, modulesIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 51660e47c37c..e4b5cd64f60b 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -89,6 +89,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 192372462a9e..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,6 +116,12 @@ 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. * + * 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 * import * as Sentry from '@sentry/core'; @@ -123,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; } @@ -133,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[]) { @@ -167,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/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/deno/src/index.ts b/packages/deno/src/index.ts index 88c795f5031b..847e979bf706 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -136,6 +136,7 @@ export { langChainIntegration, langGraphIntegration, mastraIntegration, + mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, lruMemoizerIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 56e64cffb3ba..b9d21796fcbb 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -69,6 +69,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 9b7bc0ff8abc..ec0838487d36 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -67,6 +67,7 @@ export { langChainIntegration, langGraphIntegration, mastraIntegration, + mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, modulesIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index ee9638ed98aa..ae598683d38b 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, diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 10c81b070d8a..38d7af2d1dd2 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -46,6 +46,7 @@ export { createFlueInstrumentation } from './ai/flue'; export { flueIntegration } from './integrations/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 1c22770d2d1e..3546d98d0310 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'; @@ -67,6 +68,7 @@ export function getTracingIntegrations(): Integration[] { typesafeIntegration(), 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 3da497ca62c0..13eef100a767 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'; @@ -70,6 +71,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 872a49ae520e..2f24d7865c18 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -48,6 +48,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'langGraphIntegration', modules: ['@langchain/langgraph'] }, { exportName: 'mastraIntegration', modules: ['@mastra/core'] }, { exportName: 'flueIntegration', modules: ['@flue/runtime'] }, + { 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 0c3531f65c60..2d44898747ed 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -22,6 +22,7 @@ import { langgraphConfig } from './langgraph'; import { lruMemoizerConfig } from './lru-memoizer'; import { flueConfig } from './flue'; import { mastraConfig } from './mastra'; +import { mcpServerConfig } from './mcp-server'; import { mistralConfig } from './mistral'; import { mongodbConfig } from './mongodb'; import { mongooseConfig } from './mongoose'; @@ -75,6 +76,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...lruMemoizerConfig, ...flueConfig, ...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;