Skip to content

Commit 0f35883

Browse files
betegonclaude
andauthored
feat(core): support registerTool/registerResource/registerPrompt in MCP integration (#20071)
The `@modelcontextprotocol/sdk` introduced `registerTool`, `registerResource`, and `registerPrompt` as a new API in 1.x, and in 2.x these are the *only* methods available — the old `tool`/`resource`/`prompt` names are gone. Before this change, servers using the new API would silently get no instrumentation: `validateMcpServerInstance` would reject them (it only checked for the old names), so `wrapMcpServerWithSentry` would return the unwrapped instance. The cloudflare-mcp e2e app already used `registerTool` and was affected by this. ## Changes - `MCPServerInstance` type now includes optional `registerTool?`, `registerResource?`, `registerPrompt?` alongside the legacy methods (also made legacy ones optional with `@deprecated` tags since 2.x removed them) - `validateMcpServerInstance` now accepts instances with either `tool+resource+prompt+connect` or `registerTool+registerResource+registerPrompt+connect` - `wrapAllMCPHandlers` conditionally wraps whichever set of methods exists on the instance - `captureHandlerError` maps `registerTool` → `tool_execution`, `registerResource` → `resource_execution`, `registerPrompt` → `prompt_execution` - Unit tests added for validation and wrapping of the new method names - `registerTool` handlers added to the node-express, node-express-v5, and tsx-express e2e apps The existing `wrapMethodHandler` logic (intercepts the last argument as the callback) works identically for both old and new signatures, so no changes were needed there. - [ ] Tests added - [ ] Lints and test suite passes Closes #16666 --------- Co-authored-by: claude-sonnet-4-6 <noreply@anthropic.com>
1 parent 750d242 commit 0f35883

File tree

21 files changed

+643
-25
lines changed

21 files changed

+643
-25
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
3+
Sentry.init({
4+
environment: 'qa', // dynamic sampling bias to keep transactions
5+
dsn: process.env.E2E_TEST_DSN,
6+
debug: !!process.env.DEBUG,
7+
tunnel: `http://localhost:3031/`, // proxy server
8+
tracesSampleRate: 1,
9+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "node-express-mcp-v2-app",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"build": "tsc",
7+
"start": "node --import ./instrument.mjs dist/app.js",
8+
"test": "playwright test",
9+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
10+
"test:build": "pnpm install && pnpm build",
11+
"test:assert": "pnpm test"
12+
},
13+
"dependencies": {
14+
"@cfworker/json-schema": "^4.0.0",
15+
"@modelcontextprotocol/server": "2.0.0-alpha.2",
16+
"@modelcontextprotocol/node": "2.0.0-alpha.2",
17+
"@sentry/node": "latest || *",
18+
"@types/express": "^4.17.21",
19+
"@types/node": "^18.19.1",
20+
"express": "^4.21.2",
21+
"typescript": "~5.0.0",
22+
"zod": "^4.0.0"
23+
},
24+
"devDependencies": {
25+
"@modelcontextprotocol/client": "2.0.0-alpha.2",
26+
"@playwright/test": "~1.56.0",
27+
"@sentry-internal/test-utils": "link:../../../test-utils",
28+
"@sentry/core": "latest || *"
29+
},
30+
"type": "module",
31+
"volta": {
32+
"extends": "../../package.json"
33+
},
34+
"sentryTest": {
35+
"optional": true
36+
}
37+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const config = getPlaywrightConfig({
4+
startCommand: `pnpm start`,
5+
});
6+
7+
export default config;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as Sentry from '@sentry/node';
2+
import express from 'express';
3+
import { mcpRouter } from './mcp.js';
4+
5+
const app = express();
6+
const port = 3030;
7+
8+
app.use(express.json());
9+
app.use(mcpRouter);
10+
11+
app.get('/test-success', function (_req, res) {
12+
res.send({ version: 'v1' });
13+
});
14+
15+
Sentry.setupExpressErrorHandler(app);
16+
17+
app.listen(port, () => {
18+
console.log(`Example app listening on port ${port}`);
19+
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { randomUUID } from 'node:crypto';
2+
import express from 'express';
3+
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
4+
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
5+
import { z } from 'zod';
6+
import { wrapMcpServerWithSentry } from '@sentry/node';
7+
8+
const mcpRouter = express.Router();
9+
10+
const server = wrapMcpServerWithSentry(
11+
new McpServer({
12+
name: 'Echo-V2',
13+
version: '2.0.0',
14+
}),
15+
);
16+
17+
server.registerResource(
18+
'echo',
19+
new ResourceTemplate('echo://{message}', { list: undefined }),
20+
{ title: 'Echo Resource' },
21+
async (uri, { message }) => ({
22+
contents: [
23+
{
24+
uri: uri.href,
25+
text: `Resource echo: ${message}`,
26+
},
27+
],
28+
}),
29+
);
30+
31+
server.registerTool(
32+
'echo',
33+
{ description: 'Echo tool', inputSchema: z.object({ message: z.string() }) },
34+
async ({ message }) => ({
35+
content: [{ type: 'text', text: `Tool echo: ${message}` }],
36+
}),
37+
);
38+
39+
server.registerPrompt(
40+
'echo',
41+
{ description: 'Echo prompt', argsSchema: z.object({ message: z.string() }) },
42+
({ message }) => ({
43+
messages: [
44+
{
45+
role: 'user',
46+
content: {
47+
type: 'text',
48+
text: `Please process this message: ${message}`,
49+
},
50+
},
51+
],
52+
}),
53+
);
54+
55+
server.registerTool('always-error', {}, async () => {
56+
throw new Error('intentional error for span status testing');
57+
});
58+
59+
const transports: Record<string, NodeStreamableHTTPServerTransport> = {};
60+
61+
mcpRouter.post('/mcp', async (req, res) => {
62+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
63+
64+
try {
65+
let transport: NodeStreamableHTTPServerTransport;
66+
67+
if (sessionId && transports[sessionId]) {
68+
transport = transports[sessionId];
69+
} else if (!sessionId && req.body?.method === 'initialize') {
70+
transport = new NodeStreamableHTTPServerTransport({
71+
sessionIdGenerator: () => randomUUID(),
72+
onsessioninitialized: sid => {
73+
transports[sid] = transport;
74+
},
75+
});
76+
77+
transport.onclose = () => {
78+
const sid = transport.sessionId;
79+
if (sid && transports[sid]) {
80+
delete transports[sid];
81+
}
82+
};
83+
84+
await server.connect(transport);
85+
} else {
86+
res.status(400).json({
87+
jsonrpc: '2.0',
88+
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
89+
id: null,
90+
});
91+
return;
92+
}
93+
94+
await transport.handleRequest(req, res, req.body);
95+
} catch (error) {
96+
console.error('Error handling MCP request:', error);
97+
if (!res.headersSent) {
98+
res.status(500).json({
99+
jsonrpc: '2.0',
100+
error: { code: -32603, message: 'Internal server error' },
101+
id: null,
102+
});
103+
}
104+
}
105+
});
106+
107+
mcpRouter.get('/mcp', async (req, res) => {
108+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
109+
if (!sessionId || !transports[sessionId]) {
110+
res.status(400).send('Invalid or missing session ID');
111+
return;
112+
}
113+
await transports[sessionId].handleRequest(req, res);
114+
});
115+
116+
mcpRouter.delete('/mcp', async (req, res) => {
117+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
118+
if (!sessionId || !transports[sessionId]) {
119+
res.status(400).send('Invalid or missing session ID');
120+
return;
121+
}
122+
await transports[sessionId].handleRequest(req, res);
123+
});
124+
125+
export { mcpRouter };
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'node-express-mcp-v2',
6+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
import { Client } from '@modelcontextprotocol/client';
4+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
5+
6+
test('Should record transactions for MCP handlers using @modelcontextprotocol/sdk v2 (register* API)', async ({
7+
baseURL,
8+
}) => {
9+
const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`));
10+
11+
const client = new Client({
12+
name: 'test-client-v2',
13+
version: '1.0.0',
14+
});
15+
16+
const initializeTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => {
17+
return transactionEvent.transaction === 'initialize';
18+
});
19+
20+
await client.connect(transport);
21+
22+
await test.step('initialize handshake', async () => {
23+
const initializeTransaction = await initializeTransactionPromise;
24+
expect(initializeTransaction).toBeDefined();
25+
expect(initializeTransaction.contexts?.trace?.op).toEqual('mcp.server');
26+
expect(initializeTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('initialize');
27+
expect(initializeTransaction.contexts?.trace?.data?.['mcp.client.name']).toEqual('test-client-v2');
28+
expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo-V2');
29+
expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/);
30+
});
31+
32+
await test.step('registerTool handler', async () => {
33+
const toolTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => {
34+
return transactionEvent.transaction === 'tools/call echo';
35+
});
36+
37+
const toolResult = await client.callTool({
38+
name: 'echo',
39+
arguments: {
40+
message: 'foobar',
41+
},
42+
});
43+
44+
expect(toolResult).toMatchObject({
45+
content: [
46+
{
47+
text: 'Tool echo: foobar',
48+
type: 'text',
49+
},
50+
],
51+
});
52+
53+
const toolTransaction = await toolTransactionPromise;
54+
expect(toolTransaction).toBeDefined();
55+
expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server');
56+
expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call');
57+
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo');
58+
// Proves span was completed with results (span correlation worked end-to-end)
59+
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.result.content_count']).toEqual(1);
60+
});
61+
62+
await test.step('registerResource handler', async () => {
63+
const resourceTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => {
64+
return transactionEvent.transaction === 'resources/read echo://foobar';
65+
});
66+
67+
const resourceResult = await client.readResource({
68+
uri: 'echo://foobar',
69+
});
70+
71+
expect(resourceResult).toMatchObject({
72+
contents: [{ text: 'Resource echo: foobar', uri: 'echo://foobar' }],
73+
});
74+
75+
const resourceTransaction = await resourceTransactionPromise;
76+
expect(resourceTransaction).toBeDefined();
77+
expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server');
78+
expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read');
79+
});
80+
81+
await test.step('registerPrompt handler', async () => {
82+
const promptTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => {
83+
return transactionEvent.transaction === 'prompts/get echo';
84+
});
85+
86+
const promptResult = await client.getPrompt({
87+
name: 'echo',
88+
arguments: {
89+
message: 'foobar',
90+
},
91+
});
92+
93+
expect(promptResult).toMatchObject({
94+
messages: [
95+
{
96+
content: {
97+
text: 'Please process this message: foobar',
98+
type: 'text',
99+
},
100+
role: 'user',
101+
},
102+
],
103+
});
104+
105+
const promptTransaction = await promptTransactionPromise;
106+
expect(promptTransaction).toBeDefined();
107+
expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server');
108+
expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get');
109+
});
110+
111+
await test.step('error tool sets span status to internal_error', async () => {
112+
const toolTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => {
113+
return transactionEvent.transaction === 'tools/call always-error';
114+
});
115+
116+
try {
117+
await client.callTool({ name: 'always-error', arguments: {} });
118+
} catch {
119+
// Expected: MCP SDK throws when the tool returns a JSON-RPC error
120+
}
121+
122+
const toolTransaction = await toolTransactionPromise;
123+
expect(toolTransaction).toBeDefined();
124+
expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server');
125+
expect(toolTransaction.contexts?.trace?.status).toEqual('internal_error');
126+
});
127+
128+
await client.close();
129+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"compilerOptions": {
3+
"types": ["node"],
4+
"esModuleInterop": true,
5+
"lib": ["es2020"],
6+
"module": "NodeNext",
7+
"moduleResolution": "NodeNext",
8+
"strict": true,
9+
"outDir": "dist",
10+
"skipLibCheck": true
11+
},
12+
"include": ["src/**/*.ts"]
13+
}

dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ server.tool('echo', { message: z.string() }, async ({ message }, rest) => {
3535
};
3636
});
3737

38+
server.registerTool(
39+
'echo-register',
40+
{ description: 'Echo tool (register API)', inputSchema: { message: z.string() } },
41+
async ({ message }) => ({
42+
content: [{ type: 'text', text: `registerTool echo: ${message}` }],
43+
}),
44+
);
45+
3846
server.prompt('echo', { message: z.string() }, ({ message }, extra) => ({
3947
messages: [
4048
{
@@ -107,6 +115,14 @@ streamableServer.tool('echo', { message: z.string() }, async ({ message }) => {
107115
};
108116
});
109117

118+
streamableServer.registerTool(
119+
'echo-register',
120+
{ description: 'Echo tool (register API)', inputSchema: { message: z.string() } },
121+
async ({ message }) => ({
122+
content: [{ type: 'text', text: `registerTool echo: ${message}` }],
123+
}),
124+
);
125+
110126
streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({
111127
messages: [
112128
{

0 commit comments

Comments
 (0)