Skip to content

fix(client): do not wait for the end of an unread POST reply body - #3082

Open
claude[bot] wants to merge 4 commits into
mainfrom
fix/client-release-post-reply-body
Open

claude[bot] wants to merge 4 commits into
mainfrom
fix/client-release-post-reply-body

Conversation

@claude

@claude claude Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Felix Weinberger · Slack thread

Before: after a successful POST whose reply body the client does not read (a 202, a 2xx reply to a message with no requests, a 2xx reply with an unexpected content type, any OK reply on the SSE transport), send() awaited response.text(), which settles only when the body ends. A server or proxy that sends the headers and keeps the response open stalled send(), and with it Client.connect().

After: the body is still read to its end, but send() does not wait for that. A body that has not ended after 100 ms is cancelled. A reply of a normal server ends by itself and keeps its connection, as on main. A reply that is never ended no longer stalls send() or Client.connect(); its connection closes after 100 ms.

Why not cancel at once: the first commit of this PR did. It ended the stall, but it also cut replies of normal servers whose end had not arrived yet. Measured against main, with nothing held open: about one new connection per 202 through a TLS reverse proxy built on Go's httputil.ReverseProxy (85 to 107 connections against 3 to 8, for 100 notifications and 100 calls), from a workerd client to this SDK's servers (50 to 52 against 0 to 2, for 50 notifications) and on the SSE transport to a Python server over TLS (206 against 3); Chromium logged each 202 POST as net::ERR_ABORTED; and with a custom fetch that returns response.clone(), close() was followed by an unhandled AbortError on Node 20 and 22. With the second commit the same runs give the results of main.

History of these lines: #1214 added await response.body?.cancel(). #1320 replaced it with await response.text(), because cancel() could hang. Both forms wait, and both waits can hang: cancel() on a response that a custom fetch has cloned does not settle until the other branch is consumed. Not awaiting avoids both; a test covers the cloned case.

A custom fetch may return a body that is not a WHATWG stream (node-fetch returns a Node stream, which has no getReader()). Such a body is read with response.text(), also without awaiting, and is never cancelled. The four sites share one internal helper, releaseBody(), which never throws or rejects. Each site has tests that fail when its call is deleted.

Still open: only POST replies with a 2xx status are covered. Replies with another status, the redirect drain, the GET stream and terminateSession() are left as they are. When the reply has not ended after 100 ms and the custom fetch returns response.clone(), a later close() is still followed by the unhandled AbortError on Node 20 and 22. While a reply is held open it occupies its connection for up to 100 ms, so a client with a small connection pool sends more slowly to such a server. A server whose reply takes longer than 100 ms to end has its connection closed, and any work it ties to that connection is aborted, while send() has already resolved. A body with no getReader(), or a reply a custom fetch holds a clone of, stays open until close() if the server never ends it.


Generated by Claude Code

**Before:** after a successful POST whose reply body the client does not read (a 202, a 2xx reply to a message with no requests, a 2xx reply with an unexpected content type, any OK reply on the SSE transport), `send()` awaited `response.text()`, which settles only when the body ends. A server or proxy that sends the headers and keeps the response open stalled `send()`, and with it `Client.connect()`.

**After:** the body is cancelled without awaiting. A fully received reply keeps its connection for reuse; a partly received one has it closed.

**History of these lines:** #1214 added `await response.body?.cancel()`. #1320 replaced it with `await response.text()`, because `cancel()` could hang. Both forms wait, and both waits can hang: `cancel()` on a response that a custom fetch has cloned does not settle until the other branch is consumed. Not awaiting avoids both; a test covers the cloned case.

A custom fetch may return a body that is not a WHATWG stream (node-fetch returns a Node stream, which has no `cancel()`). Such a body is still read with `response.text()`, also without awaiting. The four sites share one internal helper, `releaseBody()`, which never throws or rejects.

The non-2xx drains, the GET stream and the DELETE are left as they are.
@claude
claude Bot requested a review from a team as a code owner October 23, 2026 15:44
@changeset-bot

changeset-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4db666d

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

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/client Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/core-internal 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 Oct 23, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

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

@modelcontextprotocol/core

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

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 4db666d

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

return `Redirect to ${target.href} not followed; use that URL as the endpoint if it is the intended server`;
}

/** Releases the unread body of `response` without waiting for it; reads it to its end when it has no `cancel()`. Never throws. */

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 nit (optional): maintainers get a JSDoc block on a helper that is not public API, which CLAUDE.md principle 8 asks to avoid. releaseBody at packages/core-internal/src/shared/transport.ts:115 is exported only through the private core-internal barrel, not through exports/public/index.ts. Fix: make the comment at line 114 a one-line // comment, or drop it and keep the reasoning in the PR description. Neighbouring internal helpers in this file (lines 51, 70, 77, 101) already carry the same one-line /** */ form, so this follows local precedent and nothing breaks at runtime. [also at: packages/core-internal/src/shared/transport.ts:114 - nit: CLAUDE.md (Brief comments) keeps JSDoc to the public API: the new releaseBody helper carries a /** ... */ JSDoc block, but it is internal — it lives in the private @ modelcontextprotocol/core-internal package and is not re-exported from src/exports/public/index.ts.]

Why this was flagged

No runtime trigger; this is a convention check. packages/core-internal/src/shared/transport.ts:114 adds a /** ... */ JSDoc to releaseBody. The function is reachable only via export * from './shared/transport' in packages/core-internal/src/index.ts:21; packages/core-internal/src/exports/public/index.ts:66-67 re-exports only FetchLike, Transport, TransportSendOptions and createFetchWithInit from that file, and packages/core-internal/package.json marks the package "private": true. So the documented symbol is internal. CLAUDE.md principle 8 limits JSDoc to the public API and wants inline comments kept to one line. The base branch has no such comment because the helper is new. No safeguard applies: Prettier and ESLint do not enforce this. The comment text itself is accurate (cancel when body.cancel is a function, otherwise response.text?.(), rejection swallowed).

Verification: nit — triggering condition: none at runtime; this is a rule check on a line the diff adds. packages/core-internal/src/shared/transport.ts:114, added by this diff, is a /** ... */ JSDoc block on releaseBody (line 115). So JSDoc syntax is used on a non-public helper, which breaks the rule as written. Nothing observable changes for users.

Finding ID: f-1893d194


