Skip to content

Lifecycle guard stdin listeners cause spurious MCP -32000 Connection closed #236

@ponythewhite

Description

@ponythewhite

Bug Description

The lifecycle guard (src/lifecycle.ts) added in #103 to prevent orphaned processes registers end/close/error listeners on process.stdin and calls process.stdin.resume(). These conflict with the MCP SDK's StdioServerTransport, which also reads from process.stdin via readline. When stdin emits a transient event, the lifecycle guard interprets it as parent death and calls process.exit(0) immediately — killing the server mid-request.

The client (Claude Code) then receives:

Error: MCP error -32000: Connection closed

This is intermittent and difficult to reproduce deterministically, but occurs frequently during normal sessions — often multiple times per hour.

Root Cause

In src/lifecycle.ts:57-63:

const onStdinClose = () => shutdown();
process.stdin.resume();
process.stdin.on("end", onStdinClose);
process.stdin.on("close", onStdinClose);
process.stdin.on("error", onStdinClose);

Two problems:

  1. process.stdin.resume() puts stdin into flowing mode before StdioServerTransport sets up its own readline interface (the guard starts at server.ts:2103, transport at server.ts:2105). This can cause data loss or buffering issues during the transport handshake.

  2. end/close/error on stdin — any transient pipe event triggers an immediate gracefulShutdown()process.exit(0) with no debounce or grace period. In a stdio MCP setup, the transport legitimately manages stdin flow; transient events do not necessarily indicate parent death.

The gracefulShutdown() function (server.ts:2094-2097) also does not await transport.close() before exiting, so any in-flight JSON-RPC request is abruptly terminated.

Suggested Fix

Remove the stdin-based shutdown detection from the lifecycle guard entirely. The remaining mechanisms are sufficient:

  • ppid polling (every 30s) — reliably detects parent death via reparenting to init/systemd/launchd
  • SIGTERM/SIGINT/SIGHUP signal handlers — catch intentional shutdown

The stdin listeners are redundant in an MCP stdio context because the transport already handles stdin lifecycle, and they're harmful because they create false-positive shutdowns.

Minimal diff

--- a/src/lifecycle.ts
+++ b/src/lifecycle.ts
@@ -54,13 +54,6 @@ export function startLifecycleGuard(opts: LifecycleGuardOptions): () => void {
   }, interval);
   timer.unref();

-  // P0: Stdin close — parent pipe broken
-  // Must resume stdin to receive close/end events (Node starts paused)
-  const onStdinClose = () => shutdown();
-  process.stdin.resume();
-  process.stdin.on("end", onStdinClose);
-  process.stdin.on("close", onStdinClose);
-  process.stdin.on("error", onStdinClose);
-
   // P0: OS signals — terminal close, kill, ctrl+c
   const signals: NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
@@ -70,9 +63,6 @@ export function startLifecycleGuard(opts: LifecycleGuardOptions): () => void {
   return () => {
     stopped = true;
     clearInterval(timer);
-    process.stdin.removeListener("end", onStdinClose);
-    process.stdin.removeListener("close", onStdinClose);
-    process.stdin.removeListener("error", onStdinClose);
     for (const sig of signals) process.removeListener(sig, shutdown);
   };
 }

Environment

  • context-mode: v1.0.75
  • @modelcontextprotocol/sdk: ^1.26.0 (resolved to 1.29.0)
  • Node.js: v22.22.1
  • Platform: Linux 6.17.0
  • Client: Claude Code (CLI)

Workaround

Manually patch server.bundle.mjs in the plugin cache to remove the stdin listeners from the minified lifecycle guard function (Nx). The patch survives session restarts but is overwritten on plugin updates.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions