Skip to content

Commit ba7df76

Browse files
Aboudjemclaude
andcommitted
fix(transport): validate JSON-RPC request ID is a safe integer
When a JSON-RPC request arrives with a numeric ID exceeding Number.MAX_SAFE_INTEGER, Zod schema validation correctly rejects it (z.number().int() enforces safe integer range in Zod v4). However, the rejected message falls through to the "Unknown message type" error handler, which silently drops it without sending any response back to the client. This causes the server to appear permanently hung. This fix detects request-like messages that fail schema validation in the onmessage handler and responds with a JSON-RPC -32600 (Invalid Request) error instead of silently ignoring them. Closes #1765 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e86b183 commit ba7df76

2 files changed

Lines changed: 122 additions & 0 deletions

File tree

packages/core/src/shared/protocol.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,28 @@ export abstract class Protocol<ContextT extends BaseContext> {
480480
} else if (isJSONRPCNotification(message)) {
481481
this._onnotification(message);
482482
} else {
483+
// Check if this is a request-like message with an invalid ID (e.g.,
484+
// a numeric ID exceeding Number.MAX_SAFE_INTEGER). Such IDs fail
485+
// schema validation because JavaScript cannot represent them exactly,
486+
// which prevents reliable request/response correlation. Respond with
487+
// a JSON-RPC Invalid Request error instead of silently dropping it.
488+
const msg = message as Record<string, unknown>;
489+
if (msg && typeof msg === 'object' && msg.jsonrpc === '2.0' && 'id' in msg && 'method' in msg) {
490+
const errorResponse: JSONRPCErrorResponse = {
491+
jsonrpc: '2.0',
492+
id: msg.id as RequestId,
493+
error: {
494+
code: ProtocolErrorCode.InvalidRequest,
495+
message: 'Invalid request: ID must be a string or a safe integer'
496+
}
497+
};
498+
499+
this._transport
500+
?.send(errorResponse)
501+
.catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`)));
502+
return;
503+
}
504+
483505
this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`));
484506
}
485507
};

packages/core/test/shared/protocol.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5509,6 +5509,106 @@ describe('Error handling for missing resolvers', () => {
55095509
expect(callOrder).toEqual([1, 2, 3]);
55105510
});
55115511
});
5512+
5513+
describe('unsafe integer request ID validation', () => {
5514+
let protocol: Protocol<BaseContext>;
5515+
let transport: MockTransport;
5516+
5517+
beforeEach(() => {
5518+
transport = new MockTransport();
5519+
protocol = new (class extends Protocol<BaseContext> {
5520+
protected assertCapabilityForMethod(): void {}
5521+
protected assertNotificationCapability(): void {}
5522+
protected assertRequestHandlerCapability(): void {}
5523+
protected assertTaskCapability(): void {}
5524+
protected buildContext(ctx: BaseContext): BaseContext {
5525+
return ctx;
5526+
}
5527+
protected assertTaskHandlerCapability(): void {}
5528+
})();
5529+
});
5530+
5531+
it('should respond with InvalidRequest error when request ID exceeds MAX_SAFE_INTEGER', async () => {
5532+
await protocol.connect(transport);
5533+
const sendSpy = vi.spyOn(transport, 'send');
5534+
5535+
// Send a request with an ID exceeding Number.MAX_SAFE_INTEGER
5536+
// Note: 9007199254740992 === Number.MAX_SAFE_INTEGER + 1, but due to
5537+
// floating-point precision loss, JavaScript cannot represent it exactly.
5538+
const unsafeId = Number.MAX_SAFE_INTEGER + 1;
5539+
transport.onmessage?.({
5540+
jsonrpc: '2.0',
5541+
id: unsafeId,
5542+
method: 'tools/list',
5543+
params: {}
5544+
});
5545+
5546+
// Wait for async processing
5547+
await new Promise(resolve => setTimeout(resolve, 50));
5548+
5549+
// Should have sent an error response
5550+
expect(sendSpy).toHaveBeenCalledTimes(1);
5551+
const sentMessage = sendSpy.mock.calls[0][0] as JSONRPCErrorResponse;
5552+
expect(sentMessage.jsonrpc).toBe('2.0');
5553+
expect(sentMessage.id).toBe(unsafeId);
5554+
expect(sentMessage.error.code).toBe(ProtocolErrorCode.InvalidRequest);
5555+
expect(sentMessage.error.message).toBe('Invalid request: ID must be a string or a safe integer');
5556+
});
5557+
5558+
it('should process requests normally when ID is at MAX_SAFE_INTEGER', async () => {
5559+
await protocol.connect(transport);
5560+
const sendSpy = vi.spyOn(transport, 'send');
5561+
5562+
protocol.setRequestHandler('tools/list', async () => {
5563+
return { tools: [] };
5564+
});
5565+
5566+
// Send a request with ID exactly at Number.MAX_SAFE_INTEGER
5567+
transport.onmessage?.({
5568+
jsonrpc: '2.0',
5569+
id: Number.MAX_SAFE_INTEGER,
5570+
method: 'tools/list',
5571+
params: {}
5572+
});
5573+
5574+
// Wait for async processing
5575+
await new Promise(resolve => setTimeout(resolve, 50));
5576+
5577+
// Should have sent a successful response, not an error
5578+
expect(sendSpy).toHaveBeenCalledTimes(1);
5579+
const sentMessage = sendSpy.mock.calls[0][0] as JSONRPCResultResponse;
5580+
expect(sentMessage.jsonrpc).toBe('2.0');
5581+
expect(sentMessage.id).toBe(Number.MAX_SAFE_INTEGER);
5582+
expect(sentMessage.result).toBeDefined();
5583+
});
5584+
5585+
it('should process requests normally when ID is a string', async () => {
5586+
await protocol.connect(transport);
5587+
const sendSpy = vi.spyOn(transport, 'send');
5588+
5589+
protocol.setRequestHandler('tools/list', async () => {
5590+
return { tools: [] };
5591+
});
5592+
5593+
// String IDs should always be accepted regardless of content
5594+
transport.onmessage?.({
5595+
jsonrpc: '2.0',
5596+
id: '9007199254740992',
5597+
method: 'tools/list',
5598+
params: {}
5599+
});
5600+
5601+
// Wait for async processing
5602+
await new Promise(resolve => setTimeout(resolve, 50));
5603+
5604+
// Should have sent a successful response
5605+
expect(sendSpy).toHaveBeenCalledTimes(1);
5606+
const sentMessage = sendSpy.mock.calls[0][0] as JSONRPCResultResponse;
5607+
expect(sentMessage.jsonrpc).toBe('2.0');
5608+
expect(sentMessage.id).toBe('9007199254740992');
5609+
expect(sentMessage.result).toBeDefined();
5610+
});
5611+
});
55125612
});
55135613

55145614
describe('Protocol without task configuration', () => {

0 commit comments

Comments
 (0)