Skip to content

fix(mcp): a connect whose stdio pipe has failed fails, in both engines, rather than being taken on - #574

Merged
nissa-seru merged 5 commits into
anima-research:mainfrom
nissa-seru:fix/mcp-pipe-failed-while-connecting
Oct 19, 2026
Merged

nissa-seru merged 5 commits into
anima-research:mainfrom
nissa-seru:fix/mcp-pipe-failed-while-connecting

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A stdio MCP server whose pipe fails while it's connecting could be adopted as connected, in both engines: until its child exited, while the pipe failure was still ending it, and for good when the child outlived SIGKILL. This came up while reviewing #566, and was reproduced on main before this change (see Tests).

  • The modern engine. When the server advertises tool list changes, Client.connect opens the list-change subscription, and if that fails, it reports the failure through onerror and resolves anyway (SDK 2.3.1, index.mjs:3449-3458). Suppose the subscribe's write meets EPIPE because the server closed its stdin. The stdio transport then ends the child, SIGTERM and then SIGKILL. A child that dies closes the transport, and establish already fails a connect whose transport closed (feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262's R1-2). A child that outlives SIGKILL never closes it. Its 'unreaped' reaches handleLost, which ignores it while there's no session yet. When the subscribe gives up at the connect's 30 s bound, establish adopts the session. The connection then reads as connected while refusing every call as not sent (The request wasn't sent: this host can no longer write to the "<id>" server, so nothing ran.), and says "This host is reconnecting to it; try again later" when reconnect is on. It retries its subscription at each backoff step, with an 'error' each time, and never holds or reports the launch.
  • The legacy engine, with a narrower trigger. The handshake answers a ping from the server, and that answer can meet EPIPE the same way. A child that outlives SIGKILL reports 'unreaped' before anything listens for it, since the connection adds that listener only once the handshake hands the transport on. An initialize answer that comes after that was adopted, with the same results.

Changes

  • establish fails a session whose stdio pipe has failed, as it fails one whose transport closed. It closes the session, holds a child that the close can't reap, and throws. The modern stdio transport gets a pipeFailure getter, which reads its line's, and the session keeps that transport beside the observed one. The error names the pipe when it failed, MCP server "<id>" stdio pipe failed with <error> while connecting, and otherwise reads closed its connection while connecting, as before.
  • The legacy handshake fails when the transport has found its pipe failed by the time the handshake would hand it on, once the initialize answer has passed its error and version checks. The error reads MCPL server "<id>" stdio pipe failed with <error> during the handshake, with the stderr tail its other failures carry. Its existing cleanup holds a child it can't reap (HandshakeCleanupError), for both of its callers.

Callers now meet a failed connect where they met a connection that couldn't write, with what a failed connect brings: start() or connect() throws, or with reconnect on, connect-failed or reconnect-failed, and a retry. A child that can't be reaped is held as for any failed connect whose launch can't be: either engine's static connect() throws McplUnreapedLaunchError (the modern start() throws the connect's own error, with holdsUnreapedChild true), and a reconnect's failure is permanent. A modern connect that the SDK resolved after the pipe failure had already ended the child read closed its connection while connecting. It now names the pipe too, so a failure there reads one way whether or not the child has exited when the connect resolves.

What each part is for

  • The pipe check in establish (one condition, with the getter and the session field it reads). The connect's end is where the session would be adopted, and the check there covers both orders: a child found unreaped before the connect resolved, whose report nothing held, and a connect that resolves while the child is still being ended, before any report. It reads the pipe rather than the dropped 'unreaped', so which of the two comes first doesn't matter.
    • Not taken: acting on the 'unreaped' that comes during a connect, holding the launch then or ending the connect early. The connect ends within its own bound anyway, and with this change it fails there. The launch is held then rather than up to 30 s sooner, and nothing can use the connection meanwhile, since it's never adopted.
  • The pipe's text first (the message's order). Otherwise a failure that had already ended the child would read as a closed connection, and one that hadn't would read as a failed pipe. Either way, the text names the cause it knows.
  • The legacy check (one condition, after the handshake's await, with failed() moved out of the answer's promise so the check can carry the stderr tail). The answer's error and version checks reject inside the answer's handler, so they come first, and a server's own refusal is what's reported. The check reads the pipe after the await rather than in the handler: a write's EPIPE is reported only once the callback that wrote it has returned, so a ping and the answer read in one callback would pass a check made there. The handshake's own last write, notifications/initialized, comes after the check. Its EPIPE is reported only once the caller has wired the transport, so it reaches the connection as a loss, like any later failure (a probe under Tests shows both orders).
  • The tests' stand-ins. failSubscribeWhen replaces Client.prototype.listen for one test, so the connect-time subscribe ends at the event the test is about, the child found unreaped or the pipe failed, rather than at the connect's 30 s bound, which server/discover shares. The new fixture modes close their stdin so this host's next write meets EPIPE: deaf-at-discover before answering discover, and deaf-ping and deaf-ping-answered at initialize. Those two say so on stderr, then wait for a signal from the test before answering, or before writing the ping and the answer together. The legacy test signals only once that stderr line is in this host's tail, so the tail its error carries is known to hold it, and it checks that the ping and the answer were read in one callback, which is what makes it fail with the check in the handler. deafen() is the deafen tool's existing code, now shared with the new mode. Both fixtures reopen /dev/null on fd 0 right after closing it, the deafen tool's path included, since libuv assumes fds 0 to 2 stay taken. On macOS the modern fixture's child died at that point in CI, and a probe of its sequence (macos-latest, Node 24.21.0 and 20.20.2) was aborted at its first stdout write by libuv's assertion fd > STDERR_FILENO. That line's test is CI's macOS legs, since nothing on Linux fails without it. The two firstLaunch() helpers, and the unreaped that unreapableFrom() now gives, read a launch's transport from its spawn. The legacy helper also closes that transport in its test's cleanup, so a test that fails before its handshake ends doesn't leave the child running.

Tests

  • Probes on main 26653d36, before the change, run outside the suite on Linux with Node 24.22.0, each child's kill stubbed so it outlives SIGKILL:

    • Modern: a server that closes its stdin as server/discover arrives and only then answers, with the SDK's connect timeout set to 8 s for the probe. The transport reported 'unreaped' at 4.2 s, the subscribe gave up at 8.2 s, and start() resolved: isConnected true, holdsUnreapedChild false, no 'close', no permanent failure, one launch. A call was refused as not sent. With reconnect on, it said "This host is reconnecting to it; try again later.", and each retry of the subscription raised an 'error'.
    • Legacy: a server that closes its stdin and pings during the handshake, then answers initialize 5 s later. 'unreaped' came at 4.1 s with nothing listening, and the answer was taken on at 5.2 s. A call was refused with "…This host is reconnecting to it; try again later."
    • Without the stub, both children died on SIGTERM and both connects failed: the modern one with closed its connection while connecting, the legacy one with transport closed before handshake completed (child process exited, and its pipe failed with write EPIPE).
  • When Node reports an EPIPE, from probes outside the suite on Node 24.19.0, three runs each. A write made in a callback has its stdin 'error' reported after the callback returns, after the callback's own nextTicks and before any microtask. A write made inside a microtask has it reported after the microtasks that follow it.

  • The changed files, at this head, on Linux. mcp-modern-outcomes and mcpl-legacy-lifetime pass on Node 24.22.0, 56 and 19 tests. On Node 20.20.2 they pass too, except that mcp-modern-outcomes skips its JSON.rawJSON test, which needs Node 21 or later. The 30 other test files that name either engine's module or class, StdioTransport or either changed fixture pass on Node 24, 678 tests.

  • Red first. With main 2f05c28e's src under this head's tests, exactly the five new tests fail:

    • "a connect whose stdio pipe failed, and whose child was found unreaped before the connect resolved, fails holding the launch, rather than reading as connected";
    • "a connect whose stdio pipe failed fails even when it resolves while its child is still running and unreported: the check reads the pipe";
    • "a connect whose stdio pipe failed reads that way when the failure has already ended the child";
    • "a handshake answered after its stdio pipe failed fails, holding a child that outlived SIGKILL, rather than adopting a connection that can't write to it";
    • "a handshake fails when its answer is read in the same callback as a ping whose answer met EPIPE, and its cleanup reaps the child".

    At 22e5c79d, before main was merged in, with the legacy check made in the initialize answer's handler instead, only the last of these failed, so that test is what pins the check's place.

  • Each piece, removed alone, builds and fails tests at this head:

    • the modern pipe check, leaving only session.lost: the two tests whose child is still running at the check;
    • the words' order, naming a closed connection whenever the session is marked lost too: all three modern tests, since the failed connect's own close marks it lost before the error is built;
    • the hold in the failed connect: the test whose child was found unreaped;
    • the getter, reading no failure: the three modern tests and the existing "a server that closes its stdin and ignores SIGTERM is killed and reconnected, and a call made after the failure is refused as not sent", since send reads the pipe through it;
    • the legacy check: both legacy tests.
  • CI on this head merged with main 2f05c28e (run 39297005666) passes all four Build & Test legs, Ubuntu and macOS each with Node 20 and 24, and the packed install. Each leg type-checks, builds and runs the whole suite with none failing: 5,309 tests on macOS and 5,307 on Ubuntu, of which 28 are skipped on Node 24 and 29 on Node 20. The changelog check passes too (run 39297005616).

Not verified

  • Bun. Under Bun, a write to a child that closed its stdin raises no error (transport.ts), so the pipe doesn't fail this way there, and nothing here ran under Bun.
  • A read error on the child's stdout, the other way a pipe fails, wasn't produced: the tests use stdin's EPIPE. Both set the same pipeFailure (transport.ts).

Out of scope

  • A connect that fails rather than being taken on after its pipe failed keeps main's words. A modern connect the SDK rejects reports the SDK's own error, as when discover's write meets EPIPE: the closed connection if the child dies, or a timeout if it doesn't. A legacy handshake that the child's exit closes names the pipe through the transport's close reason, and one that's never answered times out with initialize handshake timed out. Each of these already fails, and nothing is taken on.

  • Changelog fragment added: changelog.d/mcp-pipe-failed-while-connecting.fixed.md.

Written by Crispin-2406 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5) made the design calls, and Nigel-2654 (Claude Opus 5.5) reviewed it. Two rounds of our internal review read it.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

Crispin-2406 and others added 2 commits October 19, 2026 08:56
…s, rather than being taken on

A stdio MCP server whose pipe failed while it was connecting could be
taken on as connected. In the modern engine, when the server advertises
tool list changes, Client.connect opens the list-change subscription,
reports a failure of it only through onerror, and resolves anyway (SDK
2.3.1). If the subscribe's write met EPIPE, the stdio transport ended
the child. A child that died closed the transport, and establish failed
the connect (anima-research#262's R1-2). One still being ended was adopted until it
exited, and one that outlived SIGKILL was adopted for good: its
'unreaped' came before the subscribe gave up at the connect's 30 s
bound, while there was no session to lose, so handleLost ignored it.
The connection then read as connected, refused every call
as not sent, said the host was reconnecting to it when reconnect was on,
and nothing held or reported the launch. The legacy handshake had the
same gap with a narrower trigger: its answer to a server's ping can meet
EPIPE, a child that outlives SIGKILL reports 'unreaped' before anything
listens for it, and an initialize answer that came afterwards was taken
on.

establish now fails a session whose stdio pipe has failed as it fails
one whose transport closed: it closes the session, holds a child the
close can't reap, and throws. SpawnerStdioTransport gets a pipeFailure
getter that reads its line's, and the session keeps that transport. The
error names the pipe when it failed ("MCP server "<id>" stdio pipe
failed with <error> while connecting") and otherwise reads "closed its
connection while connecting", as before, so a pipe failure reads one
way whether or not the child has exited when the connect resolves.

The legacy handshake reads the pipe once the initialize answer has
passed its error and version checks, after the handshake's await and
before notifications/initialized. A write's EPIPE is reported only once
the callback that wrote it has returned, so a ping and the answer read
in one callback would pass a check made in the answer's handler. It
fails with "MCPL server "<id>" stdio pipe failed with <error> during the
handshake" and the stderr tail its other failures carry, and its cleanup
holds a child it can't reap (HandshakeCleanupError), for the first
connect and a reconnect alike. The handshake's own last write,
notifications/initialized, comes after the check: its EPIPE is reported
only once the caller has wired the transport, so it reaches the
connection as a loss, like any later failure.

A connect that already fails some other way after its pipe failed, the
SDK rejecting it or a legacy close or timeout, keeps main's words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…osed it

On both macOS legs of CI, this pull request's three new modern tests
failed: the SDK reported the connection closed during its server/discover
probe, before the deaf-at-discover child had answered. A probe on
macos-latest (Node 24.21.0 with libuv 1.52.1, and Node 20.20.2 with libuv
1.46.0) found what ends such a child. A child that read its stdin
through readline, destroyed it, closed fd 0 by hand and then wrote
stdout for the first time was aborted by libuv's assertion
fd > STDERR_FILENO in uv__close, on both. That is this fixture's order,
since its discover answer is its first write. Every variant lived on
Linux. On macOS, one that reopened /dev/null right after the close
lived, and so did one that created stdout first.

So both fixtures that close fd 0 by hand reopen /dev/null on it at once,
rather than creating stdout first: it keeps fds 0 to 2 taken, as libuv
assumes for whatever it opens next. They are modern-raw-server's
deafen(), which the deafen tool shares, and legacy-version-server's
deaf-ping modes, which passed on macOS but also close fd 0 by hand before
their first stdout write. fd 0 was the pipe's only reference in the
child, so the parent's next write still meets EPIPE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The change affects stdio connection setup in both MCP engines.

The connection fixes appear safe to merge, with a non-blocking cleanup issue in the new legacy test.

Findings

  1. P2 Failed tests leave children running ▶
Fix with agent prompt
### Issue 1
test/mcpl-legacy-lifetime.test.ts:113
`firstLaunch()` restores `StdioTransport.spawn` but never stops its child. If the stderr wait fails before the test sends `SIGUSR2`, the `deaf-ping-answered` fixture stays alive on its interval. In a focused run with `--test-force-exit`, the test host can exit before the pending handshake times out and closes that child.

Register cleanup that closes the captured transport and waits for the child, even when setup fails.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Both MCP engines now reject a connection when its stdio pipe has already failed.

  • Modern MCP connects reject a failed stdio pipe before adopting the session.
  • Legacy MCP handshakes reject a failed stdio pipe before handing off the connection.

Acknowledged scope: nissa-seru intentionally waits until the bounded connect ends to hold an unreaped launch and leaves notifications/initialized write failures on the existing connection-loss path. nissa-seru also marked Bun behavior and stdout read-error reproduction as unverified.

Tests were inspected but not run during this review.

Reviews (1) · Last reviewed commit: "test(mcp): the deaf fixtures reopen /dev..." · Reviewed by Greptile

});
return transport;
};
cleanups.push(() => { (StdioTransport as unknown as { spawn: typeof StdioTransport.spawn }).spawn = realSpawn; });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Failed tests leave children running

firstLaunch() restores StdioTransport.spawn but never stops its child. If the stderr wait fails before the test sends SIGUSR2, the deaf-ping-answered fixture stays alive on its interval. In a focused run with --test-force-exit, the test host can exit before the pending handshake times out and closes that child.

Register cleanup that closes the captured transport and waits for the child, even when setup fails.

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/mcpl-legacy-lifetime.test.ts
Line: 113

Comment:
**Failed tests leave children running**

`firstLaunch()` restores `StdioTransport.spawn` but never stops its child. If the stderr wait fails before the test sends `SIGUSR2`, the `deaf-ping-answered` fixture stays alive on its interval. In a focused run with `--test-force-exit`, the test host can exit before the pending handshake times out and closes that child.

Register cleanup that closes the captured transport and waits for the child, even when setup fails.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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.

Addressed in 82cd0214: firstLaunch()'s cleanup now closes the transport it captured too, which resolves at once for a child the handshake has already reaped. A test that failed before sending SIGUSR2 left its deaf-ping-answered child running, by our reading until the handshake's own timeout, and under --test-force-exit past the run. On Linux with Node 24.22.0, with the test's stderr wait made to fail, a run under --test-force-exit left one such child without the cleanup and none with it.

Written by Crispin-2406.

— Lottie-2661

Crispin-2406 and others added 3 commits October 19, 2026 12:24
Greptile's review of this pull request found that firstLaunch() in
mcpl-legacy-lifetime restored StdioTransport.spawn but never ended the
child it captured. A test that failed before sending SIGUSR2 left its
deaf-ping-answered child running: by our reading of the handshake, until
the handshake's own timeout, and under --test-force-exit past the run.
The helper's cleanup now closes the captured transport too. close()
shares the transport's one attempt, so it resolves at once for a child
the handshake has already reaped, and a passing run is unchanged.

With the test's stderr wait made to fail, a run under --test-force-exit
left one deaf-ping-answered child running without the cleanup and none
with it (Node 24.22.0, Linux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eir stderr cue

The one-callback legacy test sends SIGUSR2 once the fixture's
"deaf-ping: stdin closed" line is in this host's stderr tail, but the
fixture wrote that line before installing its SIGUSR2 handler. A child
descheduled between the two statements would have been ended by the
signal's default action, and the test would have failed without reaching
the handshake's check. The fixture now installs the handler first, for
both deaf-ping modes, then writes the line, and then deaf-ping sends its
ping as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anima-research#578 changed failed() in the legacy handshake, where it stood inside the
initialize answer's promise, so the tail it puts on an error is
redacted: transport.stderrTailRedacted(serverTextRedactor(config)),
with a comment on why. This branch had moved failed() out of that
promise, so the pipe check after the handshake's await can carry the
tail too. The resolution keeps failed() where this branch put it, with
anima-research#578's body and comment, so the pipe check's error carries the redacted
tail like the handshake's other failures. Every other file merged
without a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit 6d88f04 into anima-research:main Oct 19, 2026
6 checks passed
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 19, 2026
Main has moved since the last merge (815bfe3): anima-research#579 merges 0.23.1
back, anima-research#578 keeps credentials out of host errors, anima-research#581 projects mid-turn
images, anima-research#584 turns Greptile's status comments off, anima-research#561 adds a WebSocket
auth header, anima-research#586 defines dispositions for stubs, anima-research#574 reports an MCP
pipe that fails while connecting, anima-research#583 holds module rows from
residents, and anima-research#585 sends a stream's chunks and complete to each
publish's destination, its server and its channel.

Git merges both sides on its own. One test file needs a change: anima-research#585's
three tests with the real channel registry start their turn from an
external-message that names no channel, which this branch makes a
private turn, so none of its prose is published. Their trigger now names
the channel they stream to, as this branch's other outgoing-stream tests
do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant