From ba7df76dbd27ec4faaefd0b54811d34d07111d43 Mon Sep 17 00:00:00 2001 From: Adam Boudjemaa Date: Sun, 29 Mar 2026 01:20:07 +0100 Subject: [PATCH 1/3] 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) --- packages/core/src/shared/protocol.ts | 22 +++++ packages/core/test/shared/protocol.test.ts | 100 +++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index d6daf0172..f62416426 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -480,6 +480,28 @@ export abstract class Protocol { } else if (isJSONRPCNotification(message)) { this._onnotification(message); } else { + // Check if this is a request-like message with an invalid ID (e.g., + // a numeric ID exceeding Number.MAX_SAFE_INTEGER). Such IDs fail + // schema validation because JavaScript cannot represent them exactly, + // which prevents reliable request/response correlation. Respond with + // a JSON-RPC Invalid Request error instead of silently dropping it. + const msg = message as Record; + if (msg && typeof msg === 'object' && msg.jsonrpc === '2.0' && 'id' in msg && 'method' in msg) { + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: msg.id as RequestId, + error: { + code: ProtocolErrorCode.InvalidRequest, + message: 'Invalid request: ID must be a string or a safe integer' + } + }; + + this._transport + ?.send(errorResponse) + .catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); + return; + } + this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`)); } }; diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 69735bc3a..3bf97cfc5 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -5509,6 +5509,106 @@ describe('Error handling for missing resolvers', () => { expect(callOrder).toEqual([1, 2, 3]); }); }); + + describe('unsafe integer request ID validation', () => { + let protocol: Protocol; + let transport: MockTransport; + + beforeEach(() => { + transport = new MockTransport(); + protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + protected assertTaskHandlerCapability(): void {} + })(); + }); + + it('should respond with InvalidRequest error when request ID exceeds MAX_SAFE_INTEGER', async () => { + await protocol.connect(transport); + const sendSpy = vi.spyOn(transport, 'send'); + + // Send a request with an ID exceeding Number.MAX_SAFE_INTEGER + // Note: 9007199254740992 === Number.MAX_SAFE_INTEGER + 1, but due to + // floating-point precision loss, JavaScript cannot represent it exactly. + const unsafeId = Number.MAX_SAFE_INTEGER + 1; + transport.onmessage?.({ + jsonrpc: '2.0', + id: unsafeId, + method: 'tools/list', + params: {} + }); + + // Wait for async processing + await new Promise(resolve => setTimeout(resolve, 50)); + + // Should have sent an error response + expect(sendSpy).toHaveBeenCalledTimes(1); + const sentMessage = sendSpy.mock.calls[0][0] as JSONRPCErrorResponse; + expect(sentMessage.jsonrpc).toBe('2.0'); + expect(sentMessage.id).toBe(unsafeId); + expect(sentMessage.error.code).toBe(ProtocolErrorCode.InvalidRequest); + expect(sentMessage.error.message).toBe('Invalid request: ID must be a string or a safe integer'); + }); + + it('should process requests normally when ID is at MAX_SAFE_INTEGER', async () => { + await protocol.connect(transport); + const sendSpy = vi.spyOn(transport, 'send'); + + protocol.setRequestHandler('tools/list', async () => { + return { tools: [] }; + }); + + // Send a request with ID exactly at Number.MAX_SAFE_INTEGER + transport.onmessage?.({ + jsonrpc: '2.0', + id: Number.MAX_SAFE_INTEGER, + method: 'tools/list', + params: {} + }); + + // Wait for async processing + await new Promise(resolve => setTimeout(resolve, 50)); + + // Should have sent a successful response, not an error + expect(sendSpy).toHaveBeenCalledTimes(1); + const sentMessage = sendSpy.mock.calls[0][0] as JSONRPCResultResponse; + expect(sentMessage.jsonrpc).toBe('2.0'); + expect(sentMessage.id).toBe(Number.MAX_SAFE_INTEGER); + expect(sentMessage.result).toBeDefined(); + }); + + it('should process requests normally when ID is a string', async () => { + await protocol.connect(transport); + const sendSpy = vi.spyOn(transport, 'send'); + + protocol.setRequestHandler('tools/list', async () => { + return { tools: [] }; + }); + + // String IDs should always be accepted regardless of content + transport.onmessage?.({ + jsonrpc: '2.0', + id: '9007199254740992', + method: 'tools/list', + params: {} + }); + + // Wait for async processing + await new Promise(resolve => setTimeout(resolve, 50)); + + // Should have sent a successful response + expect(sendSpy).toHaveBeenCalledTimes(1); + const sentMessage = sendSpy.mock.calls[0][0] as JSONRPCResultResponse; + expect(sentMessage.jsonrpc).toBe('2.0'); + expect(sentMessage.id).toBe('9007199254740992'); + expect(sentMessage.result).toBeDefined(); + }); + }); }); describe('Protocol without task configuration', () => { From d18ce5f4205fc6fca099faa89bae3cbf1fc47996 Mon Sep 17 00:00:00 2001 From: Adam Boudjemaa Date: Mon, 30 Mar 2026 00:06:38 +0200 Subject: [PATCH 2/3] fix: add non-null assertions to mock.calls array access in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsgo flags `mock.calls[0][0]` as "Object is possibly undefined" — add `!` after the first index access to match the convention used everywhere else in this test file. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/test/shared/protocol.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 3bf97cfc5..1c1e68142 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -5548,7 +5548,7 @@ describe('Error handling for missing resolvers', () => { // Should have sent an error response expect(sendSpy).toHaveBeenCalledTimes(1); - const sentMessage = sendSpy.mock.calls[0][0] as JSONRPCErrorResponse; + const sentMessage = sendSpy.mock.calls[0]![0] as JSONRPCErrorResponse; expect(sentMessage.jsonrpc).toBe('2.0'); expect(sentMessage.id).toBe(unsafeId); expect(sentMessage.error.code).toBe(ProtocolErrorCode.InvalidRequest); @@ -5576,7 +5576,7 @@ describe('Error handling for missing resolvers', () => { // Should have sent a successful response, not an error expect(sendSpy).toHaveBeenCalledTimes(1); - const sentMessage = sendSpy.mock.calls[0][0] as JSONRPCResultResponse; + const sentMessage = sendSpy.mock.calls[0]![0] as JSONRPCResultResponse; expect(sentMessage.jsonrpc).toBe('2.0'); expect(sentMessage.id).toBe(Number.MAX_SAFE_INTEGER); expect(sentMessage.result).toBeDefined(); @@ -5603,7 +5603,7 @@ describe('Error handling for missing resolvers', () => { // Should have sent a successful response expect(sendSpy).toHaveBeenCalledTimes(1); - const sentMessage = sendSpy.mock.calls[0][0] as JSONRPCResultResponse; + const sentMessage = sendSpy.mock.calls[0]![0] as JSONRPCResultResponse; expect(sentMessage.jsonrpc).toBe('2.0'); expect(sentMessage.id).toBe('9007199254740992'); expect(sentMessage.result).toBeDefined(); From 0a62c4c79452033f93404ff4cde057f8733189d6 Mon Sep 17 00:00:00 2001 From: Adam Boudjemaa Date: Mon, 30 Mar 2026 00:12:03 +0200 Subject: [PATCH 3/3] chore: add changeset for safe integer request ID fix Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/clever-otters-give.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clever-otters-give.md diff --git a/.changeset/clever-otters-give.md b/.changeset/clever-otters-give.md new file mode 100644 index 000000000..fd6361bb4 --- /dev/null +++ b/.changeset/clever-otters-give.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/core": patch +--- + +fix(transport): validate JSON-RPC request ID is a safe integer