Skip to content

fix(server): settle pending JSON-mode responses when the streamable HTTP transport closes - #2692

Open
retif wants to merge 1 commit into
modelcontextprotocol:mainfrom
retif:fix/close-answers-inflight-requests
Open

fix(server): settle pending JSON-mode responses when the streamable HTTP transport closes#2692
retif wants to merge 1 commit into
modelcontextprotocol:mainfrom
retif:fix/close-answers-inflight-requests

Conversation

@retif

@retif retif commented Aug 21, 2026

Copy link
Copy Markdown

Fixes the close() half of #2559.

The SSE path is deliberately untouched. A POST-initiated SSE stream closing without a JSON-RPC response is specified by hosting:session:delete-cancels-inflight — the request handler has been aborted, so the request is cancelled, and a cancelled request gets no response. This PR only changes JSON response mode, where no such reading is available: the HTTP request simply never completes. (I originally wrote an error to in-flight SSE streams too; test-e2e failed on all three Node versions and was right to. That half is dropped, and #2691, where I first reported it, is corrected.)

Problem

In JSON response mode, handleRequest() returns a Promise<Response> that only send() resolves. A stream mapping's cleanup deletes the entry without settling that promise, so WebStandardStreamableHTTPServerTransport.close() while a POST is in flight leaves the HTTP request open until the socket dies.

This is #2559's second paragraph — "cleanup only deletes the entry without settling the promise, so close() during an in-flight JSON-mode POST leaves the HTTP request hanging" — reported there as pre-existing on both main and v1.x.

The caller cannot distinguish "never ran" from "ran, reply lost", so for a mutating call retrying may double-execute and giving up may drop a completed write. In practice it presents as a client hanging until its own idle timeout on a call the server has already stopped working on.

Change

  • A new private settlePendingJsonResponses() runs in close() before the cleanup() loop, since cleanup() drops the mapping that holds resolveJson.
  • It returns immediately unless enableJsonResponse is set and something is in flight.
  • Outstanding ids are grouped by stream, because a batched POST shares one stream across several request ids; the response is resolved with a single object or an array, matching the shape send() already produces.
  • Each outstanding id gets -32000 "Connection closed: the server transport closed before this request completed". An id that already has a real response (a batch sibling that completed first) reuses it rather than being overwritten.
  • Resolution is guarded so one undeliverable response cannot stop the others being settled or prevent close() from completing.
  • close() also clears _requestToStreamMapping, which it previously left populated.

NodeStreamableHTTPServerTransport wraps this transport, so it inherits the fix.

Scope

close() only, and only the JSON-response-mode path.

This does not address #2559's first half — JSON-mode mappings leaking one _streamMapping entry per completed POST — so that issue should stay open.

Tests

Three tests in packages/server/test/server/streamableHttp.test.ts under close() with a JSON-mode request in flight.

suite result
packages/server 42 files, 478 tests, all passing
test/e2e 44 files, 2639 passing, 147 expected-fail — including hosting:session:delete-cancels-inflight, which this version leaves intact

Negative control, run against 3924de9's copy of the transport with the new tests kept: both behavioural tests hang to the 5 s test timeout, which is the defect itself. The third (is a no-op when nothing is in flight) passes either way by design.

tsgo -p packages/server/tsconfig.json --noEmit is clean. A changeset is included.

Not verified here

eslint/prettier were not run locally (tooling constraint on my machine). The added lines follow .prettierrc by hand — 4-space, single quotes, arrowParens: avoid, nothing over 140 columns — but CI is the authority.

The v1 line has the same defect and is not touched here. Happy to open the v1.x backport if you want it.

@retif
retif requested a review from a team as a code owner August 21, 2026 00:33
@changeset-bot

changeset-bot Bot commented Aug 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ba3b959

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

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

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

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

@modelcontextprotocol/core

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

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: ba3b959

…TTP transport closes

In JSON response mode, handleRequest() returns a Promise<Response> that
only send() resolves. A stream mapping's cleanup deletes the entry
without settling that promise, so close() while a POST was in flight
left the HTTP request open until the socket died, with the caller unable
to tell whether the request ran.

close() now resolves any pending JSON-mode response before the stream
mappings are torn down, with a JSON-RPC error (-32000, "Connection
closed") per outstanding request id; an id in a batch that already has a
real response reuses it.

The SSE path is left as documented by
hosting:session:delete-cancels-inflight — a POST-initiated stream
closing without a response is cancellation, not a lost reply.
@retif
retif force-pushed the fix/close-answers-inflight-requests branch from 916844b to ba3b959 Compare August 21, 2026 00:39
@retif retif changed the title fix(server): answer in-flight requests when the streamable HTTP transport closes fix(server): settle pending JSON-mode responses when the streamable HTTP transport closes Aug 21, 2026
@claude claude Bot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Aug 21, 2026
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