Skip to content

fix(server): retire in-flight request state when the streamable HTTP transport closes - #2600

Open
rxits wants to merge 1 commit into
modelcontextprotocol:mainfrom
rxits:fix/close-retires-in-flight-request-state
Open

fix(server): retire in-flight request state when the streamable HTTP transport closes#2600
rxits wants to merge 1 commit into
modelcontextprotocol:mainfrom
rxits:fix/close-retires-in-flight-request-state

Conversation

@rxits

@rxits rxits commented Aug 1, 2026

Copy link
Copy Markdown

Motivation and Context

WebStandardStreamableHTTPServerTransport.close() clears _streamMapping and _requestResponseMap, but never clears _requestToStreamMapping. Request ids therefore stay resolvable on a closed transport, which leaks state on two paths.

1. A send() suspended across close() records an undeliverable response.

send() resolves streamId from _requestToStreamMapping before awaiting eventStore.storeEvent(). If close() lands during that await, the resumed send() still holds a stale streamId and falls through to _requestResponseMap.set(requestId, message). For a batched POST the sibling response was already dropped by close(), so allResponsesReady is false forever, the cleanup loop never runs, and the response plus both correlations leak for the lifetime of the process. The closed transport also keeps accepting further responses for those ids and writing them to the event store.

2. JSON-response mode hangs the HTTP request.

In enableJsonResponse mode handleRequest() returns a promise parked in resolveJson until every response is ready. close() runs that stream's cleanup(), which only deletes the map entry — it never resolves. The POST promise never settles, so the request hangs until the platform's own timeout fires.

Both paths also reach NodeStreamableHTTPServerTransport, which is a thin wrapper around this transport.

How Has This Been Tested?

Three tests in packages/server/test/server/streamableHttp.test.ts, each verified to fail before the change and pass after:

  • a send() parked in storeEvent() across close() now rejects and leaves both maps empty
  • a JSON-mode POST parked across close() settles as 404 Session not found instead of hanging
  • a closed transport rejects a response for a retired request id

Full monorepo suite passes (pnpm test:all), plus typecheck and prettier.

Breaking Changes

None. close() and send() keep their signatures. A send() that previously resolved after close() while recording an undeliverable response now rejects with the module's existing No connection established for request ID: … error — the same error a send() issued after close() already produced, so the mid-flight case just becomes consistent with it.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

send()'s new post-await correlation re-check mirrors the re-read of stream that already sits directly above it, and for the same reason: state can change across that await.

…transport closes

close() cleared _streamMapping and _requestResponseMap but never cleared
_requestToStreamMapping, so request ids stayed resolvable on a closed
transport. A send() suspended in eventStore.storeEvent() across a close()
resumed against the retired correlation and recorded a response that could
never be delivered, leaking both the response and its correlation.

close() now clears the correlations, and send() re-checks the correlation
after the storeEvent() await for the same reason the stream is already
re-read there.

close() also settles JSON-response-mode POSTs parked waiting on their
responses, answering 404 Session not found instead of leaving the HTTP
request hanging until the platform's own timeout fires.

Both paths also reach NodeStreamableHTTPServerTransport, which wraps this
transport.
@rxits
rxits requested a review from a team as a code owner August 1, 2026 17:39
@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ba775a

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 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

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

@modelcontextprotocol/core

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

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 9ba775a

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant