From a470a44d665bbfd9c01057ac02eef1a4256f5923 Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 1 Apr 2026 12:36:58 +0200 Subject: [PATCH 01/11] feat(core): support registerTool/registerResource/registerPrompt in MCP integration The @modelcontextprotocol/sdk introduced register* methods alongside the legacy tool/resource/prompt API in 1.x, and made them the only option in 2.x. - MCPServerInstance now accepts both old and new method names - validateMcpServerInstance accepts servers with either API set - wrapAllMCPHandlers instruments whichever methods are present - captureHandlerError maps register* names to the same error categories Co-Authored-By: claude-sonnet-4-6 --- .../src/integrations/mcp-server/handlers.ts | 28 ++++++++++++------- .../core/src/integrations/mcp-server/index.ts | 3 +- .../core/src/integrations/mcp-server/types.ts | 26 ++++++++++++----- .../src/integrations/mcp-server/validation.ts | 12 ++++---- 4 files changed, 46 insertions(+), 23 deletions(-) diff --git a/packages/core/src/integrations/mcp-server/handlers.ts b/packages/core/src/integrations/mcp-server/handlers.ts index dd8e0296a95e..5ac0d0e0722a 100644 --- a/packages/core/src/integrations/mcp-server/handlers.ts +++ b/packages/core/src/integrations/mcp-server/handlers.ts @@ -96,7 +96,7 @@ function captureHandlerError(error: Error, methodName: keyof MCPServerInstance, try { const extraData: Record = {}; - if (methodName === 'tool') { + if (methodName === 'tool' || methodName === 'registerTool') { extraData.tool_name = handlerName; if ( @@ -114,10 +114,10 @@ function captureHandlerError(error: Error, methodName: keyof MCPServerInstance, } else { captureError(error, 'tool_execution', extraData); } - } else if (methodName === 'resource') { + } else if (methodName === 'resource' || methodName === 'registerResource') { extraData.resource_uri = handlerName; captureError(error, 'resource_execution', extraData); - } else if (methodName === 'prompt') { + } else if (methodName === 'prompt' || methodName === 'registerPrompt') { extraData.prompt_name = handlerName; captureError(error, 'prompt_execution', extraData); } @@ -127,31 +127,39 @@ function captureHandlerError(error: Error, methodName: keyof MCPServerInstance, } /** - * Wraps tool handlers to associate them with request spans + * Wraps tool handlers to associate them with request spans. + * Instruments both `tool` (legacy API) and `registerTool` (new API) if present. * @param serverInstance - MCP server instance */ export function wrapToolHandlers(serverInstance: MCPServerInstance): void { - wrapMethodHandler(serverInstance, 'tool'); + if (typeof serverInstance.tool === 'function') wrapMethodHandler(serverInstance, 'tool'); + if (typeof serverInstance.registerTool === 'function') wrapMethodHandler(serverInstance, 'registerTool'); } /** - * Wraps resource handlers to associate them with request spans + * Wraps resource handlers to associate them with request spans. + * Instruments both `resource` (legacy API) and `registerResource` (new API) if present. * @param serverInstance - MCP server instance */ export function wrapResourceHandlers(serverInstance: MCPServerInstance): void { - wrapMethodHandler(serverInstance, 'resource'); + if (typeof serverInstance.resource === 'function') wrapMethodHandler(serverInstance, 'resource'); + if (typeof serverInstance.registerResource === 'function') wrapMethodHandler(serverInstance, 'registerResource'); } /** - * Wraps prompt handlers to associate them with request spans + * Wraps prompt handlers to associate them with request spans. + * Instruments both `prompt` (legacy API) and `registerPrompt` (new API) if present. * @param serverInstance - MCP server instance */ export function wrapPromptHandlers(serverInstance: MCPServerInstance): void { - wrapMethodHandler(serverInstance, 'prompt'); + if (typeof serverInstance.prompt === 'function') wrapMethodHandler(serverInstance, 'prompt'); + if (typeof serverInstance.registerPrompt === 'function') wrapMethodHandler(serverInstance, 'registerPrompt'); } /** - * Wraps all MCP handler types (tool, resource, prompt) for span correlation + * Wraps all MCP handler types for span correlation. + * Supports both the legacy API (`tool`, `resource`, `prompt`) and the newer API + * (`registerTool`, `registerResource`, `registerPrompt`), instrumenting whichever methods are present. * @param serverInstance - MCP server instance */ export function wrapAllMCPHandlers(serverInstance: MCPServerInstance): void { diff --git a/packages/core/src/integrations/mcp-server/index.ts b/packages/core/src/integrations/mcp-server/index.ts index 5698cd445834..b4ef87f0fa0a 100644 --- a/packages/core/src/integrations/mcp-server/index.ts +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -14,7 +14,8 @@ const wrappedMcpServerInstances = new WeakSet(); /** * Wraps a MCP Server instance from the `@modelcontextprotocol/sdk` package with Sentry instrumentation. * - * Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package. + * Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package (legacy `tool`/`resource`/`prompt` API) + * and versions that expose the newer `registerTool`/`registerResource`/`registerPrompt` API (introduced in 1.x, sole API in 2.x). * Automatically instruments transport methods and handler functions for comprehensive monitoring. * * @example diff --git a/packages/core/src/integrations/mcp-server/types.ts b/packages/core/src/integrations/mcp-server/types.ts index 35dbcffcabb0..3b08afe97678 100644 --- a/packages/core/src/integrations/mcp-server/types.ts +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -87,17 +87,29 @@ export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcRespo /** * MCP server instance interface - * @description MCP server methods for registering handlers + * @description MCP server methods for registering handlers. + * Supports both the legacy API (`tool`, `resource`, `prompt`) used in SDK 1.x + * and the newer API (`registerTool`, `registerResource`, `registerPrompt`) introduced in SDK 1.x + * and made the only option in SDK 2.x. */ export interface MCPServerInstance { - /** Register a resource handler */ - resource: (name: string, ...args: unknown[]) => void; + /** Register a resource handler (legacy API) */ + resource?: (name: string, ...args: unknown[]) => void; - /** Register a tool handler */ - tool: (name: string, ...args: unknown[]) => void; + /** Register a tool handler (legacy API) */ + tool?: (name: string, ...args: unknown[]) => void; - /** Register a prompt handler */ - prompt: (name: string, ...args: unknown[]) => void; + /** Register a prompt handler (legacy API) */ + prompt?: (name: string, ...args: unknown[]) => void; + + /** Register a resource handler (new API, SDK >=1.x / 2.x) */ + registerResource?: (name: string, ...args: unknown[]) => void; + + /** Register a tool handler (new API, SDK >=1.x / 2.x) */ + registerTool?: (name: string, ...args: unknown[]) => void; + + /** Register a prompt handler (new API, SDK >=1.x / 2.x) */ + registerPrompt?: (name: string, ...args: unknown[]) => void; /** Connect the server to a transport */ connect(transport: MCPTransport): Promise; diff --git a/packages/core/src/integrations/mcp-server/validation.ts b/packages/core/src/integrations/mcp-server/validation.ts index 9ed21b290728..15fb27f9c803 100644 --- a/packages/core/src/integrations/mcp-server/validation.ts +++ b/packages/core/src/integrations/mcp-server/validation.ts @@ -57,7 +57,10 @@ export function isJsonRpcResponse(message: unknown): message is JsonRpcResponse } /** - * Validates MCP server instance with type checking + * Validates MCP server instance with type checking. + * Accepts both the legacy API (`tool`, `resource`, `prompt`) used in SDK 1.x + * and the newer API (`registerTool`, `registerResource`, `registerPrompt`) introduced + * alongside the legacy API in SDK 1.x and made the only option in SDK 2.x. * @param instance - Object to validate as MCP server instance * @returns True if instance has required MCP server methods */ @@ -65,10 +68,9 @@ export function validateMcpServerInstance(instance: unknown): boolean { if ( typeof instance === 'object' && instance !== null && - 'resource' in instance && - 'tool' in instance && - 'prompt' in instance && - 'connect' in instance + 'connect' in instance && + (('tool' in instance && 'resource' in instance && 'prompt' in instance) || + ('registerTool' in instance && 'registerResource' in instance && 'registerPrompt' in instance)) ) { return true; } From be8737385df4e8ceb0307c9296bbd9bf42ddea4b Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 1 Apr 2026 12:37:06 +0200 Subject: [PATCH 02/11] test(core): add tests and e2e coverage for register* MCP API - Add createMockMcpServerWithRegisterApi() to test utilities - Test validation accepts register*-only servers and rejects invalid ones - Test that registerTool/registerResource/registerPrompt get wrapped - Add registerTool handler to node-express, node-express-v5, tsx-express e2e apps Co-Authored-By: claude-sonnet-4-6 --- .../node-express-v5/src/mcp.ts | 16 +++++ .../test-applications/node-express/src/mcp.ts | 16 +++++ .../test-applications/tsx-express/src/mcp.ts | 16 +++++ .../core/src/integrations/mcp-server/types.ts | 36 +++++++++-- .../mcp-server/mcpServerWrapper.test.ts | 62 ++++++++++++++++++- .../lib/integrations/mcp-server/testUtils.ts | 17 ++++- 6 files changed, 155 insertions(+), 8 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts index a819858c8f37..ceccc13d088a 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts @@ -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: [ { @@ -103,6 +111,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: [ { diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts index 638462423d11..7f59668de738 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts @@ -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: [ { @@ -103,6 +111,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: [ { diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/src/mcp.ts b/dev-packages/e2e-tests/test-applications/tsx-express/src/mcp.ts index b3b401ac294c..bc9ec26a3477 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/src/mcp.ts +++ b/dev-packages/e2e-tests/test-applications/tsx-express/src/mcp.ts @@ -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: [ { @@ -103,6 +111,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: [ { diff --git a/packages/core/src/integrations/mcp-server/types.ts b/packages/core/src/integrations/mcp-server/types.ts index 3b08afe97678..e6fd873fa4fa 100644 --- a/packages/core/src/integrations/mcp-server/types.ts +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -93,22 +93,46 @@ export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcRespo * and made the only option in SDK 2.x. */ export interface MCPServerInstance { - /** Register a resource handler (legacy API) */ + /** + * Register a resource handler. + * Supported in `@modelcontextprotocol/sdk` v1.x alongside `registerResource`. + * @deprecated Removed in `@modelcontextprotocol/sdk` v2.0.0 — use `registerResource` instead. + */ resource?: (name: string, ...args: unknown[]) => void; - /** Register a tool handler (legacy API) */ + /** + * Register a tool handler. + * Supported in `@modelcontextprotocol/sdk` v1.x alongside `registerTool`. + * @deprecated Removed in `@modelcontextprotocol/sdk` v2.0.0 — use `registerTool` instead. + */ tool?: (name: string, ...args: unknown[]) => void; - /** Register a prompt handler (legacy API) */ + /** + * Register a prompt handler. + * Supported in `@modelcontextprotocol/sdk` v1.x alongside `registerPrompt`. + * @deprecated Removed in `@modelcontextprotocol/sdk` v2.0.0 — use `registerPrompt` instead. + */ prompt?: (name: string, ...args: unknown[]) => void; - /** Register a resource handler (new API, SDK >=1.x / 2.x) */ + /** + * Register a resource handler. + * Available in `@modelcontextprotocol/sdk` v1.x (alongside the legacy `resource` method) + * and the only supported form in v2.0.0+. + */ registerResource?: (name: string, ...args: unknown[]) => void; - /** Register a tool handler (new API, SDK >=1.x / 2.x) */ + /** + * Register a tool handler. + * Available in `@modelcontextprotocol/sdk` v1.x (alongside the legacy `tool` method) + * and the only supported form in v2.0.0+. + */ registerTool?: (name: string, ...args: unknown[]) => void; - /** Register a prompt handler (new API, SDK >=1.x / 2.x) */ + /** + * Register a prompt handler. + * Available in `@modelcontextprotocol/sdk` v1.x (alongside the legacy `prompt` method) + * and the only supported form in v2.0.0+. + */ registerPrompt?: (name: string, ...args: unknown[]) => void; /** Connect the server to a transport */ diff --git a/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts b/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts index c277162017aa..3fc48a2e0b47 100644 --- a/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as currentScopes from '../../../../src/currentScopes'; import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; import * as tracingModule from '../../../../src/tracing'; -import { createMockMcpServer } from './testUtils'; +import { createMockMcpServer, createMockMcpServerWithRegisterApi } from './testUtils'; describe('wrapMcpServerWithSentry', () => { const startSpanSpy = vi.spyOn(tracingModule, 'startSpan'); @@ -45,6 +45,19 @@ describe('wrapMcpServerWithSentry', () => { expect(startInactiveSpanSpy).not.toHaveBeenCalled(); }); + it('should accept a server with only the new register* API (no legacy methods)', () => { + const mockServer = createMockMcpServerWithRegisterApi(); + const result = wrapMcpServerWithSentry(mockServer); + expect(result).toBe(mockServer); + }); + + it('should reject a server with neither legacy nor register* methods', () => { + const invalidServer = { connect: vi.fn() }; + const result = wrapMcpServerWithSentry(invalidServer); + expect(result).toBe(invalidServer); + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + it('should not wrap the same instance twice', () => { const mockMcpServer = createMockMcpServer(); @@ -77,6 +90,19 @@ describe('wrapMcpServerWithSentry', () => { expect(wrappedMcpServer.prompt).not.toBe(originalPrompt); }); + it('should wrap handler methods (registerTool, registerResource, registerPrompt)', () => { + const mockServer = createMockMcpServerWithRegisterApi(); + const originalRegisterTool = mockServer.registerTool; + const originalRegisterResource = mockServer.registerResource; + const originalRegisterPrompt = mockServer.registerPrompt; + + const wrapped = wrapMcpServerWithSentry(mockServer); + + expect(wrapped.registerTool).not.toBe(originalRegisterTool); + expect(wrapped.registerResource).not.toBe(originalRegisterResource); + expect(wrapped.registerPrompt).not.toBe(originalRegisterPrompt); + }); + describe('Handler Wrapping', () => { let mockMcpServer: ReturnType; let wrappedMcpServer: ReturnType; @@ -118,4 +144,38 @@ describe('wrapMcpServerWithSentry', () => { }).not.toThrow(); }); }); + + describe('Handler Wrapping (register* API)', () => { + let mockServer: ReturnType; + let wrappedServer: ReturnType; + + beforeEach(() => { + mockServer = createMockMcpServerWithRegisterApi(); + wrappedServer = wrapMcpServerWithSentry(mockServer); + }); + + it('should register tool handlers via registerTool without throwing errors', () => { + const toolHandler = vi.fn(); + + expect(() => { + wrappedServer.registerTool('test-tool', {}, toolHandler); + }).not.toThrow(); + }); + + it('should register resource handlers via registerResource without throwing errors', () => { + const resourceHandler = vi.fn(); + + expect(() => { + wrappedServer.registerResource('test-resource', 'res://test', {}, resourceHandler); + }).not.toThrow(); + }); + + it('should register prompt handlers via registerPrompt without throwing errors', () => { + const promptHandler = vi.fn(); + + expect(() => { + wrappedServer.registerPrompt('test-prompt', {}, promptHandler); + }).not.toThrow(); + }); + }); }); diff --git a/packages/core/test/lib/integrations/mcp-server/testUtils.ts b/packages/core/test/lib/integrations/mcp-server/testUtils.ts index 782f03a78e35..23b9ee6ff51b 100644 --- a/packages/core/test/lib/integrations/mcp-server/testUtils.ts +++ b/packages/core/test/lib/integrations/mcp-server/testUtils.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest'; /** - * Create a mock MCP server instance for testing + * Create a mock MCP server instance for testing (legacy API: tool/resource/prompt) */ export function createMockMcpServer() { return { @@ -15,6 +15,21 @@ export function createMockMcpServer() { }; } +/** + * Create a mock MCP server instance using the new register* API (SDK >=1.x / 2.x) + */ +export function createMockMcpServerWithRegisterApi() { + return { + registerResource: vi.fn(), + registerTool: vi.fn(), + registerPrompt: vi.fn(), + connect: vi.fn().mockResolvedValue(undefined), + server: { + setRequestHandler: vi.fn(), + }, + }; +} + /** * Create a mock HTTP transport (StreamableHTTPServerTransport) * Uses exact naming pattern from the official SDK From 89846395f9e364f91ed29aa979a830e59a8f0bcb Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 1 Apr 2026 14:48:26 +0200 Subject: [PATCH 03/11] test(e2e): add e2e test coverage for registerTool instrumentation Call echo-register tool in SSE transport tests across node-express, node-express-v5, and tsx-express, verifying that tools/call transactions are recorded for handlers registered via registerTool. Co-Authored-By: claude-sonnet-4-6 --- .../node-express-v5/tests/mcp.test.ts | 32 +++++++++++++++++ .../node-express/tests/mcp.test.ts | 35 +++++++++++++++++++ .../tsx-express/tests/mcp.test.ts | 34 ++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/mcp.test.ts index 73dfc1d69432..9d290075892f 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/mcp.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/mcp.test.ts @@ -60,6 +60,38 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction }); + await test.step('registerTool handler', async () => { + const postTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => { + return transactionEvent.transaction === 'POST /messages'; + }); + const toolTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => { + return transactionEvent.transaction === 'tools/call echo-register'; + }); + + const toolResult = await client.callTool({ + name: 'echo-register', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'registerTool echo: foobar', + type: 'text', + }, + ], + }); + + const postTransaction = await postTransactionPromise; + expect(postTransaction).toBeDefined(); + + const toolTransaction = await toolTransactionPromise; + expect(toolTransaction).toBeDefined(); + expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo-register'); + }); + await test.step('resource handler', async () => { const postTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => { return transactionEvent.transaction === 'POST /messages'; diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts index 143867c773e6..12eacc1259af 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts @@ -62,6 +62,41 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction }); + await test.step('registerTool handler', async () => { + const postTransactionPromise = waitForTransaction('node-express', transactionEvent => { + return transactionEvent.transaction === 'POST /messages'; + }); + const toolTransactionPromise = waitForTransaction('node-express', transactionEvent => { + return transactionEvent.transaction === 'tools/call echo-register'; + }); + + const toolResult = await client.callTool({ + name: 'echo-register', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'registerTool echo: foobar', + type: 'text', + }, + ], + }); + + const postTransaction = await postTransactionPromise; + expect(postTransaction).toBeDefined(); + expect(postTransaction.contexts?.trace?.op).toEqual('http.server'); + + 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-register'); + }); + await test.step('resource handler', async () => { const postTransactionPromise = waitForTransaction('node-express', transactionEvent => { return transactionEvent.transaction === 'POST /messages'; diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/tsx-express/tests/mcp.test.ts index a89cfcaa11c6..71694a2b72b0 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/tests/mcp.test.ts +++ b/dev-packages/e2e-tests/test-applications/tsx-express/tests/mcp.test.ts @@ -63,6 +63,40 @@ test('Records transactions for mcp handlers', async ({ baseURL }) => { // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction }); + await test.step('registerTool handler', async () => { + const postTransactionPromise = waitForTransaction('tsx-express', transactionEvent => { + return transactionEvent.transaction === 'POST /messages'; + }); + const toolTransactionPromise = waitForTransaction('tsx-express', transactionEvent => { + return transactionEvent.transaction === 'tools/call echo-register'; + }); + + const toolResult = await client.callTool({ + name: 'echo-register', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'registerTool echo: foobar', + type: 'text', + }, + ], + }); + + const postTransaction = await postTransactionPromise; + expect(postTransaction).toBeDefined(); + + 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-register'); + }); + await test.step('resource handler', async () => { const postTransactionPromise = waitForTransaction('tsx-express', transactionEvent => { return transactionEvent.transaction === 'POST /messages'; From 68356d4d0a255ffb9794d8b42434567331c262be Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 1 Apr 2026 20:42:38 +0200 Subject: [PATCH 04/11] fix(core): set span.status to error when MCP tool returns JSON-RPC error response When a tool handler threw an error, completeSpanWithResults() was ending the span without setting its status to error. This caused all MCP tool spans to appear with span.status=ok in Sentry, breaking the failure_rate() metric in the MCP insights dashboard. The fix passes hasError=true to completeSpanWithResults() when the outgoing JSON-RPC response contains an error object, setting the span status to internal_error directly on the stored span (bypassing getActiveSpan() which doesn't return the right span at send() time). Co-Authored-By: claude-sonnet-4-6 --- .../integrations/mcp-server/correlation.ts | 6 +- .../src/integrations/mcp-server/transport.ts | 2 +- .../transportInstrumentation.test.ts | 62 +++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index b47f0f9b4a69..a831a822039c 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -81,19 +81,23 @@ export function storeSpanForRequest(transport: MCPTransport, requestId: RequestI * @param requestId - Request identifier * @param result - Execution result for attribute extraction * @param options - Resolved MCP options + * @param hasError - Whether the JSON-RPC response contained an error */ export function completeSpanWithResults( transport: MCPTransport, requestId: RequestId, result: unknown, options: ResolvedMcpOptions, + hasError = false, ): void { const spanMap = getOrCreateSpanMap(transport); const spanData = spanMap.get(requestId); if (spanData) { const { span, method } = spanData; - if (method === 'initialize') { + if (hasError) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + } else if (method === 'initialize') { const sessionData = extractSessionDataFromInitializeResponse(result); const serverAttributes = buildServerAttributesFromInfo(sessionData.serverInfo); diff --git a/packages/core/src/integrations/mcp-server/transport.ts b/packages/core/src/integrations/mcp-server/transport.ts index 5e4fd6e75c23..4b04a78f43b0 100644 --- a/packages/core/src/integrations/mcp-server/transport.ts +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -121,7 +121,7 @@ export function wrapTransportSend(transport: MCPTransport, options: ResolvedMcpO } } - completeSpanWithResults(this, message.id, message.result, options); + completeSpanWithResults(this, message.id, message.result, options, !!message.error); } } diff --git a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts index c720512cb97b..71590fd17712 100644 --- a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -182,6 +182,68 @@ describe('MCP Server Transport Instrumentation', () => { // Trigger onclose - should not throw expect(() => mockTransport.onclose?.()).not.toThrow(); }); + + it('should set span status to error when JSON-RPC error response is sent', async () => { + const mockSpan = { + setAttributes: vi.fn(), + setStatus: vi.fn(), + end: vi.fn(), + isRecording: vi.fn().mockReturnValue(true), + }; + startInactiveSpanSpy.mockReturnValue(mockSpan as any); + + await wrappedMcpServer.connect(mockTransport); + + // Simulate an incoming tools/call request + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-err-1', + params: { name: 'always-error' }, + }; + mockTransport.onmessage?.(jsonRpcRequest, {}); + + // Simulate the MCP SDK sending back a JSON-RPC error response + const jsonRpcErrorResponse = { + jsonrpc: '2.0', + id: 'req-err-1', + error: { code: -32603, message: 'Internal error: tool threw an exception' }, + }; + await mockTransport.send?.(jsonRpcErrorResponse as any); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('should not set error span status for successful JSON-RPC responses', async () => { + const mockSpan = { + setAttributes: vi.fn(), + setStatus: vi.fn(), + end: vi.fn(), + isRecording: vi.fn().mockReturnValue(true), + }; + startInactiveSpanSpy.mockReturnValue(mockSpan as any); + + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-ok-1', + params: { name: 'echo' }, + }; + mockTransport.onmessage?.(jsonRpcRequest, {}); + + const jsonRpcSuccessResponse = { + jsonrpc: '2.0', + id: 'req-ok-1', + result: { content: [{ type: 'text', text: 'hello' }] }, + }; + await mockTransport.send?.(jsonRpcSuccessResponse as any); + + expect(mockSpan.setStatus).not.toHaveBeenCalled(); + expect(mockSpan.end).toHaveBeenCalled(); + }); }); describe('Stdio Transport Tests', () => { From 1b8ecb8ae27e890f71d8b2978da04a8abbb05ddb Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 6 Apr 2026 08:45:02 +0200 Subject: [PATCH 05/11] test(e2e): add MCP SDK v2 e2e test app using register* API only Adds a new optional e2e test application that pins @modelcontextprotocol/sdk v2 (split into @modelcontextprotocol/server + @modelcontextprotocol/node) and exercises only the register* API (registerTool, registerResource, registerPrompt), which is the only API supported in v2. The app is marked as optional in CI since v2 is still in alpha. Co-Authored-By: Claude Sonnet 4.6 --- .../node-express-mcp-v2/package.json | 36 +++++ .../node-express-mcp-v2/playwright.config.mjs | 7 + .../node-express-mcp-v2/src/app.ts | 28 ++++ .../node-express-mcp-v2/src/mcp.ts | 125 +++++++++++++++++ .../node-express-mcp-v2/tests/mcp.test.ts | 126 ++++++++++++++++++ .../node-express-mcp-v2/tsconfig.json | 13 ++ 6 files changed, 335 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-express-mcp-v2/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/mcp.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json new file mode 100644 index 000000000000..229df460167d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json @@ -0,0 +1,36 @@ +{ + "name": "node-express-mcp-v2-app", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node 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/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 + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/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-v2/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/app.ts new file mode 100644 index 000000000000..8c1305ec46f4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/app.ts @@ -0,0 +1,28 @@ +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, +}); + +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}`); +}); 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 new file mode 100644 index 000000000000..6034032b46df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/mcp.ts @@ -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 = {}; + +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 }; 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 new file mode 100644 index 000000000000..2ae2e0785668 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts @@ -0,0 +1,126 @@ +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'); + }); + + 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'); + }); + + 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(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tsconfig.json new file mode 100644 index 000000000000..21ecf1357722 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/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"] +} From b2e922734b75eb0ad3485f795298071e91ff0210 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 6 Apr 2026 08:49:51 +0200 Subject: [PATCH 06/11] test(e2e): strengthen MCP v2 e2e assertions for transport and span correlation Add mcp.transport assertion (NodeStreamableHTTPServerTransport) and mcp.tool.result.content_count check to prove span correlation completes with results end-to-end, matching the coverage level of the v1 tests. Co-Authored-By: Claude Sonnet 4.6 --- .../test-applications/node-express-mcp-v2/tests/mcp.test.ts | 3 +++ 1 file changed, 3 insertions(+) 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 2ae2e0785668..bed69e26f162 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 @@ -26,6 +26,7 @@ test('Should record transactions for MCP handlers using @modelcontextprotocol/sd 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(/NodeStreamableHTTPServerTransport/); }); await test.step('registerTool handler', async () => { @@ -54,6 +55,8 @@ test('Should record transactions for MCP handlers using @modelcontextprotocol/sd 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 () => { From effc0c9794495840c4558db56a8bab8ee6600631 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 6 Apr 2026 09:03:19 +0200 Subject: [PATCH 07/11] test(e2e): add missing start-event-proxy.mjs to node-express-mcp-v2 getPlaywrightConfig starts 'node start-event-proxy.mjs' on port 3031 unconditionally. Without it the event proxy never starts and all waitForTransaction calls hang. Co-Authored-By: Claude Sonnet 4.6 --- .../node-express-mcp-v2/start-event-proxy.mjs | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-express-mcp-v2/start-event-proxy.mjs diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/start-event-proxy.mjs new file mode 100644 index 000000000000..3276781a442a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express', +}); From 08f25bc8202c473143fb09a1a0a56f1f0ccd01c9 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 6 Apr 2026 09:10:27 +0200 Subject: [PATCH 08/11] fix(e2e): correct proxyServerName in node-express-mcp-v2 event proxy Was copied from node-express but not updated. waitForTransaction keyed on 'node-express-mcp-v2' would never match events from a proxy advertising 'node-express', causing all tests to hang. Co-Authored-By: Claude Sonnet 4.6 --- .../test-applications/node-express-mcp-v2/start-event-proxy.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/start-event-proxy.mjs index 3276781a442a..7e4303f958d8 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'node-express', + proxyServerName: 'node-express-mcp-v2', }); From e3eed422503f143e44cd048a6a4dc3dfa64b5673 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 6 Apr 2026 09:30:45 +0200 Subject: [PATCH 09/11] fix(e2e): add missing @cfworker/json-schema dep to node-express-mcp-v2 @modelcontextprotocol/server declares it as an optional peer dependency, so pnpm doesn't install it automatically. Without it the app fails at startup with ERR_MODULE_NOT_FOUND. Co-Authored-By: Claude Sonnet 4.6 --- .../e2e-tests/test-applications/node-express-mcp-v2/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json index 229df460167d..22e232b1ae0f 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json @@ -11,6 +11,7 @@ "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 || *", From 8b22796270db38336dba940a13506f23b6e25258 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 6 Apr 2026 10:21:26 +0200 Subject: [PATCH 10/11] fix(e2e): use --import for Sentry ESM init in node-express-mcp-v2 The app runs as ESM ("type": "module") because the MCP SDK v2 packages are ESM-only. Sentry must be loaded via --import before the app module to instrument Express correctly; importing it inside app.ts is too late. Extracts Sentry.init() into instrument.mjs and starts the app with node --import ./instrument.mjs dist/app.js, matching the pattern used by tsx-express. Co-Authored-By: Claude Sonnet 4.6 --- .../test-applications/node-express-mcp-v2/instrument.mjs | 9 +++++++++ .../test-applications/node-express-mcp-v2/package.json | 2 +- .../test-applications/node-express-mcp-v2/src/app.ts | 9 --------- 3 files changed, 10 insertions(+), 10 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-express-mcp-v2/instrument.mjs diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/instrument.mjs new file mode 100644 index 000000000000..f3dd95215d03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/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-v2/package.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json index 22e232b1ae0f..6a5a293b956d 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "build": "tsc", - "start": "node dist/app.js", + "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", diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/app.ts index 8c1305ec46f4..0fa1366dd2d6 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/app.ts @@ -1,13 +1,4 @@ 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, -}); - import express from 'express'; import { mcpRouter } from './mcp.js'; From 968c077f46d89fac54074352275ffd855554f796 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 6 Apr 2026 15:43:19 +0200 Subject: [PATCH 11/11] fix(e2e): add missing .npmrc and fix transport assertion in node-express-mcp-v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without .npmrc, pnpm resolves @sentry/* from the public registry instead of Verdaccio, so the published validateMcpServerInstance (v1-only) rejects the v2 McpServer and no instrumentation is applied — causing the 30s timeout. Also fix the mcp.transport assertion: NodeStreamableHTTPServerTransport proxies onmessage to its inner WebStandardStreamableHTTPServerTransport, so constructor.name in the wrapper is the inner class. Use the broader /StreamableHTTPServerTransport/ regex (matching the v1 test pattern). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../e2e-tests/test-applications/node-express-mcp-v2/.npmrc | 2 ++ .../test-applications/node-express-mcp-v2/tests/mcp.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-express-mcp-v2/.npmrc diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/.npmrc b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 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 bed69e26f162..776725c11cf2 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 @@ -26,7 +26,7 @@ test('Should record transactions for MCP handlers using @modelcontextprotocol/sd 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(/NodeStreamableHTTPServerTransport/); + expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/); }); await test.step('registerTool handler', async () => {