Skip to content

fix(server): tear down the request stream when an event store write rejects - #2609

Draft
rxits wants to merge 2 commits into
modelcontextprotocol:mainfrom
rxits:fix/storeevent-rejection-teardown
Draft

fix(server): tear down the request stream when an event store write rejects#2609
rxits wants to merge 2 commits into
modelcontextprotocol:mainfrom
rxits:fix/storeevent-rejection-teardown

Conversation

@rxits

@rxits rxits commented Aug 3, 2026

Copy link
Copy Markdown

Motivation and Context

send() awaits the user-supplied eventStore.storeEvent() before writing a response to the per-request SSE stream. That await is unguarded, so a rejected write propagates straight out of send() and skips every teardown below it:

  • the entry in _streamMapping survives, so the stream is never closed
  • the correlation in _requestToStreamMapping survives, so the request id is never retired
  • the keep-alive timer stays armed and keeps writing : keepalive comment frames
  • the HTTP response body is never closed

The client is left holding a stream that will never carry its response while the server keeps the request alive indefinitely. Every failed write leaks another one. An event store is user-supplied infrastructure — a transient database or network failure is an expected condition, not a programming error.

This follows the same principle as the existing shutdown-path handling: teardown must not be skippable by a throw from a user-supplied callback (#1735, #1763).

Changes

send() wraps the storeEvent() call. On rejection it closes the request's stream via the existing cleanup() and retires every correlation and recorded response for that stream, then rethrows. send() still rejects with the original error, so callers continue to see the failure — they just no longer leave a leaked stream behind.

How Has This Been Tested?

New test in packages/server/test/server/streamableHttp.test.ts uses an event store that always rejects, asserts send() rejects with the store's error, asserts all three internal maps are empty, and asserts the SSE body is terminated rather than held open by the keep-alive timer. Verified to fail before the change and pass after.

Full server suite passes (469 tests), plus typecheck and prettier.

Breaking Changes

None. send() rejects with the same error it did before.

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

Also reaches NodeStreamableHTTPServerTransport, which wraps this transport.

…ejects

send() awaits the user-supplied eventStore.storeEvent() before writing a
response to the per-request SSE stream. A rejection propagated straight
out of send(), skipping every teardown below it: the stream mapping and
the request correlation stayed in their maps, the keep-alive timer stayed
armed, and the HTTP response body was never closed. The client waited on
a stream that would never carry its response and the server held the
request forever.

The write is now wrapped so a rejection retires the request and closes
its stream before rethrowing. send() still rejects, so callers continue
to see the failure.
@changeset-bot

changeset-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 996b709

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

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

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

@modelcontextprotocol/core

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

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 996b709

for...of iterates a Map directly; the array copy was unnecessary and
tripped unicorn/no-useless-spread. Deleting entries during Map iteration
is well-defined, and the entries removed here are the ones the loop
intends to retire.
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