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
6 changes: 6 additions & 0 deletions .changeset/strict-response-identifier-matching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

Keep response IDs and progress tokens distinct by JSON type. String values such as `"0"` and `""` no longer match an outstanding request or progress callback registered with numeric ID `0`; unmatched messages are reported through the existing error handler.
154 changes: 154 additions & 0 deletions packages/client/test/client/requestIdMatching.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { setImmediate } from 'node:timers/promises';

import type { JSONRPCRequest } from '@modelcontextprotocol/core-internal';
import { InMemoryTransport } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';

import { Client } from '../../src/client/client';

const initializeResult = {
protocolVersion: '2025-11-25',
capabilities: {},
serverInfo: { name: 'id-matching-peer', version: '1.0.0' }
};

async function startHandshake() {
const client = new Client({ name: 'id-matching-client', version: '1.0.0' });
const [clientTransport, peerTransport] = InMemoryTransport.createLinkedPair();
const errors: Error[] = [];
const progress: unknown[] = [];
client.onerror = error => errors.push(error);
let resolveRequest!: (request: JSONRPCRequest) => void;
const requestReceived = new Promise<JSONRPCRequest>(resolve => {
resolveRequest = resolve;
});
peerTransport.onmessage = message => {
if ('method' in message && message.method === 'initialize' && 'id' in message) {
resolveRequest(message);
}
};
await peerTransport.start();
let settled = false;
const connected = client.connect(clientTransport, { onprogress: value => progress.push(value) });
void connected.then(
() => {
settled = true;
},
() => {
settled = true;
}
);
const request = await requestReceived;
expect(request.id).toBe(0);
return { client, peerTransport, connected, request, errors, progress, isSettled: () => settled };
}

describe('request identifier matching', () => {
for (const kind of ['result', 'error'] as const) {
it.each(['', ' ', '0', '0.0', '0x0', 'unrelated', 999])(`ignores an unmatched ${kind} response with id %j`, async id => {
const state = await startHandshake();
try {
await state.peerTransport.send(
kind === 'result'
? { jsonrpc: '2.0', id, result: initializeResult }
: { jsonrpc: '2.0', id, error: { code: -32_603, message: 'Unrelated request failed' } }
);
await setImmediate();
expect(state.isSettled()).toBe(false);
expect(state.errors).toHaveLength(1);
expect(state.errors[0]!.message).toContain('unknown message ID');

await state.peerTransport.send({ jsonrpc: '2.0', id: state.request.id, result: initializeResult });
await expect(state.connected).resolves.toBeUndefined();
} finally {
await state.client.close();
}
});
}

it.each(['', ' ', '0', '0.0', '0x0', 'unrelated', 999])('ignores an unmatched progress token %j', async progressToken => {
const state = await startHandshake();
try {
await state.peerTransport.send({
jsonrpc: '2.0',
method: 'notifications/progress',
params: { progressToken, progress: 1 }
});
await setImmediate();
expect(state.progress).toEqual([]);
expect(state.errors).toHaveLength(1);
expect(state.errors[0]!.message).toContain('unknown token');
expect(state.isSettled()).toBe(false);

await state.peerTransport.send({
jsonrpc: '2.0',
method: 'notifications/progress',
params: { progressToken: state.request.id, progress: 2 }
});
await setImmediate();
expect(state.progress).toEqual([{ progress: 2 }]);
await state.peerTransport.send({ jsonrpc: '2.0', id: state.request.id, result: initializeResult });
await expect(state.connected).resolves.toBeUndefined();
} finally {
await state.client.close();
}
});

it('ignores an error response without an identifier', async () => {
const state = await startHandshake();
try {
await state.peerTransport.send({ jsonrpc: '2.0', error: { code: -32_700, message: 'Parse error' } });
await setImmediate();
expect(state.isSettled()).toBe(false);
expect(state.errors).toHaveLength(1);
expect(state.errors[0]!.message).toContain('unknown message ID');
await state.peerTransport.send({ jsonrpc: '2.0', id: state.request.id, result: initializeResult });
await expect(state.connected).resolves.toBeUndefined();
} finally {
await state.client.close();
}
});

it('rejects a null-identifier error without settling the request', async () => {
const state = await startHandshake();
try {
// @ts-expect-error Exercise the schema guard with an invalid incoming identifier.
await state.peerTransport.send({ jsonrpc: '2.0', id: null, error: { code: -32_700, message: 'Parse error' } });
await setImmediate();
expect(state.isSettled()).toBe(false);
expect(state.errors).toHaveLength(1);
expect(state.errors[0]!.message).toContain('Unknown message type');
await state.peerTransport.send({ jsonrpc: '2.0', id: state.request.id, result: initializeResult });
await expect(state.connected).resolves.toBeUndefined();
expect(state.errors).toHaveLength(1);
} finally {
await state.client.close();
}
});

it('accepts a result with the matching numeric identifier', async () => {
const state = await startHandshake();
try {
await state.peerTransport.send({ jsonrpc: '2.0', id: state.request.id, result: initializeResult });
await expect(state.connected).resolves.toBeUndefined();
expect(state.errors).toEqual([]);
} finally {
await state.client.close();
}
});

it('rejects an error with the matching numeric identifier', async () => {
const state = await startHandshake();
try {
await state.peerTransport.send({
jsonrpc: '2.0',
id: state.request.id,
error: { code: -32_603, message: 'Initialization failed' }
});
await expect(state.connected).rejects.toThrow('Initialization failed');
expect(state.errors).toEqual([]);
} finally {
await state.client.close();
}
});
});
20 changes: 10 additions & 10 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,9 +570,9 @@ export abstract class Protocol<ContextT extends BaseContext> {
private _customSchemaRequestMethods = new Set<string>();
private _requestHandlerAbortControllers: Map<RequestId, AbortController> = new Map();
private _notificationHandlers: Map<string, (notification: JSONRPCNotification, codec: WireCodec) => Promise<void>> = new Map();
private _responseHandlers: Map<number, (response: JSONRPCResultResponse | Error) => void> = new Map();
private _progressHandlers: Map<number, ProgressCallback> = new Map();
private _timeoutInfo: Map<number, TimeoutInfo> = new Map();
private _responseHandlers: Map<RequestId, (response: JSONRPCResultResponse | Error) => void> = new Map();
private _progressHandlers: Map<RequestId, ProgressCallback> = new Map();
private _timeoutInfo: Map<RequestId, TimeoutInfo> = new Map();
private _pendingDebouncedNotifications = new Set<string>();

/**
Expand Down Expand Up @@ -744,7 +744,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
}

private _setupTimeout(
messageId: number,
messageId: RequestId,
timeout: number,
maxTotalTimeout: number | undefined,
onTimeout: () => void,
Expand All @@ -760,7 +760,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
});
}

private _resetTimeout(messageId: number): boolean {
private _resetTimeout(messageId: RequestId): boolean {
const info = this._timeoutInfo.get(messageId);
if (!info) return false;

Expand All @@ -778,7 +778,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
return true;
}

private _cleanupTimeout(messageId: number) {
private _cleanupTimeout(messageId: RequestId) {
const info = this._timeoutInfo.get(messageId);
if (info) {
clearTimeout(info.timeoutId);
Expand Down Expand Up @@ -1177,7 +1177,7 @@ export abstract class Protocol<ContextT extends BaseContext> {

private _onprogress(notification: ProgressNotification): void {
const { progressToken, ...params } = notification.params;
const messageId = Number(progressToken);
const messageId = progressToken;

const handler = this._progressHandlers.get(messageId);
if (!handler) {
Expand Down Expand Up @@ -1211,10 +1211,10 @@ export abstract class Protocol<ContextT extends BaseContext> {
* else.
*/
protected _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void {
const messageId = Number(response.id);
const messageId = response.id;

const handler = this._responseHandlers.get(messageId);
if (handler === undefined) {
const handler = messageId === undefined ? undefined : this._responseHandlers.get(messageId);
if (messageId === undefined || handler === undefined) {
this._onerror(new Error(`Received a response for an unknown message ID: ${JSON.stringify(response)}`));
return;
}
Expand Down
Loading