stdio MCP servers spawned via launcher commands (bun run, npx) leave orphaned grandchildren after CLI exit — consider killing the process group
Environment
- Claude Code 2.1.206, macOS 26.5 (Apple Silicon), bun 1.3.11
- Headless
-psessions (stream-json) spawned/recycled by an external supervisor; plugin MCP servers from the claude-plugins-official marketplace
Summary
When an MCP server's command is a launcher rather than the server binary itself — e.g. the telegram plugin's
{ "command": "bun", "args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--shell=bun", "--silent", "start"] }
— the CLI's shutdown only reaps its direct child (the launcher). The real server is a grandchild (bun run → bun server.ts); on CLI exit (SIGTERM, crash, supervisor recycle) it reparents to PID 1 and lives on. Well-behaved servers can self-terminate on stdin EOF, but (a) many don't, and (b) even then the launcher process itself is often left behind idling.
Observed at fleet scale
On a host that recycles sessions frequently (context-compaction respawns, idle eviction, supervisor restarts) we accumulated 72 orphaned MCP pairs (144 processes, ~6.9 GB RSS) in a single day, all reparented to PID 1. Some grandchildren also ignored SIGTERM (plugin-side bug, reported separately), so only SIGKILL reclaimed them.
Expected
MCP subprocess lifetime should not depend on every plugin author getting orphan self-detection right. Suggestions, in increasing strength:
- Spawn each stdio MCP server in its own process group (
detached/setsid) and signal the group (kill(-pgid, SIGTERM), escalate to SIGKILL after a grace period) during CLI shutdown. - Do the same reaping on abnormal paths (uncaught exit, SIGTERM/SIGINT of the CLI itself).
- Optionally document that launcher-style commands (
npx,bun run,uv run, shell wrappers) are orphan-prone if 1) is not implemented.
npx/bun run-style commands are the dominant idiom in MCP configs across the ecosystem, so the grandchild-escape pattern is common, not exotic.
Repro
- Register any stdio MCP whose command is a launcher that execs a child (e.g.
bun run ... start,npx some-mcp) - Start
claude -p, confirm the process tree:claude→ launcher → server kill -TERM <claude pid>ps -o pid,ppid,command— launcher and/or server remain with PPID 1
3 Comments
Reproduced on Linux — orphans survived 2 weeks, 119% CPU on a 1-core VPS
Confirming this on Linux (Debian, 1 vCPU / 3.9 GB, bun 1.3.x), so it isn't macOS-specific. The
platform:macoslabel on this and #73814 should probably widen.Why the plugin's own watchdog can't catch it
external_plugins/telegram/server.tshas an orphan watchdog that self-terminates on reparenting:This never fires with the launcher indirection described in this issue. The chain is
claude → bun run wrapper → bun server.ts. Whenclaudedies, the wrapper is the process that gets reparented to PID 1 — and it stays alive. The server's ownppidtherefore still equalsbootPpid(the wrapper), so it concludes it is healthy and polls forever. The watchdog is watching the wrong link in the chain. Killing the process group, as this issue proposes, fixes it; a grandparent-liveness check would too.Observed damage
Six orphaned
bun server.tsprocesses accumulated, the oldest running continuously for 13 days. Combined: ~119% CPU and ~31% of RAM. Load average on the single core was 13.1, with 50% CPU steal as the hypervisor throttled the VM. After killing them: load 0.19, steal 0%, ~1.1 GB RAM returned.The CPU burn is a second-order effect worth calling out, because it makes this leak much more expensive than a plain memory leak. Telegram allows only one
getUpdatesconsumer per bot token, so every orphan past the first gets HTTP409 Conflictand hot-loops on retry. The plugin's startup path kills the one stale poller recorded inbot.pid, but any earlier orphan that already lost the pid-file race is never reaped and spins indefinitely.They also ignore SIGTERM
All six survived
kill -TERMdespiteserver.tsregistering a SIGTERM handler with a 2s force-exit; onlykill -9cleared them, which suggests the event loop is wedged in the retry loop. So a supervisor-side process-group kill needs to escalate toSIGKILLrather than assume graceful shutdown.Stale
.in_use/<pid>lock files for long-dead Claude PIDs also accumulate in the plugin cache dir and are never reaped.Confirming this on Windows 11 with measured numbers, plus a pointer to a fix that already landed elsewhere for the exact same root cause.
Observed
Single machine, Windows 11, multiple concurrent Claude Code sessions (~27 open over a working day). Snapshot taken ~5h after boot:
| | count | private RAM |
|---|---|---|
|
node.exe(total) | 296 | 29.0 GB ||
node.exefrom one single MCP server (chrome-devtools-mcp@1.6.0vianpx) | 99 | 11.4 GB ||
cmd.exe| 224 | 0.9 GB |Of those 99, 68 are independent root launches — verified by parentage, not guessed: each has its own
cmd.exeparent, and the chain is exactly the launcher pattern described in this issue:Walking up the ancestor chain, 66 of the 68 roots resolve to a live
claude.exe.Growth rate: ~60 new processes per hour, monotonic. Nothing reaps them.
They are not doing work
This is what makes it clearly a leak rather than legitimate usage:
--remote-debugging/--headless).Why the spec-prescribed shutdown isn't enough on Windows
The MCP stdio lifecycle prescribes: close stdin → wait →
SIGTERM→SIGKILL. That is a POSIX model and it terminates one PID. Windows has no POSIX signal semantics and no automatic parent→descendant propagation:TerminateProcess()affects only the target. Sincenpxon Windows resolves through a.cmdshim, the process the client spawned and controls (cmd.exe) is not the process doing the work (node.exe), so killing the top-level PID orphans the actual server.The fix already exists upstream
The official MCP Python SDK hit this exact bug and fixed it in modelcontextprotocol/python-sdk#850. Root cause quoted in that PR:
Their fix:
start_new_session=Trueon POSIX (kill the process group) plus tree termination viataskkill /Ton Windows.Same mechanism confirmed independently in:
launcher.cmd → cmd.exe → node.exechain: "On Windows, terminating the launcher does not cascade to its descendants... these are left orphaned and alive after termination." Suggests Windows Job Objects withJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEas the race-free alternative totaskkill.taskkill /pid PID /T /Fon Windows, process-group kill on POSIX.So the "consider killing the process group" in this issue's title is exactly right, and there is a validated reference implementation in the MCP org's own SDK.
Note on related closed issues
#11502 (Windows, 60+ zombie
node.exe) is essentially this report from November 2025 — closed as duplicate without a linked fix. #66280 was closednot planned. The two lazy-load requests (#42220, #23410) were also closed as duplicates, so there is currently no way to avoid paying the full MCP server set per session either.Right now there is no official cleanup command and no documented way to share servers between sessions, so the only mitigation available to users is killing process trees manually — which does not scale when the count grows by ~60/hour.
Confirmed — reproduced on 2.1.233 on Linux. This is a real bug, and long-standing rather than a recent regression.
What I ran: an MCP config whose
commandis a launcher (sh -c 'node server.js; :') pointing at a minimal stdio MCP server that ignores stdin EOF, thenclaude -p --mcp-config cfg.json --strict-mcp-config "list your tools". The server connected and its tool was listed.Observed after exit:
command, no launcher): cleaned up correctly in every case, even though the server ignores stdin EOF.So shutdown currently terminates only the direct child; anything the launcher spawned is never signaled. Your process-group suggestion is the right shape for the clean-exit and SIGTERM cases (SIGKILL inherently also needs the supervisor to kill the group, or a post-hoc sweep). We're tracking this as a bug.
🤖 Generated with Claude Code