You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[v2] A response that arrives after its request was cancelled raises onerror #3097
When a request is cancelled (abort or timeout) and the server's response was already in flight, the response reaches onerror:
rejected: AbortError: This operation was aborted
onerror: Received a response for an unknown message ID: {"jsonrpc":"2.0","id":1,"result":{"content":[]}}
Protocol.request() deletes the response handler in .finally() once cancel() rejects, so _onresponse no longer knows the id.
What did you expect?
The response is ignored and the call stays rejected. The spec: the sender of the cancellation "SHOULD ignore any response to the request that arrives afterward" (2025-11-25). test/e2e/requirements.ts already lists this as a known failure (protocol:cancel:late-response-ignored).
Code to reproduce
import{Client,InMemoryTransport}from'@modelcontextprotocol/client';const[clientTransport,server]=InMemoryTransport.createLinkedPair();letonCall;constcalled=newPromise(resolve=>(onCall=resolve));// A server that answers tools/call 50 ms later, as if the response crossed the cancellation.server.onmessage=m=>{if(!('id'inm)||!('method'inm))return;constreply=result=>server.send({jsonrpc: '2.0',id: m.id, ...result});if(m.method==='initialize')reply({result: {protocolVersion: '2025-11-25',capabilities: {tools: {}},serverInfo: {name: 's',version: '0'}}});elseif(m.method==='tools/call')(onCall(),setTimeout(()=>reply({result: {content: []}}),50));elsereply({error: {code: -32601,message: 'Method not found'}});};awaitserver.start();constclient=newClient({name: 'c',version: '0'});client.onerror=e=>console.log('onerror:',e.message);awaitclient.connect(clientTransport);constac=newAbortController();constcall=client.callTool({name: 'slow',arguments: {}},{signal: ac.signal});awaitcalled;ac.abort();awaitcall.catch(e=>console.log('rejected:',e.message));awaitnewPromise(r=>setTimeout(r,100));`{ timeout: 10 }`insteadofthesignalgivesthesame`onerror`.
The bug is a conflation in one handler, and separating the two cases is most of the fix. _onresponse has a single "received a response for an unknown message ID" path, and that handling is correct for an id this client never issued, which is a peer protocol violation and deserves onerror. It is wrong for an id the client issued and then cancelled, because there the response is expected by the spec and must be dropped silently. Right now both cases look identical to the handler, because .finally() deletes the entry that would have told them apart.
So keep the cleanup and add memory. A tombstone set recording ids this client cancelled, checked before the unknown-id branch, is enough to route the late response to a no-op. Deleting the tombstone on transport close is a better anchor than a wall clock timer, since a response cannot legitimately outlive the transport that carried the request, and it removes the case where a sufficiently slow response reintroduces the same error after a TTL expires. If the ids are monotonic per client you can also drop tombstones below a watermark, but that is an optimisation rather than a correctness requirement.
Bound it. Whatever holds the tombstones needs a maximum size or a watermark, because cancellations that are never reaped are the same leak class as the listener accumulation in #3070, and it would be an unhelpful trade to fix a spurious error by introducing unbounded growth in the same code path. Also delete the tombstone when a new request takes that id, so a reused id behaves normally.
One property worth asserting explicitly, because the obvious fix breaks it: a response for an id the client never issued must still raise. If the tombstone check is written as "ignore late responses" rather than "ignore responses to ids this client cancelled", the handler stops reporting genuine protocol violations and you will not find out until something else depends on that signal. test/e2e/requirements.ts already carries protocol:cancel:late-response-ignored, so the requirement is expressible there; adding its negative twin in the same file keeps the pair from drifting apart.
Worth covering in the same change: a late response carrying an error rather than a result, for example a cancellation error object, should be swallowed by the same tombstone path. The reporter's case only shows a result, and an implementation that special-cases only result-shaped messages will pass the test and still emit the spurious onerror for the error-shaped variant in production.
Activity
Ben-Home commentedon Oct 28, 2026
The bug is a conflation in one handler, and separating the two cases is most of the fix.
_onresponsehas a single "received a response for an unknown message ID" path, and that handling is correct for an id this client never issued, which is a peer protocol violation and deservesonerror. It is wrong for an id the client issued and then cancelled, because there the response is expected by the spec and must be dropped silently. Right now both cases look identical to the handler, because.finally()deletes the entry that would have told them apart.So keep the cleanup and add memory. A tombstone set recording ids this client cancelled, checked before the unknown-id branch, is enough to route the late response to a no-op. Deleting the tombstone on transport close is a better anchor than a wall clock timer, since a response cannot legitimately outlive the transport that carried the request, and it removes the case where a sufficiently slow response reintroduces the same error after a TTL expires. If the ids are monotonic per client you can also drop tombstones below a watermark, but that is an optimisation rather than a correctness requirement.
Bound it. Whatever holds the tombstones needs a maximum size or a watermark, because cancellations that are never reaped are the same leak class as the listener accumulation in #3070, and it would be an unhelpful trade to fix a spurious error by introducing unbounded growth in the same code path. Also delete the tombstone when a new request takes that id, so a reused id behaves normally.
One property worth asserting explicitly, because the obvious fix breaks it: a response for an id the client never issued must still raise. If the tombstone check is written as "ignore late responses" rather than "ignore responses to ids this client cancelled", the handler stops reporting genuine protocol violations and you will not find out until something else depends on that signal.
test/e2e/requirements.tsalready carriesprotocol:cancel:late-response-ignored, so the requirement is expressible there; adding its negative twin in the same file keeps the pair from drifting apart.Worth covering in the same change: a late response carrying an error rather than a result, for example a cancellation error object, should be swallowed by the same tombstone path. The reporter's case only shows a result, and an implementation that special-cases only result-shaped messages will pass the test and still emit the spurious
onerrorfor the error-shaped variant in production.