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
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '139 KB',
limit: '142 KB',
disablePlugins: ['@size-limit/esbuild'],
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const NO_AUTO_INSTRUMENTATION = [
'suites/tracing/lru-memoizer/test.ts',
'suites/tracing/mastra/test.ts',
'suites/tracing/mcp-handler-exact-once/test.ts',
'suites/tracing/mcp-server-streamed/test.ts',
'suites/tracing/mcp-server/**',
'suites/tracing/mistral/test.ts',
'suites/tracing/mongodb-v4/test.ts',
'suites/tracing/mongodb-v5/test.ts',
Expand Down
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',
});
Comment on lines -10 to +14

Copy link
Copy Markdown
Member

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 decide

Copy link
Copy Markdown
Member

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

Comment on lines +9 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The E2E test's custom instrumentation script (instrument.mjs) calls Sentry.init() but fails to register the orchestrion runtime hook, preventing auto-instrumentation and causing the test to fail.
Severity: MEDIUM

Suggested Fix

Modify the test application's startup command to correctly register the orchestrion runtime hook. Instead of using a custom instrument.mjs file, the package.json should be updated to use --import @sentry/node/import. This ensures the hook is active before the application code runs, allowing mcpServerIntegration to function as expected.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/mcp.ts#L9-L14

Potential issue: The E2E test application `node-express-mcp-v2` is configured to run
with a custom entry point (`--import ./instrument.mjs`) that calls `Sentry.init()`.
However, calling `Sentry.init()` alone does not register the orchestrion runtime hook,
which is required for the `mcpServerIntegration` to perform auto-instrumentation. The
hook is typically registered by using `--import @sentry/node/import`. Without the hook,
the `@modelcontextprotocol/server` module will not be instrumented, no spans will be
produced, and the test will fail because it explicitly waits for and asserts the
presence of these spans.

Did we get this right? 👍 / 👎 to inform future reviews.


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