Skip to content

Commit 0b1759c

Browse files
betegoncodex
andauthored
fix(core): avoid retrying failing MCP handlers (#24505)
`wrapMcpServerWithSentry` retries synchronous handler failures after capturing them. This can repeat application side effects and replace the original failure with a successful retry response. Remove that fallback while preserving the existing return-value and async error behavior. The regression is reproduced with both TypeScript SDK majors and in a deployed Cloudflare Worker, including negotiated MCP `2026-07-28`. Fixes #24504 Co-authored-by: GPT-5 <codex@openai.com>
1 parent 33b0769 commit 0b1759c

7 files changed

Lines changed: 189 additions & 55 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
tracesSampleRate: 1,
7+
transport: loggingTransport,
8+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
const assert = require('node:assert/strict');
2+
3+
module.exports = async function run({ Client, InMemoryTransport, McpServer, Sentry }) {
4+
await Sentry.startSpan({ name: 'handler-regression' }, async span => {
5+
const calls = { tool: 0, resource: 0, prompt: 0, existingResource: 0 };
6+
const failOnce = (name, result) => () => {
7+
calls[name] += 1;
8+
if (calls[name] === 1) {
9+
throw new Error(`${name} failed`);
10+
}
11+
return result;
12+
};
13+
const server = new McpServer({ name: 'handler-test-server', version: '1.0.0' });
14+
server.registerResource(
15+
'existingResource',
16+
'test://existing-resource',
17+
{},
18+
failOnce('existingResource', { contents: [{ uri: 'test://existing-resource', text: 'unexpected retry' }] }),
19+
);
20+
Sentry.wrapMcpServerWithSentry(server);
21+
server.registerTool('tool', {}, failOnce('tool', { content: [{ type: 'text', text: 'unexpected retry' }] }));
22+
server.registerResource(
23+
'resource',
24+
'test://resource',
25+
{},
26+
failOnce('resource', { contents: [{ uri: 'test://resource', text: 'unexpected retry' }] }),
27+
);
28+
server.registerPrompt(
29+
'prompt',
30+
{},
31+
failOnce('prompt', { messages: [{ role: 'user', content: { type: 'text', text: 'unexpected retry' } }] }),
32+
);
33+
const client = new Client({ name: 'handler-test-client', version: '1.0.0' });
34+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
35+
36+
try {
37+
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
38+
assert.deepEqual(await client.callTool({ name: 'tool', arguments: {} }), {
39+
content: [{ type: 'text', text: 'tool failed' }],
40+
isError: true,
41+
});
42+
await assert.rejects(client.readResource({ uri: 'test://resource' }), /resource failed$/);
43+
await assert.rejects(client.getPrompt({ name: 'prompt' }), /prompt failed$/);
44+
await assert.rejects(client.readResource({ uri: 'test://existing-resource' }), /existingResource failed$/);
45+
assert.deepEqual(calls, { tool: 1, resource: 1, prompt: 1, existingResource: 1 });
46+
} finally {
47+
await client.close();
48+
await server.close();
49+
}
50+
span.setAttribute('test.mcp.handlers_verified', 4);
51+
});
52+
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
3+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4+
import * as Sentry from '@sentry/node';
5+
import run from './run.cjs';
6+
7+
run({ Client, InMemoryTransport, McpServer, Sentry });
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { Client } from '@modelcontextprotocol/client';
2+
import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server';
3+
import * as Sentry from '@sentry/node';
4+
import run from './run.cjs';
5+
6+
run({ Client, InMemoryTransport, McpServer, Sentry });
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { SerializedStreamedSpanContainer } from '@sentry/core';
2+
import { describe, expect } from 'vitest';
3+
import { createEsmAndCjsTests } from '../../../utils/runner';
4+
5+
describe.each(['v1', 'v2'])('MCP TypeScript SDK %s', sdk => {
6+
createEsmAndCjsTests(
7+
__dirname,
8+
`scenario-sdk-${sdk}.mjs`,
9+
'instrument.mjs',
10+
(createTestRunner, test) => {
11+
test('preserves handler errors without repeating their side effects', async () => {
12+
let root: SerializedStreamedSpanContainer['items'][number] | undefined;
13+
14+
await createTestRunner()
15+
.unordered()
16+
.expect({
17+
event: event => {
18+
expect(event.exception?.values).toHaveLength(1);
19+
expect(event.exception?.values?.[0]?.value).toBe('tool failed');
20+
expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.ai.mcp_server');
21+
},
22+
})
23+
.expect({
24+
span: container => {
25+
const segment = container.items.find(item => item.is_segment && item.name === 'handler-regression');
26+
expect(segment?.name).toBe('handler-regression');
27+
root = segment;
28+
},
29+
})
30+
.start()
31+
.completed();
32+
33+
expect(root?.status).toBe('ok');
34+
expect(root?.attributes['test.mcp.handlers_verified']).toEqual({ type: 'integer', value: 4 });
35+
});
36+
},
37+
{
38+
additionalDependencies: sdk === 'v1' ? { '@modelcontextprotocol/sdk': '1.30.0' } : undefined,
39+
copyPaths: ['run.cjs'],
40+
},
41+
);
42+
});

‎packages/core/src/integrations/mcp-server/handlers.ts‎

Lines changed: 12 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
* and prompt handlers.
66
*/
77

8-
import { DEBUG_BUILD } from '../../debug-build';
9-
import { debug } from '../../utils/debug-logger';
108
import { isObjectLike } from '../../utils/is';
119
import { fill } from '../../utils/object';
1210
import { captureError } from './errorCapture';
@@ -44,46 +42,21 @@ function wrapMethodHandler(serverInstance: MCPServerInstance, methodName: keyof
4442
function createWrappedHandler(originalHandler: MCPHandler, methodName: keyof MCPServerInstance, handlerName: string) {
4543
return function (this: unknown, ...handlerArgs: unknown[]): unknown {
4644
try {
47-
return createErrorCapturingHandler.call(this, originalHandler, methodName, handlerName, handlerArgs);
48-
} catch (error) {
49-
DEBUG_BUILD && debug.warn('MCP handler wrapping failed:', error);
50-
return originalHandler.apply(this, handlerArgs);
51-
}
52-
};
53-
}
45+
const result = originalHandler.apply(this, handlerArgs);
5446

55-
/**
56-
* Creates an error-capturing wrapper for handler execution
57-
* @internal
58-
* @param originalHandler - Original handler function
59-
* @param methodName - MCP method name
60-
* @param handlerName - Handler identifier
61-
* @param handlerArgs - Handler arguments
62-
* @param extraHandlerData - Additional handler context
63-
* @returns Handler execution result
64-
*/
65-
function createErrorCapturingHandler(
66-
this: MCPServerInstance,
67-
originalHandler: MCPHandler,
68-
methodName: keyof MCPServerInstance,
69-
handlerName: string,
70-
handlerArgs: unknown[],
71-
): unknown {
72-
try {
73-
const result = originalHandler.apply(this, handlerArgs);
47+
if (isObjectLike(result) && typeof (result as { then?: unknown }).then === 'function') {
48+
return Promise.resolve(result).catch(error => {
49+
captureHandlerError(error, methodName, handlerName);
50+
throw error;
51+
});
52+
}
7453

75-
if (isObjectLike(result) && typeof (result as { then?: unknown }).then === 'function') {
76-
return Promise.resolve(result).catch(error => {
77-
captureHandlerError(error, methodName, handlerName);
78-
throw error;
79-
});
54+
return result;
55+
} catch (error) {
56+
captureHandlerError(error as Error, methodName, handlerName);
57+
throw error;
8058
}
81-
82-
return result;
83-
} catch (error) {
84-
captureHandlerError(error as Error, methodName, handlerName);
85-
throw error;
86-
}
59+
};
8760
}
8861

8962
/**

‎packages/core/test/lib/integrations/mcp-server/mcpServerErrorCapture.test.ts‎

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as currentScopes from '../../../../src/currentScopes';
33
import * as exports from '../../../../src/exports';
44
import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server';
55
import { captureError } from '../../../../src/integrations/mcp-server/errorCapture';
6+
import type { MCPHandler } from '../../../../src/integrations/mcp-server/types';
67
import { createMockClient, createMockMcpServer } from './testUtils';
78

89
describe('MCP Server Error Capture', () => {
@@ -145,41 +146,86 @@ describe('MCP Server Error Capture', () => {
145146
});
146147

147148
describe('Error Capture Integration', () => {
148-
let mockMcpServer: ReturnType<typeof createMockMcpServer>;
149149
let wrappedMcpServer: ReturnType<typeof createMockMcpServer>;
150+
let registeredHandler: MCPHandler;
150151

151152
beforeEach(() => {
152-
mockMcpServer = createMockMcpServer();
153+
captureExceptionSpy.mockReturnValue('event-id');
154+
const mockMcpServer = createMockMcpServer();
155+
mockMcpServer.tool.mockImplementation((_name: string, handler: MCPHandler) => {
156+
registeredHandler = handler;
157+
});
153158
wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer);
154159
});
155160

156-
it('should capture tool execution errors and continue normal flow', async () => {
161+
it('should not retry a handler after a synchronous error', () => {
157162
const toolError = new Error('Tool execution failed');
158-
const mockToolHandler = vi.fn().mockRejectedValue(toolError);
163+
const mockToolHandler = vi
164+
.fn()
165+
.mockImplementationOnce(() => {
166+
throw toolError;
167+
})
168+
.mockReturnValue({ content: [] });
169+
wrappedMcpServer.tool('failing-tool', mockToolHandler);
170+
171+
expect(() => registeredHandler()).toThrow(toolError);
172+
173+
expect(mockToolHandler).toHaveBeenCalledTimes(1);
174+
expect(captureExceptionSpy).toHaveBeenCalledExactlyOnceWith(toolError, {
175+
mechanism: {
176+
type: 'auto.ai.mcp_server',
177+
handled: false,
178+
data: { error_type: 'tool_execution', tool_name: 'failing-tool' },
179+
},
180+
});
181+
});
159182

183+
it('should capture and rethrow asynchronous errors without retrying', async () => {
184+
const toolError = new Error('Tool execution failed');
185+
const mockToolHandler = vi.fn().mockRejectedValue(toolError);
160186
wrappedMcpServer.tool('failing-tool', mockToolHandler);
161187

162-
await expect(mockToolHandler({ input: 'test' }, { requestId: 'req-123', sessionId: 'sess-456' })).rejects.toThrow(
163-
'Tool execution failed',
164-
);
188+
await expect(registeredHandler()).rejects.toBe(toolError);
165189

166-
// The capture should be set up correctly
167-
expect(captureExceptionSpy).toHaveBeenCalledTimes(0); // No capture yet since we didn't call the wrapped handler
190+
expect(mockToolHandler).toHaveBeenCalledTimes(1);
191+
expect(captureExceptionSpy).toHaveBeenCalledExactlyOnceWith(toolError, {
192+
mechanism: {
193+
type: 'auto.ai.mcp_server',
194+
handled: false,
195+
data: { error_type: 'tool_execution', tool_name: 'failing-tool' },
196+
},
197+
});
168198
});
169199

170-
it('should handle Sentry capture errors gracefully', async () => {
200+
it('should not retry a failing handler when Sentry capture also throws', () => {
171201
captureExceptionSpy.mockImplementation(() => {
172202
throw new Error('Sentry error');
173203
});
174-
175-
// Test that the capture function itself doesn't throw
176204
const toolError = new Error('Tool execution failed');
177-
const mockToolHandler = vi.fn().mockRejectedValue(toolError);
178-
205+
const mockToolHandler = vi.fn(() => {
206+
throw toolError;
207+
});
179208
wrappedMcpServer.tool('failing-tool', mockToolHandler);
180209

181-
// The error capture should be resilient to Sentry errors
182-
expect(captureExceptionSpy).toHaveBeenCalledTimes(0);
210+
expect(() => registeredHandler()).toThrow(toolError);
211+
212+
expect(mockToolHandler).toHaveBeenCalledTimes(1);
213+
expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
214+
});
215+
216+
it('should preserve the handler receiver, arguments, and return value', () => {
217+
const result = { content: [] };
218+
const mockToolHandler = vi.fn().mockReturnValue(result);
219+
const receiver = {};
220+
const args = { input: 'test' };
221+
const extra = { requestId: 'req-123', sessionId: 'sess-456' };
222+
wrappedMcpServer.tool('successful-tool', mockToolHandler);
223+
224+
expect(registeredHandler.call(receiver, args, extra)).toBe(result);
225+
226+
expect(mockToolHandler).toHaveBeenCalledExactlyOnceWith(args, extra);
227+
expect(mockToolHandler.mock.contexts).toEqual([receiver]);
228+
expect(captureExceptionSpy).not.toHaveBeenCalled();
183229
});
184230
});
185231
});

0 commit comments

Comments
 (0)