/** Releases the unread body of `response` without waiting for it; reads it to its end when it has no `cancel()`. Never throws. */
export function releaseBody(response: Response): void {
const release = async () => (typeof response.body?.cancel === 'function' ? response.body.cancel() : response.text?.());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 (optional) Clients whose fetch returns a body with no cancel() (node-fetch, React Native) now leave one socket and one pending read open per POST when a server holds replies open. releaseBody at transport.ts:116 falls back to an unawaited response.text?.(). Nothing ends that read until close() aborts the transport signal. The base stalled on the first such send(); now they pile up, once per message on the SSE transport. Fix: make the release end the reply on every fetch, for example by aborting a per-POST signal instead of reading. A cloned response has the same gap, because cancel() on one branch leaves the reply open.

Why this was flagged

Trigger: a custom fetch whose reply body has no cancel() (the PR's own case 'a fetch whose reply body is a Node stream, which has no cancel()', heldOpenPostReply.test.ts:84) and a server that sends headers and never ends the reply (holdOpen, heldOpenPostReply.test.ts:22). releaseBody (transport.ts:116) calls response.text?.() and drops the promise; that read settles only when the body ends, so the socket and the buffered read stay alive. The only line that ends them is this._abortController?.abort() in close() (streamableHttp.ts:973, sse.ts:426). On the base branch send() awaited the same read, so a sequential caller never had more than one such reply open; after the merge each send() returns and the next adds another, with no cap for the life of the transport. No safeguard applies: .catch(() => {}) at transport.ts:117 only swallows the rejection.

Verification: Triggering condition: a custom fetch whose reply body has no cancel(), talking to a server that never ends the POST reply. packages/core-internal/src/shared/transport.ts:116: with no cancel(), the fallback is an unawaited response.text(), which only settles when the body ends. Base await response.text?.().catch(() => {}) never returned, so the first such send() hung.

Finding ID: f-8d01272d

// If the response is 202 Accepted, there's no body to process
if (response.status === 202) {
await response.text?.().catch(() => {});
releaseBody(response);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟣 pre-existing, not blocking: pre-existing: A server or proxy that holds a reply open still stalls terminateSession() and any send() answered with a 401, exactly as on the base branch. Only the four 2xx POST drains were converted. Other drains whose text is thrown away still run await response.text?.(). Fix: release every discarded reply body without awaiting, for example with releaseBody(response), which covers the 5 sites listed. Same pattern at 5 sites (streamableHttp.ts:1095, streamableHttp.ts:1099, streamableHttp.ts:1266, sse.ts:472, sse.ts:476). The description leaves these out on purpose, but the DELETE reply is as unread as a 202.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger (peer behaviour, not reproducible from this checkout): the peer the changeset names, one that sends headers and keeps the response open, answers a DELETE with 200 or a POST with 401 while an authProvider is set. terminateSession() at packages/client/src/client/streamableHttp.ts:1266 runs await response.text?.().catch(() => {}) before it reads the status, so it never settles. _send() awaits the same discarded drain at :1095 (after onUnauthorized, before the retry) and :1099 (before throwing UnauthorizedError), and packages/client/src/client/sse.ts:472 and :476 do the same, so send() and a Client.connect() whose first POST is challenged still hang. The base branch behaves identically; the diff changes only :1168, :1212, :1219 and sse.ts:495. No timeout guards these awaits; only transport.close() aborting _abortController ends them. The drains at :1111 and sse.ts:488 use the text and are not part of this.

Verification: pre-existing — triggers when a server or proxy sends headers and holds the response open on a DELETE reply, or on a 401 POST reply while an authProvider is set. packages/client/src/client/streamableHttp.ts:1265-1266 — terminateSession() runs await response.text?.().catch(() => {}); before the status check at :1270. None of the lines above appear in the diff, so the base fails the same way by the same route.

Finding ID: f-a05dae63

@claude claude Bot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Oct 24, 2026
**Before:** `releaseBody()` cancelled the unread body of a 2xx POST reply at once. That ended the stall on a reply that is never ended, but it also cut replies of normal servers whose end had not arrived yet. Measured against `main`, with nothing held open: about one new connection per 202 through a TLS reverse proxy built on Go's `httputil.ReverseProxy` (85 to 107 connections against 3 to 8, for 100 notifications and 100 calls), from a workerd client to this SDK's servers (50 to 52 against 0 to 2, for 50 notifications) and on the SSE transport to a Python server over TLS (206 against 3); Chromium logged each 202 POST as `net::ERR_ABORTED`; and with a custom `fetch` that returns `response.clone()`, `close()` was followed by an unhandled `AbortError` on Node 20 and 22.

**After:** the body is read to its end, still without `send()` waiting for it, and is cancelled only if it has not ended after 100 ms. The same runs now give the results of `main`. A reply that is never ended still does not stall `send()` or `Client.connect()`; its connection closes after 100 ms instead of at once.

The timer is cleared when the body ends or fails. A body with no `getReader()` (node-fetch) is read with `response.text()`, also without waiting, and is never cancelled.

**Still open:** only POST replies with a 2xx status are covered. Replies with another status, the redirect drain and `terminateSession()` wait as before. When the reply has not ended after 100 ms and the custom `fetch` returns `response.clone()`, a later `close()` is still followed by the unhandled `AbortError` on Node 20 and 22.

Each of the four call sites now has tests that fail when its call is deleted.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No new blocking issues. 1 optional suggestion (a nit or a note on pre-existing code) was found and not posted. Nothing in this review needs a push before merging.

Still open from earlier reviews (3):

  • Unresolved: 3 minor or pre-existing.

This review covers commit c140325, which is no longer the latest commit on this pull request; later commits are not covered by it.

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.

1 participant