Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading