Skip to content
Merged
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,2 @@
@sentry:registry=http://127.0.0.1:4873
@sentry-internal:registry=http://127.0.0.1:4873
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,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
}
}
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,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}`);
});
Original file line number Diff line number Diff line change
@@ -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<string, NodeStreamableHTTPServerTransport> = {};

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 };
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-v2',
});
Original file line number Diff line number Diff line change
@@ -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();
});
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 @@ -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: [
{
Expand Down Expand Up @@ -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: [
{
Expand Down
Loading
Loading