Skip to content

fix(server): handle stdout errors in StdioServerTransport (v1.x backport of #1568) - #2579

Draft
ondraulehla wants to merge 1 commit into
modelcontextprotocol:v1.xfrom
ondraulehla:fix/stdio-server-epipe-v1x
Draft

fix(server): handle stdout errors in StdioServerTransport (v1.x backport of #1568)#2579
ondraulehla wants to merge 1 commit into
modelcontextprotocol:v1.xfrom
ondraulehla:fix/stdio-server-epipe-v1x

Conversation

@ondraulehla

@ondraulehla ondraulehla commented Jul 29, 2026

Copy link
Copy Markdown

I opened this as a draft because a second non-draft PR from me is refused while #2552 is open. The change itself is finished and tested, so please read it as ready for review.

Backport of #1568 to v1.x. StdioServerTransport there attaches an 'error' listener to stdin but not to stdout, so a stdio client that goes away turns the server's next write into an unhandled 'error' event.

Motivation and Context

#1564 reported this as a fatal process crash and #1568 fixed it on main in March, but the port never happened, so the released @modelcontextprotocol/sdk@1.30.0 still ships the old shape: dist/cjs/server/stdio.js registers only the stdin listener, and send() resolves on 'drain' with no failure path.

I reproduced it against the published package rather than a synthetic script. A server built on sdk@1.30.0 whose client stops reading and closes the pipe dies with Unhandled 'error' event and exit code 1; the same server built from this branch keeps running with nothing on stderr. For scale, the v1 package sits at roughly 47M weekly downloads against 230k for the v2 server package, so the line without the fix is the one nearly everyone runs.

The send() half matters while the server is alive: a destroyed stream never drains, so a backpressured write that fails leaves the promise pending instead of rejecting. That is the same defect I reported for the client transport in #2552.

This is a faithful port rather than a new design. The _closed field, the _onstdouterror handler, the stdout listener in start(), the idempotency guard and listener removal in close(), and the settled flag in send() are the same code as the merged v2 version, adapted to the v1 file layout and its plain Error usage.

How Has This Been Tested?

  • The five tests fix: handle EPIPE errors in StdioServerTransport to prevent process crash #1568 added are ported to test/server/stdio.test.ts. Against the current v1.x source all five fail, including should reject send() when stdout errors before drain, which times out instead of rejecting. With this change all ten tests in that file pass.
  • After a stdout error the transport closes, send() rejects with StdioServerTransport is closed, and no 'error' or 'drain' listeners are left on the stream.
  • Full v1 suite on Ubuntu with Node 24: 77 files, 2759 tests green. npm run typecheck and npm run lint are clean, changeset included.

Breaking Changes

None for a working client. Two failures that used to be silent now surface: a failed write rejects send() instead of leaving the promise pending, and send() after close() rejects instead of writing to a stream the transport has released. Both match the v2 behaviour from #1568. On a stdout error the transport closes itself and leaves the exit decision to the application, as on main.

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

A stdio client that disconnects makes the server's next write fail with EPIPE,
and nothing listened for 'error' on stdout, so that became an unhandled 'error'
event and took the whole Node process down. Listen for it, report it through
onerror and close the transport instead.

send() also waited for a 'drain' event that a destroyed stream never emits. It
now settles from an error listener armed before the write, removes both
listeners on every exit path, and rejects after close() instead of writing to a
stream the transport has released. close() is idempotent, so an error-triggered
close followed by an explicit one fires onclose once.

Backport of modelcontextprotocol#1568 to the v1.x line.
@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f316cc2

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

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 Jul 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@modelcontextprotocol/sdk@2579

commit: f316cc2

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