Repository navigation
requestId 0 is silently dropped by _oncancel, making the first request from every Protocol uncancellable #2115
Description
Problem
packages/core/src/shared/protocol.ts:411-414:
private async _oncancel(notification: CancelledNotification): Promise<void> {
if (!notification.params.requestId) {
return;
}
const controller = this._requestHandlerAbortControllers.get(notification.params.requestId);
controller?.abort(notification.params.reason);
}The if (!notification.params.requestId) check is falsy on 0. Combined with:
_requestMessageId = 0(line 313)const messageId = this._requestMessageId++(line 868, post-increment)
The first outbound request from every Client or Server instance gets wire id 0. A peer notifications/cancelled { requestId: 0 } is silently dropped. The handler runs to completion with ctx.mcpReq.signal.aborted === false.
Both Client and Server extend Protocol, so the bug applies in both directions (peer-cancelling the client's first request, or peer-cancelling the server's first peer-initiated request).
Reproducer
Requires a build from main or the next alpha (InMemoryTransport is not in the 2.0.0-alpha.2 publish; see #1834). The reproducer drives raw JSON-RPC messages so it can pin the wire id to 0:
import { Server, InMemoryTransport } from '@modelcontextprotocol/server';
const [a, b] = InMemoryTransport.createLinkedPair();
const server = new Server({ name: 's', version: '1' }, { capabilities: { tools: {} } });
let observed;
server.setRequestHandler('tools/call', async (req, ctx) => {
await new Promise(resolve => {
ctx.mcpReq.signal.addEventListener('abort', () => {
observed = true;
resolve();
}, { once: true });
setTimeout(() => {
observed = ctx.mcpReq.signal.aborted;
resolve();
}, 500);
});
return { content: [{ type: 'text', text: 'done' }] };
});
await server.connect(a);
b.onmessage = () => {};
await b.send({ jsonrpc: '2.0', id: 0, method: 'tools/call', params: { name: 'foo', arguments: {} } });
await new Promise(r => setTimeout(r, 50));
await b.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 0 } });
await new Promise(r => setTimeout(r, 600));
console.log('observed:', observed); // false (BUG: should be true)The same script with id: 1 (a non-zero id) cancels in under 50ms.
Proposed fix
if (notification.params.requestId === undefined) {
return;
}JSON-RPC permits string ids too, so a defense-in-depth version is === undefined || requestId === null.
Activity
jshaofa-ui commentedon May 17, 2026
serejaris commentedon May 27, 2026
I can take a focused look at this if maintainers agree this is a good small bugfix target.
The regression shape I would use:
Protocoltest fixture with an in-flight request controller stored under request id0;notifications/cancellednotification withparams.requestId = 0;requestIdso malformed cancel notifications are still ignored.The intended patch should stay narrow:
_oncancelshould ignore only missing request ids (undefined/null), not every falsy id, because0is a valid JSON-RPC request id.I will wait for maintainer direction/assignment before opening a PR to avoid duplicate work.