-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
test(e2e): Add MCP auto-instrumentation e2e apps #24531
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a5d8fb7
c029964
b0615a5
3030fde
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { getPlaywrightConfig } from '@sentry-internal/test-utils'; | ||
|
|
||
| const config = getPlaywrightConfig({ | ||
| startCommand: `pnpm start`, | ||
| }); | ||
|
|
||
| export default config; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}`); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, StreamableHTTPServerTransport> = {}; | ||
|
|
||
| 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 }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { startEventProxyServer } from '@sentry-internal/test-utils'; | ||
|
|
||
| startEventProxyServer({ | ||
| port: 3031, | ||
| proxyServerName: 'node-express-mcp-v1', | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "types": ["node"], | ||
| "esModuleInterop": true, | ||
| "lib": ["es2020"], | ||
| "module": "NodeNext", | ||
| "moduleResolution": "NodeNext", | ||
| "strict": true, | ||
| "outDir": "dist", | ||
| "skipLibCheck": true | ||
| }, | ||
| "include": ["src/**/*.ts"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', | ||
| }); | ||
|
Comment on lines
+9
to
+14
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The E2E test's custom instrumentation script ( Suggested FixModify the test application's startup command to correctly register the orchestrion runtime hook. Instead of using a custom Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
|
|
||
| server.registerResource( | ||
| 'echo', | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this pr still just
test, even though there are actual changes? I'll let you decideThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is just changed based on the previous PR in the stack