Skip to content

Bug: StdioServerTransport doesn't handle stdin close/end — causes zombie process accumulation #2002

Closed

Description

@ElliotDrel

Summary

StdioServerTransport does not listen for stdin close or end events. When an MCP client closes its window or restarts, it drops its end of the stdio pipe — but the server process never detects this and keeps running indefinitely. Over time, with multiple client sessions, this causes unbounded zombie process accumulation.

The bug

In src/server/stdio.ts, the start() method attaches listeners for data and error, but not for close or end:

// src/server/stdio.ts — start() method
this._stdin.on('data', this._ondata);
this._stdin.on('error', this._onerror);
// ← no 'close' or 'end' listener

When the MCP client (e.g. Claude Code) closes its window, it closes its end of the stdin pipe. Node.js fires stdin.on('close') and stdin.on('end') on the server side — but since nothing is listening, the server process keeps running forever.

Real-world impact

Tested with Claude Code (3 concurrent windows) using two MCP servers built on this SDK:

  • After one day of normal use: 37 orphaned google-tools-mcp processes, oldest consuming 26,807 CPU-seconds
  • 9 orphaned gbrain processes after a few session restarts
  • Machine became unresponsive; only manual taskkill cleared them

This is reproducible: close any Claude Code window and the MCP server process it spawned will remain running until the machine reboots.

Why SIGTERM doesn't help

The common suggestion is "handle SIGTERM" — but this doesn't solve it:

  1. On Windows, SIGTERM is not delivered to child processes when a parent exits. Orphaning is guaranteed on Windows regardless of signal handlers.
  2. On all platforms, when a stdio MCP client closes its connection (not the whole process), no signal is sent at all — only the pipe closes. The stdin close/end event is the only reliable cross-platform shutdown signal.

Note: PR #1568 addressed stdout EPIPE errors (client abruptly disconnecting mid-write), but left stdin disconnection unhandled.

The fix

In StdioServerTransport.start(), add two listeners:

async start(): Promise<void> {
  if (this._started) {
    throw new Error("StdioServerTransport already started! ...");
  }
  this._started = true;
  this._stdin.on("data", this._ondata);
  this._stdin.on("error", this._onerror);

  // Exit when the MCP client closes the pipe (window closed, session restarted, etc.)
  // This is the only reliable cross-platform shutdown signal for stdio servers.
  this._stdin.on("close", () => this.onclose?.());
  this._stdin.on("end", () => this.onclose?.());
}

And in close(), clean them up:

async close(): Promise<void> {
  this._stdin.off("data", this._ondata);
  this._stdin.off("error", this._onerror);
  this._stdin.off("close", this._onclose);  // add cleanup
  this._stdin.off("end", this._onend);      // add cleanup
  // ... rest of existing close() logic
}

Impact

This is a universal issue — every stdio MCP server built on this SDK is affected. Fixing it here protects all downstream servers without requiring changes in each one individually. The fix is two lines in start().

Environment

  • SDK version: 1.29.0 (latest)
  • OS: Windows 11 (worst case — SIGTERM not delivered), but reproducible on all platforms
  • MCP client: Claude Code
  • Affected servers confirmed: google-tools-mcp, gbrain, and any other server using StdioServerTransport

Activity

  1. ElliotDrel commented on May 1, 2026

    @ElliotDrel
    Author

    Related issues tracking similar stdio/process lifecycle problems:

    Same root cause (stdin/process shutdown):

    Related upstream fix:

    Similar PR attempts:

  2. louis198617 commented on May 17, 2026

    @louis198617

    Independent reproduction on a different MCP server — confirms this is widespread, not isolated to one project.

    • Server: obsidian-mcp v1.0.6 (bundles @modelcontextprotocol/sdk; also reproduced against the bundled dist/esm/server/stdio.js in standalone SDK v1.29.0)
    • Client: Claude Code (stdio transport)
    • Platform: macOS 15 (Darwin 25.3.0), Node v24.14.1
    • Damage: 7 zombie obsidian-mcp processes accumulated over ~5 days of normal use, each holding vault file handles + memory. Only pkill -9 cleared them.

    Minimal repro (works against any SDK-based stdio server):

    obsidian-mcp /path/to/vault < /dev/null &
    PID=$!
    sleep 2
    kill -0 $PID && echo "alive — should have exited on stdin EOF"

    The 2-line patch proposed in #2003 (stdin.on("end" | "close", ...)), applied to the bundled SDK code inside obsidian-mcp/build/main.js, fixes the leak immediately — verified ✅ — and survives session restart cleanly with zero accumulation.

    This affects every third-party Node MCP server in the wild. Would be great to see #2003 land soon.

  3. added
    triageQueued for automated analysis — bot will process and remove this label
    on Jul 9, 2026
  4. ghost added
    bugSomething isn't working
    on Jul 9, 2026
  5. ghost added
    ready for workEnough information for someone to start working on
    on Jul 9, 2026
  6. ghost added
    P1Significant bug affecting many users, highly requested feature
    on Jul 9, 2026
  7. ghost added
    fix proposedBot has a verified fix diff in the comment
    on Jul 9, 2026
  8. ghost removed
    triageQueued for automated analysis — bot will process and remove this label
    on Jul 9, 2026
  9. harshmathurx commented on Jul 9, 2026

    @harshmathurx

    I can take this one. I have a small focused change locally to close the server stdio transport on stdin close/end, with unit coverage; will open a PR once the package checks pass.

  10. lzrong0203 commented on Jul 17, 2026

    @lzrong0203

    Additional independent confirmation on the published @modelcontextprotocol/sdk 1.29.0 dist (Linux, Node v24.13.0), with two data points not yet in this thread:

    1. onclose never fires even when the process does exit. A control run without any app-owned handles (NO_KEEPALIVE=1, minimal server, < /dev/null for an immediately-EOF'd stdin) exits in ~0.27 s — but purely by event-loop drain: onclose still never fires, on either transport.onclose or server.onclose (both wired post-connect() to observe Protocol's chain). So handle-free servers only appear healthy — their teardown code never runs either. Worth keeping in mind for review: the fix needs to route EOF through close() so the onclose contract fires (which #2494 does, including the end-only and idempotency tests).

    2. Long-duration memory dimension (Linux). 12 orphaned discord-mcp server processes accumulated over ~3 weeks of normal client use, each kept alive by its Discord gateway socket, at 0.8–2.1 GB RSS each (~15 GB total) — the RSS grows the longer an orphan stays connected to its upstream service. This complements the Windows CPU-seconds and macOS reports above: the damage scales with orphan age, not just count.

    Downstream, servers are having to ship the entry-point workaround themselves in the meantime (e.g. PaSympa/discord-mcp#83) — another reason a transport-level fix like #2494 is the right layer.

  11. majkelooo commented on Aug 6, 2026

    @majkelooo

    Field data plus one observation that may matter for whichever fix lands here.

    The ref'd handle is the necessary condition, not the SDK gap alone. Minimal repro, three lines:

    // a.mjs — the listener shape StdioServerTransport installs today
    process.stdin.on('data', () => {}); process.stdin.on('error', () => {});
    // b.mjs — same, plus one non-unref'd interval
    process.stdin.on('data', () => {}); process.stdin.on('error', () => {}); setInterval(() => {}, 60_000);

    printf 'x\n' | node a.mjs exits on its own at EOF; printf 'x\n' | node b.mjs never does.

    That explains why this bug looks intermittent across the ecosystem. On one workstation, chrome-devtools-mcp and @codefuturist/email-mcp ran side by side through the same npx launcher and the same SDK version, across the same client sessions over 7 days. The former left zero orphans; the latter left 232 process pairs holding 5.2 GB RSS, which pushed the machine into 13.3 GB of swap and pinned kernel_task at 22% CPU. The difference was not the SDK or the launcher — it was that one of them holds a timer that keeps the event loop from draining. Servers with no such handle exit at EOF anyway and never report the problem.

    Fixing the transport here is necessary but may not be sufficient downstream. email-mcp wired transport.onclose only on its HTTP path, so even once this transport starts emitting onclose at EOF, that server would still not have exited. I sent them a downstream fix (codefuturist/email-mcp#61) and it needed its own stdin handling regardless of what happens here. If the goal is that the ecosystem actually stops leaking, it might be worth deciding whether the transport should do more than fire onclose — or at least saying plainly in the stdio docs that a server holding ref'd handles must act on it.

    Worth noting the spec already assigns the expectation, in basic/lifecycle under Shutdown: the client SHOULD initiate shutdown by "first, closing the input stream to the child process (the server)", then by "waiting for the server to exit" before escalating to SIGTERM and SIGKILL. A server that cannot exit on EOF turns step 2 into a guaranteed wait for the timeout.

    No opinion offered on which of #2003 / #2473 / #2494 is the right shape — just adding the measurements and the downstream half in case they help the decision.

  12. chanwoonglee commented on Aug 20, 2026

    @chanwoonglee

    Another field data point, plus a mechanism that I don't think is in this thread yet: the missing end/close listener doesn't just leak idle zombies — combined with a very common server-side handler shape, it produces processes that peg a full CPU core indefinitely.

    • Server: @suncreation/mcp-google-docs@1.3.5 (fastmcp 3.35.0 → @modelcontextprotocol/sdk@1.30.0, current latest)
    • Client: Claude Code (stdio)
    • Platform: macOS 14.7 (Darwin 23.6.0), Node v22.14.0

    Field data

    Five orphans, each at ~100% CPU, oldest running 9d 04h:

      PID  %CPU     RSS  TTY   ELAPSED     COMMAND
     3216  99.0  713984   ??   03-01:27:17 node .../mcp-google-docs
    23187 110.7  835232   ??   03-00:29:57 node .../mcp-google-docs
    26416 100.0  494688   ??   09-04:19:31 node .../mcp-google-docs
    71394 100.1  862080   ??   09-00:04:07 node .../mcp-google-docs
    91568  99.8 2058M(*) ??   09-13:13:29 node .../mcp-google-docs   (*) RSS at time of kill
    

    ~500% aggregate CPU on a laptop. Peak RSS 2 GB with 108M page faults — the memory grows monotonically because each loop iteration allocates a fresh Error plus its serialized stack.

    lsof confirms the trigger — fds 0/1/2 are socketpairs whose peer is gone:

    node 3216  0u  unix 0xe1c12ec7edd7440  ->(none)
    node 3216  1u  unix 0x5bbb0a53bffeb91f ->(none)
    node 3216  2u  unix 0xf0787f7756e872d4 ->(none)
    

    Mechanism: EPIPE ↔ uncaughtException self-feeding loop

    The server installs the shape that a lot of MCP servers install, precisely because the SDK gives them no disconnect signal to hook:

    process.on('uncaughtException', (error) => {
      console.error('Uncaught Exception:', error);   // writes to the dead stderr
      // Don't exit process, just log the error and continue
    });

    Once the host is gone, this closes a cycle: write to dead stderr → EPIPE → no 'error' listener on process.stderr → uncaughtException → the handler's own console.error → EPIPE → … Nothing ever breaks it, because the handler's entire purpose is to not exit.

    /usr/bin/sample on a live orphan, confirming the cycle rather than inferring it:

    uv_run
     └ uv__run_check → node::Environment::CheckImmediate
        └ v8::internal::Isolate::ReportPendingMessages
           └ node::errors::TriggerUncaughtException        ← handler invoked
              └ node::inspector::InspectorConsoleCall      ← console.error inside the handler
                 ├ v8::internal::Accessors::ErrorStackGetter
                 │  └ ErrorUtils::FormatStackTrace → SerializeCallSiteInfo
                 │     └ CallSiteInfo::GetLineNumber → SourcePositionTableIterator::Advance   ← most samples land here
                 └ node::StreamBase::WriteString
                    └ node::LibuvStreamWrap::DoTryWrite    ← write to the dead socket
    

    Worth noting where the CPU actually goes: not the failing write(2), but V8 stack-trace serialization. Formatting error.stack every tick is what turns this from a spin into a hot spin, and it's also the allocation source behind the RSS growth.

    Control experiment

    Same parent-dies scenario, one variable changed:

    // child.js — the listener shape StdioServerTransport installs today
    process.stdin.on('data', () => {});
    setInterval(() => console.error('heartbeat'), 50);
    // (+ the uncaughtException handler above, for the treatment run)
    outcome
    with the uncaughtException handler 100% CPU indefinitely, RSS 94 MB → 107 MB in 12 s and climbing
    without it (control) exits immediately on EPIPE

    So the handler is necessary for the hot spin, and the SDK gap is necessary for the process to still be alive to hit it.

    Why this might matter for which fix lands

    Reading #2003 / #2473 / #2494, they all correctly make stdin EOF reach close(). That fixes this too, so I'm not arguing for a different patch — just adding two things I think are worth weighing:

    1. The severity ceiling here is higher than "zombie accumulation". An idle zombie is a resource leak you notice eventually; a 100%-CPU spinner on a laptop is thermal throttling and battery drain within hours. That may be worth reflecting in how this is prioritized.

    2. The uncaughtException shape is arguably induced by the gap. Server authors add "log and never exit" handlers because the transport gives them no clean disconnect signal and they're trying to stop the server from dying on the client's behalf. Fixing Bug: StdioServerTransport doesn't handle stdin close/end — causes zombie process accumulation #2002 removes the reason to write that handler, but servers that already have one will keep spinning until they update. If the eventual fix also handled stderr/stdout EPIPE inside the transport (cf. the closed fix: handle EPIPE errors in StdioServerTransport gracefully #1644 / fix: handle EPIPE and ERR_STREAM_DESTROYED in StdioServerTransport #1679 / fix(server): handle stdout errors in StdioServerTransport (v1.x backport of #1568) #2579), it would defuse those servers without requiring them to change. Fully understand that's a separate concern from this issue — flagging it because the two combine here rather than acting independently.

    Happy to re-run anything or share the full sample output if useful.

  13. SamGu-NRX commented on Aug 24, 2026

    @SamGu-NRX

    Downstream severity data: this can produce a deterministic V8 OOM, not only idle zombie accumulation.

    Convex 1.45.0 uses @modelcontextprotocol/server 2.0.0 serveStdio(..., { legacy: "serve" }). On macOS 26.5.1 with Node 26.5.0, a bounded harness initializes the server, sends resources/list, and closes the peer ends of stdin/stdout/stderr before the error response completes. With a 128 MB old-space limit, the child aborts from OOM in under one second. Production incidents reached 3.1–4.5 GB per child and show InspectorConsoleCall → TriggerUncaughtException → RunTimers, matching the reproduction.

    The same harness exits normally on Convex 1.44.0. Convex 1.45.0 also exits without OOM when there is no in-flight error response, so stdin EOF is one necessary part of the failure but the closed-output error path is the severity multiplier. The likely downstream loop is stdout failure → host onerror → write to closed stderr → uncaught EPIPE → console.error to closed stderr.

    Full reproduction and controls: get-convex/convex-js#186. This suggests the fix should both close on stdin EOF and avoid routing persistent transport-write failures back through error handling after stdio is already dead.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Significant bug affecting many users, highly requested featurebugSomething isn't workingfix proposedBot has a verified fix diff in the commentready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions