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
[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
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:
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";constserver=newServer({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();awaitserver.connect(serverTransport);constclient=newClient({name: "repro-client",version: "1.0.0"});awaitclient.connect(clientTransport);constcontroller=newAbortController();constresult=awaitclient.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");awaitnewPromise((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.
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).
Activity
suryakant-source commentedon Oct 24, 2026
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).