Skip to content

Commit 17f6f8c

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 4a7cdf4 commit 17f6f8c

File tree

2 files changed

+122
-0
lines changed

2 files changed

+122
-0
lines changed

packages/core/src/shared/protocol.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,28 @@ export abstract class Protocol<ContextT extends BaseContext> {
713713
} else if (isJSONRPCNotification(message)) {
714714
this._onnotification(message);
715715
} else {
716+
// Check if this is a request-like message with an invalid ID (e.g.,
717+
// a numeric ID exceeding Number.MAX_SAFE_INTEGER). Such IDs fail
718+
// schema validation because JavaScript cannot represent them exactly,
719+
// which prevents reliable request/response correlation. Respond with
720+
// a JSON-RPC Invalid Request error instead of silently dropping it.
721+
const msg = message as Record<string, unknown>;
722+
if (msg && typeof msg === 'object' && msg.jsonrpc === '2.0' && 'id' in msg && 'method' in msg) {
723+
const errorResponse: JSONRPCErrorResponse = {
724+
jsonrpc: '2.0',
725+
id: msg.id as RequestId,
726+
error: {
727+
code: ProtocolErrorCode.InvalidRequest,
728+
message: 'Invalid request: ID must be a string or a safe integer'
729+
}
730+
};
731+
732+
this._transport
733+
?.send(errorResponse)
734+
.catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`)));
735+
return;
736+
}
737+
716738
this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`));
717739
}
718740
};

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5722,4 +5722,104 @@ describe('Error handling for missing resolvers', () => {
57225722
expect(callOrder).toEqual([1, 2, 3]);
57235723
});
57245724
});
5725+
5726+
describe('unsafe integer request ID validation', () => {
5727+
let protocol: Protocol<BaseContext>;
5728+
let transport: MockTransport;
5729+
5730+
beforeEach(() => {
5731+
transport = new MockTransport();
5732+
protocol = new (class extends Protocol<BaseContext> {
5733+
protected assertCapabilityForMethod(): void {}
5734+
protected assertNotificationCapability(): void {}
5735+
protected assertRequestHandlerCapability(): void {}
5736+
protected assertTaskCapability(): void {}
5737+
protected buildContext(ctx: BaseContext): BaseContext {
5738+
return ctx;
5739+
}
5740+
protected assertTaskHandlerCapability(): void {}
5741+
})();
5742+
});
5743+
5744+
it('should respond with InvalidRequest error when request ID exceeds MAX_SAFE_INTEGER', async () => {
5745+
await protocol.connect(transport);
5746+
const sendSpy = vi.spyOn(transport, 'send');
5747+
5748+
// Send a request with an ID exceeding Number.MAX_SAFE_INTEGER
5749+
// Note: 9007199254740992 === Number.MAX_SAFE_INTEGER + 1, but due to
5750+
// floating-point precision loss, JavaScript cannot represent it exactly.
5751+
const unsafeId = Number.MAX_SAFE_INTEGER + 1;
5752+
transport.onmessage?.({
5753+
jsonrpc: '2.0',
5754+
id: unsafeId,
5755+
method: 'tools/list',
5756+
params: {}
5757+
});
5758+
5759+
// Wait for async processing
5760+
await new Promise(resolve => setTimeout(resolve, 50));
5761+
5762+
// Should have sent an error response
5763+
expect(sendSpy).toHaveBeenCalledTimes(1);
5764+
const sentMessage = sendSpy.mock.calls[0][0] as JSONRPCErrorResponse;
5765+
expect(sentMessage.jsonrpc).toBe('2.0');
5766+
expect(sentMessage.id).toBe(unsafeId);
5767+
expect(sentMessage.error.code).toBe(ProtocolErrorCode.InvalidRequest);
5768+
expect(sentMessage.error.message).toBe('Invalid request: ID must be a string or a safe integer');
5769+
});
5770+
5771+
it('should process requests normally when ID is at MAX_SAFE_INTEGER', async () => {
5772+
await protocol.connect(transport);
5773+
const sendSpy = vi.spyOn(transport, 'send');
5774+
5775+
protocol.setRequestHandler('tools/list', async () => {
5776+
return { tools: [] };
5777+
});
5778+
5779+
// Send a request with ID exactly at Number.MAX_SAFE_INTEGER
5780+
transport.onmessage?.({
5781+
jsonrpc: '2.0',
5782+
id: Number.MAX_SAFE_INTEGER,
5783+
method: 'tools/list',
5784+
params: {}
5785+
});
5786+
5787+
// Wait for async processing
5788+
await new Promise(resolve => setTimeout(resolve, 50));
5789+
5790+
// Should have sent a successful response, not an error
5791+
expect(sendSpy).toHaveBeenCalledTimes(1);
5792+
const sentMessage = sendSpy.mock.calls[0][0] as JSONRPCResultResponse;
5793+
expect(sentMessage.jsonrpc).toBe('2.0');
5794+
expect(sentMessage.id).toBe(Number.MAX_SAFE_INTEGER);
5795+
expect(sentMessage.result).toBeDefined();
5796+
});
5797+
5798+
it('should process requests normally when ID is a string', async () => {
5799+
await protocol.connect(transport);
5800+
const sendSpy = vi.spyOn(transport, 'send');
5801+
5802+
protocol.setRequestHandler('tools/list', async () => {
5803+
return { tools: [] };
5804+
});
5805+
5806+
// String IDs should always be accepted regardless of content
5807+
transport.onmessage?.({
5808+
jsonrpc: '2.0',
5809+
id: '9007199254740992',
5810+
method: 'tools/list',
5811+
params: {}
5812+
});
5813+
5814+
// Wait for async processing
5815+
await new Promise(resolve => setTimeout(resolve, 50));
5816+
5817+
// Should have sent a successful response
5818+
expect(sendSpy).toHaveBeenCalledTimes(1);
5819+
const sentMessage = sendSpy.mock.calls[0][0] as JSONRPCResultResponse;
5820+
expect(sentMessage.jsonrpc).toBe('2.0');
5821+
expect(sentMessage.id).toBe('9007199254740992');
5822+
expect(sentMessage.result).toBeDefined();
5823+
});
5824+
});
57255825
});

0 commit comments

Comments
 (0)