Skip to content

fix(node): cancel the response stream when the client disconnects - #3151

Open
SwapnilSkr wants to merge 1 commit into
modelcontextprotocol:mainfrom
SwapnilSkr:fix/node-handler-cancel-on-disconnect
Open

SwapnilSkr wants to merge 1 commit into
modelcontextprotocol:mainfrom
SwapnilSkr:fix/node-handler-cancel-on-disconnect

Conversation

@SwapnilSkr

Copy link
Copy Markdown

Motivation and Context

Fixes #3148.

toNodeHandler read the response body with for await, so it only noticed a client disconnect when the stream produced its next chunk. An idle session GET stream produces nothing until the next keep-alive, up to 15s by default, and its slot stays taken until then. During that time every reconnect gets 409. With default options the client retries twice in about 2.5s and gives up, so one dropped connection, such as a proxy idle timeout, ends notifications for the rest of the session.

NodeStreamableHTTPServerTransport already releases the slot in about 2ms in the same test.

What changed

  • packages/middleware/node/src/toNodeHandler.ts: read the body through a reader and cancel it when the abort signal fires. The transport's cancel handler then frees the GET slot straight away. The existing backpressure handling is unchanged.
  • packages/middleware/node/test/toNodeHandler.test.ts: add a test that closes the response while the stream is idle and checks that cancel runs.
  • Changeset (patch: @modelcontextprotocol/node).

No new API or options.

Racing iterator.next() against the abort, as suggested in the issue, is not enough. It exits the loop, but return() on the async iterator waits for the pending next(), so the stream is not cancelled and the 409 stays. The new test fails with that version.

Behaviour to review

All response streams now cancel at disconnect rather than at their next chunk, including POST SSE streams. The existing hosting:http:disconnect-not-cancel scenario still passes.

How Has This Been Tested?

  • The new test fails on unmodified main and passes with this change.
  • I ran the repro from the issue over a real socket. Notifications delivered after the drop: 0 before this change, 1 after.
  • @modelcontextprotocol/node tests: 106 passed.
  • pnpm build:all, pnpm typecheck:all and pnpm lint:all pass.
  • pnpm test:all passes except for protocol:timeout:max-total in test/e2e/scenarios/protocol.test.ts, which is flaky and not related to this change. Run on its own, it failed 1 of 10 runs with this change and 1 of 10 without it.

Tested locally on Node 25.9.0, macOS.

Breaking Changes

None.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

`toNodeHandler` read the response body with `for await`, so it only saw a
client disconnect when the stream produced its next chunk. On an idle GET
stream that held the session's only GET slot until the next keep-alive, and
reconnects got 409 until the client gave up.

Read through a reader and cancel it on abort. Racing `next()` against the
abort does not work here: `return()` on the async iterator waits for the
pending `next()`, so an idle stream would still not be cancelled.
@SwapnilSkr
SwapnilSkr requested a review from a team as a code owner November 5, 2026 05:54
@changeset-bot

changeset-bot Bot commented Nov 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6ca7b5b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@modelcontextprotocol/node Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Nov 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@3151

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@3151

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@3151

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@3151

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@3151

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@3151

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@3151

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@3151

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@3151

commit: 6ca7b5b

@claude claude Bot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Nov 5, 2026

@JosephDoUrden JosephDoUrden left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pulled 6ca7b5b, 0 behind main. @modelcontextprotocol/node 5 files, 106 passed. new test fails with main's toNodeHandler.ts (1 failed, 14 passed). real socket on node 22.23.2, idle stream, client drops after first chunk: main cancel calls 0, this branch 1, within 2ms of destroy. plain Response('ok') still 200.

no findings. lgtm.

This branch has not been deployed

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v2] toNodeHandler: a dropped GET stream blocks reconnects until the next keep-alive, so the client stops receiving notifications

2 participants