Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clever-otters-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@modelcontextprotocol/core": patch
---

fix(transport): validate JSON-RPC request ID is a safe integer
22 changes: 22 additions & 0 deletions packages/core/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,28 @@ export abstract class Protocol<ContextT extends BaseContext> {
} 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<string, unknown>;
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)}`));
}
};
Expand Down
100 changes: 100 additions & 0 deletions packages/core/test/shared/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BaseContext>;
let transport: MockTransport;

beforeEach(() => {
transport = new MockTransport();
protocol = new (class extends Protocol<BaseContext> {
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', () => {
Expand Down
Loading