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 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..1c1e68142 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', () => {