diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/.npmrc b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/instrument.mjs new file mode 100644 index 000000000000..f3dd95215d03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/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-v2/package.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json new file mode 100644 index 000000000000..6a5a293b956d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json @@ -0,0 +1,37 @@ +{ + "name": "node-express-mcp-v2-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": { + "@cfworker/json-schema": "^4.0.0", + "@modelcontextprotocol/server": "2.0.0-alpha.2", + "@modelcontextprotocol/node": "2.0.0-alpha.2", + "@sentry/node": "latest || *", + "@types/express": "^4.17.21", + "@types/node": "^18.19.1", + "express": "^4.21.2", + "typescript": "~5.0.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@modelcontextprotocol/client": "2.0.0-alpha.2", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "latest || *" + }, + "type": "module", + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/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-v2/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/app.ts new file mode 100644 index 000000000000..0fa1366dd2d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/app.ts @@ -0,0 +1,19 @@ +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' }); +}); + +Sentry.setupExpressErrorHandler(app); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); 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 new file mode 100644 index 000000000000..6034032b46df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/mcp.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'node:crypto'; +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', + }), +); + +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: z.object({ message: z.string() }) }, + async ({ message }) => ({ + content: [{ type: 'text', text: `Tool echo: ${message}` }], + }), +); + +server.registerPrompt( + 'echo', + { description: 'Echo prompt', argsSchema: z.object({ 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: NodeStreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && req.body?.method === 'initialize') { + transport = new NodeStreamableHTTPServerTransport({ + 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-v2/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/start-event-proxy.mjs new file mode 100644 index 000000000000..7e4303f958d8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-mcp-v2', +}); 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 new file mode 100644 index 000000000000..776725c11cf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { Client } from '@modelcontextprotocol/client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +test('Should record transactions for MCP handlers using @modelcontextprotocol/sdk v2 (register* API)', async ({ + baseURL, +}) => { + const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`)); + + const client = new Client({ + name: 'test-client-v2', + version: '1.0.0', + }); + + const initializeTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => { + return transactionEvent.transaction === 'initialize'; + }); + + await client.connect(transport); + + await test.step('initialize handshake', async () => { + const initializeTransaction = await initializeTransactionPromise; + expect(initializeTransaction).toBeDefined(); + expect(initializeTransaction.contexts?.trace?.op).toEqual('mcp.server'); + expect(initializeTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('initialize'); + expect(initializeTransaction.contexts?.trace?.data?.['mcp.client.name']).toEqual('test-client-v2'); + expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo-V2'); + expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/); + }); + + await test.step('registerTool handler', async () => { + const toolTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => { + return transactionEvent.transaction === 'tools/call echo'; + }); + + const toolResult = await client.callTool({ + name: 'echo', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'Tool echo: foobar', + type: 'text', + }, + ], + }); + + const toolTransaction = await toolTransactionPromise; + expect(toolTransaction).toBeDefined(); + expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server'); + expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call'); + expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo'); + // Proves span was completed with results (span correlation worked end-to-end) + expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.result.content_count']).toEqual(1); + }); + + await test.step('registerResource handler', async () => { + const resourceTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => { + return transactionEvent.transaction === 'resources/read echo://foobar'; + }); + + const resourceResult = await client.readResource({ + uri: 'echo://foobar', + }); + + expect(resourceResult).toMatchObject({ + contents: [{ text: 'Resource echo: foobar', uri: 'echo://foobar' }], + }); + + const resourceTransaction = await resourceTransactionPromise; + expect(resourceTransaction).toBeDefined(); + expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server'); + expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read'); + }); + + await test.step('registerPrompt handler', async () => { + const promptTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => { + return transactionEvent.transaction === 'prompts/get echo'; + }); + + const promptResult = await client.getPrompt({ + name: 'echo', + arguments: { + message: 'foobar', + }, + }); + + expect(promptResult).toMatchObject({ + messages: [ + { + content: { + text: 'Please process this message: foobar', + type: 'text', + }, + role: 'user', + }, + ], + }); + + const promptTransaction = await promptTransactionPromise; + expect(promptTransaction).toBeDefined(); + expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server'); + expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get'); + }); + + await test.step('error tool sets span status to internal_error', async () => { + const toolTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => { + return transactionEvent.transaction === '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 toolTransaction = await toolTransactionPromise; + expect(toolTransaction).toBeDefined(); + expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server'); + expect(toolTransaction.contexts?.trace?.status).toEqual('internal_error'); + }); + + await client.close(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tsconfig.json new file mode 100644 index 000000000000..21ecf1357722 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/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-v5/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts index f17de442d9ab..09472a9288be 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts @@ -35,6 +35,14 @@ server.tool('echo', { message: z.string() }, async ({ message }, rest) => { }; }); +server.registerTool( + 'echo-register', + { description: 'Echo tool (register API)', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `registerTool echo: ${message}` }], + }), +); + server.prompt('echo', { message: z.string() }, ({ message }, extra) => ({ messages: [ { @@ -107,6 +115,14 @@ streamableServer.tool('echo', { message: z.string() }, async ({ message }) => { }; }); +streamableServer.registerTool( + 'echo-register', + { description: 'Echo tool (register API)', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `registerTool echo: ${message}` }], + }), +); + streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({ messages: [ { diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/mcp.test.ts index cdb78220454b..c943ebfd4ab1 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/mcp.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/mcp.test.ts @@ -60,6 +60,38 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction }); + await test.step('registerTool handler', async () => { + const postTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => { + return transactionEvent.transaction === 'POST /messages'; + }); + const toolTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => { + return transactionEvent.transaction === 'tools/call echo-register'; + }); + + const toolResult = await client.callTool({ + name: 'echo-register', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'registerTool echo: foobar', + type: 'text', + }, + ], + }); + + const postTransaction = await postTransactionPromise; + expect(postTransaction).toBeDefined(); + + const toolTransaction = await toolTransactionPromise; + expect(toolTransaction).toBeDefined(); + expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo-register'); + }); + await test.step('resource handler', async () => { const postTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => { return transactionEvent.transaction === 'POST /messages'; diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts index d576cfc6c495..72c4535a3d6f 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts @@ -35,6 +35,14 @@ server.tool('echo', { message: z.string() }, async ({ message }, rest) => { }; }); +server.registerTool( + 'echo-register', + { description: 'Echo tool (register API)', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `registerTool echo: ${message}` }], + }), +); + server.prompt('echo', { message: z.string() }, ({ message }, extra) => ({ messages: [ { @@ -107,6 +115,14 @@ streamableServer.tool('echo', { message: z.string() }, async ({ message }) => { }; }); +streamableServer.registerTool( + 'echo-register', + { description: 'Echo tool (register API)', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `registerTool echo: ${message}` }], + }), +); + streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({ messages: [ { diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts index d746330f7c71..504bfaffcd27 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts @@ -62,6 +62,41 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction }); + await test.step('registerTool handler', async () => { + const postTransactionPromise = waitForTransaction('node-express', transactionEvent => { + return transactionEvent.transaction === 'POST /messages'; + }); + const toolTransactionPromise = waitForTransaction('node-express', transactionEvent => { + return transactionEvent.transaction === 'tools/call echo-register'; + }); + + const toolResult = await client.callTool({ + name: 'echo-register', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'registerTool echo: foobar', + type: 'text', + }, + ], + }); + + const postTransaction = await postTransactionPromise; + expect(postTransaction).toBeDefined(); + expect(postTransaction.contexts?.trace?.op).toEqual('http.server'); + + const toolTransaction = await toolTransactionPromise; + expect(toolTransaction).toBeDefined(); + expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server'); + expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call'); + expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo-register'); + }); + await test.step('resource handler', async () => { const postTransactionPromise = waitForTransaction('node-express', transactionEvent => { return transactionEvent.transaction === 'POST /messages'; diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/src/mcp.ts b/dev-packages/e2e-tests/test-applications/tsx-express/src/mcp.ts index fec65bcf590d..71a3d810dc1c 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/src/mcp.ts +++ b/dev-packages/e2e-tests/test-applications/tsx-express/src/mcp.ts @@ -35,6 +35,14 @@ server.tool('echo', { message: z.string() }, async ({ message }, rest) => { }; }); +server.registerTool( + 'echo-register', + { description: 'Echo tool (register API)', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `registerTool echo: ${message}` }], + }), +); + server.prompt('echo', { message: z.string() }, ({ message }, extra) => ({ messages: [ { @@ -107,6 +115,14 @@ streamableServer.tool('echo', { message: z.string() }, async ({ message }) => { }; }); +streamableServer.registerTool( + 'echo-register', + { description: 'Echo tool (register API)', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `registerTool echo: ${message}` }], + }), +); + streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({ messages: [ { diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/tsx-express/tests/mcp.test.ts index 921bb1120d84..f85eedf74b99 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/tests/mcp.test.ts +++ b/dev-packages/e2e-tests/test-applications/tsx-express/tests/mcp.test.ts @@ -63,6 +63,40 @@ test('Records transactions for mcp handlers', async ({ baseURL }) => { // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction }); + await test.step('registerTool handler', async () => { + const postTransactionPromise = waitForTransaction('tsx-express', transactionEvent => { + return transactionEvent.transaction === 'POST /messages'; + }); + const toolTransactionPromise = waitForTransaction('tsx-express', transactionEvent => { + return transactionEvent.transaction === 'tools/call echo-register'; + }); + + const toolResult = await client.callTool({ + name: 'echo-register', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'registerTool echo: foobar', + type: 'text', + }, + ], + }); + + const postTransaction = await postTransactionPromise; + expect(postTransaction).toBeDefined(); + + const toolTransaction = await toolTransactionPromise; + expect(toolTransaction).toBeDefined(); + expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server'); + expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call'); + expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo-register'); + }); + await test.step('resource handler', async () => { const postTransactionPromise = waitForTransaction('tsx-express', transactionEvent => { return transactionEvent.transaction === 'POST /messages'; diff --git a/packages/core/src/integrations/mcp-server/handlers.ts b/packages/core/src/integrations/mcp-server/handlers.ts index dd8e0296a95e..5ac0d0e0722a 100644 --- a/packages/core/src/integrations/mcp-server/handlers.ts +++ b/packages/core/src/integrations/mcp-server/handlers.ts @@ -96,7 +96,7 @@ function captureHandlerError(error: Error, methodName: keyof MCPServerInstance, try { const extraData: Record = {}; - if (methodName === 'tool') { + if (methodName === 'tool' || methodName === 'registerTool') { extraData.tool_name = handlerName; if ( @@ -114,10 +114,10 @@ function captureHandlerError(error: Error, methodName: keyof MCPServerInstance, } else { captureError(error, 'tool_execution', extraData); } - } else if (methodName === 'resource') { + } else if (methodName === 'resource' || methodName === 'registerResource') { extraData.resource_uri = handlerName; captureError(error, 'resource_execution', extraData); - } else if (methodName === 'prompt') { + } else if (methodName === 'prompt' || methodName === 'registerPrompt') { extraData.prompt_name = handlerName; captureError(error, 'prompt_execution', extraData); } @@ -127,31 +127,39 @@ function captureHandlerError(error: Error, methodName: keyof MCPServerInstance, } /** - * Wraps tool handlers to associate them with request spans + * Wraps tool handlers to associate them with request spans. + * Instruments both `tool` (legacy API) and `registerTool` (new API) if present. * @param serverInstance - MCP server instance */ export function wrapToolHandlers(serverInstance: MCPServerInstance): void { - wrapMethodHandler(serverInstance, 'tool'); + if (typeof serverInstance.tool === 'function') wrapMethodHandler(serverInstance, 'tool'); + if (typeof serverInstance.registerTool === 'function') wrapMethodHandler(serverInstance, 'registerTool'); } /** - * Wraps resource handlers to associate them with request spans + * Wraps resource handlers to associate them with request spans. + * Instruments both `resource` (legacy API) and `registerResource` (new API) if present. * @param serverInstance - MCP server instance */ export function wrapResourceHandlers(serverInstance: MCPServerInstance): void { - wrapMethodHandler(serverInstance, 'resource'); + if (typeof serverInstance.resource === 'function') wrapMethodHandler(serverInstance, 'resource'); + if (typeof serverInstance.registerResource === 'function') wrapMethodHandler(serverInstance, 'registerResource'); } /** - * Wraps prompt handlers to associate them with request spans + * Wraps prompt handlers to associate them with request spans. + * Instruments both `prompt` (legacy API) and `registerPrompt` (new API) if present. * @param serverInstance - MCP server instance */ export function wrapPromptHandlers(serverInstance: MCPServerInstance): void { - wrapMethodHandler(serverInstance, 'prompt'); + if (typeof serverInstance.prompt === 'function') wrapMethodHandler(serverInstance, 'prompt'); + if (typeof serverInstance.registerPrompt === 'function') wrapMethodHandler(serverInstance, 'registerPrompt'); } /** - * Wraps all MCP handler types (tool, resource, prompt) for span correlation + * Wraps all MCP handler types for span correlation. + * Supports both the legacy API (`tool`, `resource`, `prompt`) and the newer API + * (`registerTool`, `registerResource`, `registerPrompt`), instrumenting whichever methods are present. * @param serverInstance - MCP server instance */ export function wrapAllMCPHandlers(serverInstance: MCPServerInstance): void { diff --git a/packages/core/src/integrations/mcp-server/index.ts b/packages/core/src/integrations/mcp-server/index.ts index 5698cd445834..b4ef87f0fa0a 100644 --- a/packages/core/src/integrations/mcp-server/index.ts +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -14,7 +14,8 @@ const wrappedMcpServerInstances = new WeakSet(); /** * Wraps a MCP Server instance from the `@modelcontextprotocol/sdk` package with Sentry instrumentation. * - * Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package. + * Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package (legacy `tool`/`resource`/`prompt` API) + * and versions that expose the newer `registerTool`/`registerResource`/`registerPrompt` API (introduced in 1.x, sole API in 2.x). * Automatically instruments transport methods and handler functions for comprehensive monitoring. * * @example diff --git a/packages/core/src/integrations/mcp-server/types.ts b/packages/core/src/integrations/mcp-server/types.ts index 35dbcffcabb0..e6fd873fa4fa 100644 --- a/packages/core/src/integrations/mcp-server/types.ts +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -87,17 +87,53 @@ export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcRespo /** * MCP server instance interface - * @description MCP server methods for registering handlers + * @description MCP server methods for registering handlers. + * Supports both the legacy API (`tool`, `resource`, `prompt`) used in SDK 1.x + * and the newer API (`registerTool`, `registerResource`, `registerPrompt`) introduced in SDK 1.x + * and made the only option in SDK 2.x. */ export interface MCPServerInstance { - /** Register a resource handler */ - resource: (name: string, ...args: unknown[]) => void; + /** + * Register a resource handler. + * Supported in `@modelcontextprotocol/sdk` v1.x alongside `registerResource`. + * @deprecated Removed in `@modelcontextprotocol/sdk` v2.0.0 — use `registerResource` instead. + */ + resource?: (name: string, ...args: unknown[]) => void; + + /** + * Register a tool handler. + * Supported in `@modelcontextprotocol/sdk` v1.x alongside `registerTool`. + * @deprecated Removed in `@modelcontextprotocol/sdk` v2.0.0 — use `registerTool` instead. + */ + tool?: (name: string, ...args: unknown[]) => void; - /** Register a tool handler */ - tool: (name: string, ...args: unknown[]) => void; + /** + * Register a prompt handler. + * Supported in `@modelcontextprotocol/sdk` v1.x alongside `registerPrompt`. + * @deprecated Removed in `@modelcontextprotocol/sdk` v2.0.0 — use `registerPrompt` instead. + */ + prompt?: (name: string, ...args: unknown[]) => void; - /** Register a prompt handler */ - prompt: (name: string, ...args: unknown[]) => void; + /** + * Register a resource handler. + * Available in `@modelcontextprotocol/sdk` v1.x (alongside the legacy `resource` method) + * and the only supported form in v2.0.0+. + */ + registerResource?: (name: string, ...args: unknown[]) => void; + + /** + * Register a tool handler. + * Available in `@modelcontextprotocol/sdk` v1.x (alongside the legacy `tool` method) + * and the only supported form in v2.0.0+. + */ + registerTool?: (name: string, ...args: unknown[]) => void; + + /** + * Register a prompt handler. + * Available in `@modelcontextprotocol/sdk` v1.x (alongside the legacy `prompt` method) + * and the only supported form in v2.0.0+. + */ + registerPrompt?: (name: string, ...args: unknown[]) => void; /** Connect the server to a transport */ connect(transport: MCPTransport): Promise; diff --git a/packages/core/src/integrations/mcp-server/validation.ts b/packages/core/src/integrations/mcp-server/validation.ts index 9ed21b290728..15fb27f9c803 100644 --- a/packages/core/src/integrations/mcp-server/validation.ts +++ b/packages/core/src/integrations/mcp-server/validation.ts @@ -57,7 +57,10 @@ export function isJsonRpcResponse(message: unknown): message is JsonRpcResponse } /** - * Validates MCP server instance with type checking + * Validates MCP server instance with type checking. + * Accepts both the legacy API (`tool`, `resource`, `prompt`) used in SDK 1.x + * and the newer API (`registerTool`, `registerResource`, `registerPrompt`) introduced + * alongside the legacy API in SDK 1.x and made the only option in SDK 2.x. * @param instance - Object to validate as MCP server instance * @returns True if instance has required MCP server methods */ @@ -65,10 +68,9 @@ export function validateMcpServerInstance(instance: unknown): boolean { if ( typeof instance === 'object' && instance !== null && - 'resource' in instance && - 'tool' in instance && - 'prompt' in instance && - 'connect' in instance + 'connect' in instance && + (('tool' in instance && 'resource' in instance && 'prompt' in instance) || + ('registerTool' in instance && 'registerResource' in instance && 'registerPrompt' in instance)) ) { return true; } 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 c277162017aa..3fc48a2e0b47 100644 --- a/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as currentScopes from '../../../../src/currentScopes'; import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; import * as tracingModule from '../../../../src/tracing'; -import { createMockMcpServer } from './testUtils'; +import { createMockMcpServer, createMockMcpServerWithRegisterApi } from './testUtils'; describe('wrapMcpServerWithSentry', () => { const startSpanSpy = vi.spyOn(tracingModule, 'startSpan'); @@ -45,6 +45,19 @@ describe('wrapMcpServerWithSentry', () => { expect(startInactiveSpanSpy).not.toHaveBeenCalled(); }); + it('should accept a server with only the new register* API (no legacy methods)', () => { + const mockServer = createMockMcpServerWithRegisterApi(); + const result = wrapMcpServerWithSentry(mockServer); + expect(result).toBe(mockServer); + }); + + it('should reject a server with neither legacy nor register* methods', () => { + const invalidServer = { connect: vi.fn() }; + const result = wrapMcpServerWithSentry(invalidServer); + expect(result).toBe(invalidServer); + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + it('should not wrap the same instance twice', () => { const mockMcpServer = createMockMcpServer(); @@ -77,6 +90,19 @@ describe('wrapMcpServerWithSentry', () => { expect(wrappedMcpServer.prompt).not.toBe(originalPrompt); }); + it('should wrap handler methods (registerTool, registerResource, registerPrompt)', () => { + const mockServer = createMockMcpServerWithRegisterApi(); + const originalRegisterTool = mockServer.registerTool; + const originalRegisterResource = mockServer.registerResource; + const originalRegisterPrompt = mockServer.registerPrompt; + + const wrapped = wrapMcpServerWithSentry(mockServer); + + expect(wrapped.registerTool).not.toBe(originalRegisterTool); + expect(wrapped.registerResource).not.toBe(originalRegisterResource); + expect(wrapped.registerPrompt).not.toBe(originalRegisterPrompt); + }); + describe('Handler Wrapping', () => { let mockMcpServer: ReturnType; let wrappedMcpServer: ReturnType; @@ -118,4 +144,38 @@ describe('wrapMcpServerWithSentry', () => { }).not.toThrow(); }); }); + + describe('Handler Wrapping (register* API)', () => { + let mockServer: ReturnType; + let wrappedServer: ReturnType; + + beforeEach(() => { + mockServer = createMockMcpServerWithRegisterApi(); + wrappedServer = wrapMcpServerWithSentry(mockServer); + }); + + it('should register tool handlers via registerTool without throwing errors', () => { + const toolHandler = vi.fn(); + + expect(() => { + wrappedServer.registerTool('test-tool', {}, toolHandler); + }).not.toThrow(); + }); + + it('should register resource handlers via registerResource without throwing errors', () => { + const resourceHandler = vi.fn(); + + expect(() => { + wrappedServer.registerResource('test-resource', 'res://test', {}, resourceHandler); + }).not.toThrow(); + }); + + it('should register prompt handlers via registerPrompt without throwing errors', () => { + const promptHandler = vi.fn(); + + expect(() => { + wrappedServer.registerPrompt('test-prompt', {}, promptHandler); + }).not.toThrow(); + }); + }); }); diff --git a/packages/core/test/lib/integrations/mcp-server/testUtils.ts b/packages/core/test/lib/integrations/mcp-server/testUtils.ts index 782f03a78e35..23b9ee6ff51b 100644 --- a/packages/core/test/lib/integrations/mcp-server/testUtils.ts +++ b/packages/core/test/lib/integrations/mcp-server/testUtils.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest'; /** - * Create a mock MCP server instance for testing + * Create a mock MCP server instance for testing (legacy API: tool/resource/prompt) */ export function createMockMcpServer() { return { @@ -15,6 +15,21 @@ export function createMockMcpServer() { }; } +/** + * Create a mock MCP server instance using the new register* API (SDK >=1.x / 2.x) + */ +export function createMockMcpServerWithRegisterApi() { + return { + registerResource: vi.fn(), + registerTool: vi.fn(), + registerPrompt: vi.fn(), + connect: vi.fn().mockResolvedValue(undefined), + server: { + setRequestHandler: vi.fn(), + }, + }; +} + /** * Create a mock HTTP transport (StreamableHTTPServerTransport) * Uses exact naming pattern from the official SDK