Skip to content

stdio server never exits when the client closes stdin — orphaned processes accumulate (232 in 7 days, 5.2 GB RSS) #60

Description

@majkelooo

Description

The stdio server never terminates when the MCP client closes its stdin. Every client disconnect that does not deliver SIGTERM — closing a terminal window, SIGKILL, a client crash, or an MCP server reconnect — leaves an immortal process behind, reparented to launchd (macOS) or init/systemd (Linux).

These accumulate silently. On one workstation I found 232 orphaned email-mcp process pairs (npm exec wrapper + node child) after 7 days of uptime, holding 5.2 GB RSS. They were idle (~0.1% CPU total), but pushing 5 GB of dead weight into a 36 GB machine drove it into 13.3 GB of swap, and the resulting compressor churn pinned kernel_task at 22% CPU with a load average of 68.

The MCP stdio transport contract is that the server exits when the client closes the pipe. This server cannot, because nothing in the process is listening for that event and a timer keeps the event loop alive indefinitely.

Steps to reproduce

Minimal reproduction, no email account needed — this isolates the mechanism to three lines:

// repro_a.mjs — the exact stdin listener shape StdioServerTransport installs
process.stdin.on('data', () => {});
process.stdin.on('error', () => {});
// repro_b.mjs — same, plus the scheduler interval from main.ts
process.stdin.on('data', () => {});
process.stdin.on('error', () => {});
setInterval(() => {}, 60_000);
printf 'x\n' | node repro_a.mjs   # exits on its own when the pipe closes
printf 'x\n' | node repro_b.mjs   # never exits

With the real server — complete the handshake so oninitialized fires and the interval is created, then close stdin:

( printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}'
  sleep 6
  printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
  sleep 6
) | npx -y @codefuturist/email-mcp@0.2.3

# the pipe is now closed; the process is still running
pgrep -af email-mcp

Expected behavior

The process exits shortly after stdin reaches EOF, running the existing shutdown() path so IMAP connections close cleanly.

Actual behavior

The process runs forever. pgrep -af email-mcp still lists it minutes, hours or days later, with PPID=1.

Root cause

Three things have to coincide, and all three currently do:

  1. StdioServerTransport never observes EOF. It subscribes only to 'data' and 'error'sdk/dist/esm/server/stdio.js:37-38:

    this._stdin.on('data', this._ondata);
    this._stdin.on('error', this._onerror);

    No 'end' and no 'close', so transport.onclose never fires in stdio mode.

  2. email-mcp does not compensate. transport.onclose is wired only on the HTTP path (src/main.ts:286), not in runStdioServer. The only reference to stdin anywhere in the package is the TTY check in src/cli/guard.ts.

  3. The scheduler interval pins the event loop. src/main.ts:158:

    schedulerInterval = setInterval(async () => { ... }, 60_000);

    It is never unref()d, so Node has a live handle forever.

The only exits registered are SIGINT and SIGTERM (src/main.ts ~176); there is no SIGHUP handler, and shutdown() does not call process.exit().

Item 3 is the decisive one. Without that timer Node would exit by itself at EOF despite the SDK gap — which is exactly why other servers are unaffected.

Control experiment

On the same machine, chrome-devtools-mcp runs through the same npx launcher and the same MCP SDK, across the same client sessions, and left zero orphans over those 7 days — 21 live processes, every one with a live parent. The difference is not the launcher and not the SDK version; it is the event-loop-pinning timer.

Impact note

Orphans are produced faster than "one per closed session". I observed 23 new orphans in 15 minutes on a machine with 6 interactive sessions, and one session owning two live email-mcp children — so a client-side MCP reconnect orphans the previous instance too.

Possible directions

I have not opened a PR yet because the fix arguably belongs in more than one place, and I would rather hear your preference first:

  • In email-mcp — subscribe to 'end'/'close' on stdin in runStdioServer and run shutdown(), and/or unref() the scheduler interval so it stops pinning the loop. Adding a SIGHUP handler alongside the existing SIGINT/SIGTERM would cover terminal-close on POSIX. Note that attaching a 'data' listener must be avoided — it would switch stdin to flowing mode and consume protocol bytes out from under the SDK.
  • Upstream in the SDK — have StdioServerTransport treat stdin 'end' as a close. That fixes every stdio MCP server at once, but it is a different repo and a wider blast radius.

Happy to prepare a PR against main with a regression test built on the repro_a/repro_b skeleton above (red before, green after) if you tell me which direction you prefer.

Workaround for anyone hitting this now: reap the orphans with a filter that only matches processes already reparented away from a live client, so running sessions are untouched:

ps -Ao pid,ppid,args | grep "npm exec @codefuturist/email-mcp" | grep -v grep \
  | awk '$2==1 {print $1}' | xargs kill -TERM

Possibly related but distinct: #55 concerns heap growth inside a single long-running process, whereas this is about process count. If anyone measured "memory growth over ~6 days" by watching total system memory rather than one PID's RSS, orphan accumulation could confound that reading.

Version

0.2.3

Email provider

Other / Self-hosted — though the bug is provider-independent: it reproduces before any account is contacted, and the three-line repro uses no account at all.

MCP client

Other — Claude Code CLI 2.1.220

Node.js version

v22.22.2

Operating system

macOS 26.5.2 (arm64). The mechanism is not macOS-specific; on Linux the same processes would reparent to init/systemd.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions