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({