Skip to content

[v2] A response that arrives after its request was cancelled raises onerror #3097

Open

Description

@subhashpolisetti

What happened?

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();
let onCall;
const called = new Promise(resolve => (onCall = resolve));

// A server that answers tools/call 50 ms later, as if the response crossed the cancellation.
server.onmessage = m => {
    if (!('id' in m) || !('method' in m)) return;
    const reply = 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' } } });
    else if (m.method === 'tools/call') (onCall(), setTimeout(() => reply({ result: { content: [] } }), 50));
    else reply({ error: { code: -32601, message: 'Method not found' } });
};
await server.start();

const client = new Client({ name: 'c', version: '0' });
client.onerror = e => console.log('onerror:', e.message);
await client.connect(clientTransport);

const ac = new AbortController();
const call = client.callTool({ name: 'slow', arguments: {} }, { signal: ac.signal });
await called;
ac.abort();
await call.catch(e => console.log('rejected:', e.message));
await new Promise(r => setTimeout(r, 100));


`{ timeout: 10 }` instead of the signal gives the same `onerror`.

SDK version

2.3.1

Area

Client

Activity

  1. added
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    on Oct 25, 2026
  2. Ben-Home commented on Oct 28, 2026

    @Ben-Home

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions