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:
-
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.
-
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.
-
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.
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 tolaunchd(macOS) orinit/systemd(Linux).These accumulate silently. On one workstation I found 232 orphaned
email-mcpprocess pairs (npm execwrapper +nodechild) 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 pinnedkernel_taskat 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:
With the real server — complete the handshake so
oninitializedfires and the interval is created, then close stdin: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-mcpstill lists it minutes, hours or days later, withPPID=1.Root cause
Three things have to coincide, and all three currently do:
StdioServerTransportnever observes EOF. It subscribes only to'data'and'error'—sdk/dist/esm/server/stdio.js:37-38:No
'end'and no'close', sotransport.onclosenever fires in stdio mode.email-mcpdoes not compensate.transport.oncloseis wired only on the HTTP path (src/main.ts:286), not inrunStdioServer. The only reference to stdin anywhere in the package is the TTY check insrc/cli/guard.ts.The scheduler interval pins the event loop.
src/main.ts:158:It is never
unref()d, so Node has a live handle forever.The only exits registered are
SIGINTandSIGTERM(src/main.ts~176); there is noSIGHUPhandler, andshutdown()does not callprocess.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-mcpruns through the samenpxlauncher 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-mcpchildren — 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:
email-mcp— subscribe to'end'/'close'on stdin inrunStdioServerand runshutdown(), and/orunref()the scheduler interval so it stops pinning the loop. Adding aSIGHUPhandler alongside the existingSIGINT/SIGTERMwould 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.StdioServerTransporttreat 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
mainwith a regression test built on therepro_a/repro_bskeleton 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:
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.