Skip to content

[v1.x] Protocol.request keeps its abort listener after the request resolved, so a later abort sends notifications/cancelled for an answered request (1.30.0) #3090

Open

Description

@roundai-kiril

Summary

Protocol.request() registers its 'abort' listener on options.signal and never removes it. Once the request has resolved, the listener is still attached, so a caller that later aborts the controller (because the operation it guarded is over and it is releasing resources) makes cancel() run and send a notifications/cancelled for a request that was already answered. The server then receives a cancellation for a finished request, and over Streamable HTTP that is one extra POST per call.

Seen on @modelcontextprotocol/sdk 1.30.0 (Node 24). The v1.x branch (3b73021) still has the same code in src/shared/protocol.ts:

options?.signal?.addEventListener('abort', () => {
    cancel(options?.signal?.reason);
});

cancel() deletes the response handler and clears the timeout, which are no-ops after resolution, but the this._transport?.send({ method: 'notifications/cancelled', ... }) call still goes out. The reject(error) is a no-op on the settled promise, so the caller sees nothing. The v2 line (packages/core-internal/src/shared/protocol.ts on main) already guards this with responseReceived, registers the listener with { once: true } and removes it once the response arrives; v1.x does not.

Reproduction

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { CallToolRequestSchema, CancelledNotificationSchema } from "@modelcontextprotocol/sdk/types.js";

const server = new Server({ name: "repro-server", version: "1.0.0" }, { capabilities: { tools: {} } });
server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [{ type: "text", text: "done" }] }));
server.setNotificationHandler(CancelledNotificationSchema, async (n) => console.log("server received:", JSON.stringify(n.params)));

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: "repro-client", version: "1.0.0" });
await client.connect(clientTransport);

const controller = new AbortController();
const result = await client.callTool({ name: "echo", arguments: {} }, undefined, { signal: controller.signal });
console.log("resolved:", JSON.stringify(result));

// The caller is done with the request and releases its controller.
controller.abort("caller released the controller");
await new Promise((resolve) => setTimeout(resolve, 50));

Output with 1.30.0:

resolved: {"content":[{"type":"text","text":"done"}]}
server received: {"requestId":1,"reason":"caller released the controller"}

Expected: nothing after resolved:. A request that has resolved has nothing left to cancel; the abort listener should be removed (or cancel() should return) once the response handler has run, as main already does.

How we hit it

Our runtime hands each HTTP request's AbortController to callTool and aborts it on the Node response's 'close' event, which fires for a normally finished response as well. Every completed tool call was therefore followed by a notifications/cancelled that travelled through our proxy and authorization path like a real call. We have stopped aborting after a finished response on our side; this report is for the listener that stays attached.

Activity

  1. suryakant-source commented on Oct 24, 2026

    @suryakant-source

    Opened #3091 against v1.x with the backport of v2's pattern: a responseReceived guard in cancel(), the abort listener registered once, and removal in finally. Two tests included — the new regression test fails on unfixed v1.x and passes with the change, and pre-response cancellation still sends exactly one notifications/cancelled. Full non-e2e suite passes (1860 tests).

  2. added
    v1Issues / PRs related to v1.x
    on Oct 24, 2026
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

    v1Issues / PRs related to v1.x

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions