[BUG] Stdio MCP servers still connecting (or queued) at session end are never terminated and are left orphaned
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet (closest: #1935, #22612, #11778 — see "Additional Information")
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code (2.1.266)
What's Wrong?
When a session ends, Claude Code only terminates the stdio MCP servers that are already in the connected state. Servers that are still connecting (child process spawned, initialize not yet answered) and servers still waiting in the connection queue (Claude Code starts stdio servers three at a time) are never signalled. The CLI runs its SessionEnd hooks, writes ~/.claude.json and exits normally, and those child processes are left behind, reparented to launchd (PPID 1).
This happens both when the CLI is asked to stop (SIGTERM) and when it simply finishes on its own. With more than three stdio servers the queue makes it worse: the 4th+ server starts only after an earlier one connects or times out, so it is much more likely to still be "connecting" when the session ends.
The real-world trigger on my machine needs no user action at all: after an extension-host restart (VS Code window reload, extension update), the VS Code extension spawns a CLI, ends that session about 2 s later, and spawns a fresh one. Every time, the servers still connecting at that moment were orphaned. The extension log shows it three times on 2026-09-09 (18:53, 18:54:55, 19:34:55); the two occurrences I traced end to end (18:54:55 and 19:34:55) each left three orphaned dab (Data API builder) processes. Because DAB has its own bug that makes an orphaned --mcp-stdio process spin at 200–900% CPU after stdin EOF (Azure/data-api-builder#3806), each occurrence pegged several cores until the processes were killed by hand — but the orphaning itself is a Claude Code problem and is reproducible with plain sleep as the "server" (below).
The kill path for connected servers works well and fast (SIGINT → 100 ms → SIGTERM → 400 ms → SIGKILL, all logged; children gone within ~0.6 s of the CLI receiving SIGTERM). Only the connecting/queued servers are missed.
What Should Happen?
Session end should terminate every child process the session spawned, whatever its connection state: connecting servers should be killed and the pending connection queue cancelled, using the same SIGINT/SIGTERM/SIGKILL escalation that already exists for connected servers.
Error Messages/Logs
VS Code extension log (~/Library/Application Support/Code/logs/<ts>/window1/exthost/Anthropic.claude-code/Claude VSCode.log), the 19:34:55 occurrence. Six stdio servers (mssql-*, each a dab start --mcp-stdio ... process) plus one npx server (azure) are registered. Note how mssql-dev-* and azure start from the queue after the shutdown of mssql-local-* has begun, and never get a kill line; the CLI then completes SessionEnd and the extension spawns the next CLI. Paths shortened.
19:34:55.740 Spawning Claude with SDK query function (version 2.1.266)
19:34:56.436 MCP server "mssql-local-platform": Starting connection with timeout of 30000ms
19:34:56.439 MCP server "mssql-local-my": Starting connection with timeout of 30000ms
19:34:56.443 MCP server "mssql-local-sg": Starting connection with timeout of 30000ms
19:34:57.496 MCP server "mssql-local-platform": Successfully connected (transport: stdio) in 1061ms
19:34:57.497 MCP server "mssql-dev-platform": Starting connection with timeout of 30000ms <- queued server starts
19:34:57.994 MCP server "mssql-local-platform": Sending SIGINT to MCP server process <- session end begins
19:34:58.095 MCP server "mssql-local-platform": SIGINT failed, sending SIGTERM to MCP server process
19:34:58.176 MCP server "mssql-local-my": Successfully connected (transport: stdio) in 1737ms
19:34:58.177 MCP server "mssql-dev-my": Starting connection with timeout of 30000ms <- starts during shutdown
19:34:58.225 MCP server "mssql-local-sg": Successfully connected (transport: stdio) in 1782ms
19:34:58.226 MCP server "mssql-dev-sg": Starting connection with timeout of 30000ms <- starts during shutdown
19:34:58.496 MCP server "mssql-local-platform": SIGTERM failed, sending SIGKILL to MCP server process
19:34:58.504 MCP server "mssql-local-platform": STDIO connection closed after 1s (cleanly)
19:34:58.507 MCP server "mssql-local-my": Sending SIGINT to MCP server process
19:34:58.507 MCP server "mssql-local-sg": Sending SIGINT to MCP server process
19:34:58.608 MCP server "mssql-local-my": SIGINT failed, sending SIGTERM to MCP server process
19:34:58.608 MCP server "mssql-local-sg": SIGINT failed, sending SIGTERM to MCP server process
19:34:58.905 MCP server "mssql-dev-platform": Successfully connected (transport: stdio) in 1409ms
19:34:58.906 MCP server "azure": Starting connection with timeout of 30000ms <- starts during shutdown
19:34:59.009 MCP server "mssql-local-my": SIGTERM failed, sending SIGKILL to MCP server process
19:34:59.009 MCP server "mssql-local-sg": SIGTERM failed, sending SIGKILL to MCP server process
19:34:59.015 MCP server "mssql-local-my": STDIO connection closed after 0s (cleanly)
19:34:59.015 MCP server "mssql-local-sg": STDIO connection closed after 0s (cleanly)
19:34:59.025 SessionEnd:other completed with status 0
19:34:59.100 SessionEnd:other completed with status 0
(no kill line ever appears for mssql-dev-platform, mssql-dev-my, mssql-dev-sg or azure)
19:35:01.651 Spawning Claude with SDK query function (version 2.1.266)
19:35:01.876 MCP server "mssql-local-platform": Starting connection with timeout of 30000ms
...
ps about a minute later (the previous CLI is gone; the three leftovers are the mssql-dev-* processes of the ended session, now spinning because of the DAB bug):
PID PPID %CPU ELAPSED COMMAND
16837 1 603.4 01:49 ~/.dotnet/tools/dab start --mcp-stdio ... --config <repo>/scripts/mcp-sql/platform.dab.json
16848 1 484.4 01:48 ~/.dotnet/tools/dab start --mcp-stdio ... --config <repo>/scripts/mcp-sql/regional.dab.json
16849 1 617.8 01:48 ~/.dotnet/tools/dab start --mcp-stdio ... --config <repo>/scripts/mcp-sql/regional.dab.json
Steps to Reproduce
No real MCP server is needed; sleep stands in for a server that never finishes initialize, so it stays in the "connecting" state until the 30 s timeout.
cat > /tmp/slow-mcp.json <<'JSON'
{"mcpServers":{
"slow-a":{"command":"sleep","args":["1000"]},
"slow-b":{"command":"sleep","args":["1001"]},
"slow-c":{"command":"sleep","args":["1002"]},
"slow-d":{"command":"sleep","args":["1003"]}}}
JSON
A. Session ended by SIGTERM while servers are connecting
claude -p "Reply with exactly the word ok and nothing else." --output-format json \
--mcp-config /tmp/slow-mcp.json --strict-mcp-config &
CLI=$!
# wait until the sleep children exist (only three appear — the fourth waits in the queue)
until [ "$(pgrep -P $CLI -f '^sleep 100' | wc -l)" -ge 3 ]; do sleep 0.1; done
kill -TERM $CLI; sleep 3
ps -o pid,ppid,command -p "$(pgrep -f '^sleep 100' | paste -sd, -)"
Observed (2.1.266): the CLI exits with status 143; all three sleep processes are still running with PPID 1.
53566 ALIVE ppid=1 cmd=sleep 1000
53567 ALIVE ppid=1 cmd=sleep 1001
53569 ALIVE ppid=1 cmd=sleep 1002
B. Session runs to completion, no signal at all
claude -p "Reply with exactly the word ok and nothing else." --output-format json \
--mcp-config /tmp/slow-mcp.json --strict-mcp-config
pgrep -fl '^sleep 100'
Observed: the CLI answers ok and exits 0 after ~35 s (30 s connect timeout + the turn). The three servers that timed out were reaped, but the fourth one — started from the queue once a slot freed up, and still connecting when the session ended — is left running with PPID 1.
Control: with the same servers already connected when the session ends (real servers, e.g. the dab ones above), SIGTERM to the CLI reaps all children within ~0.6 s, and so does a normal exit. Only connecting/queued servers are affected.
Claude Model
Not model-related (default model; the bug is in process lifecycle handling).
Is this a regression?
Unknown. First observed on 2.1.266; the age of #1935 suggests the connecting-state gap may be long-standing.
Last Working Version
Unknown.
Claude Code Version
2.1.266 (native binary shipped with the VS Code extension anthropic.claude-code-2.1.266-darwin-arm64; the same binary via claude -p reproduces it)
Platform
VS Code extension (real-world trigger) and CLI (claude -p, reproduction)
Operating System
macOS 26.6.2 (25G83), Apple Silicon
Terminal/Shell
zsh; VS Code 1.136.2
Additional Information
- Related: #1935 (open — "MCP servers not properly terminated when Claude Code exits"), #22612 (closed, not planned — orphaned MCP processes on session end), #11778 (closed —
claude mcp listorphans; on 2.1.266claude mcp listdoes reap its servers, so that one does not reproduce here). None of them pins down the connecting/queued state as the condition, which is what makes this deterministic. - The VS Code extension's own close sequence is
stdin.end()→ 2 s → SIGTERM → 2 s → SIGKILL (fromextension.js). That is fine for connected servers; the problem is purely which servers the CLI considers on shutdown. - Suggested fix: track child processes from
spawn, not from "connected"; on session end, cancel the pending connection queue and run the existing SIGINT/SIGTERM/SIGKILL escalation on every spawned transport, including those still connecting. - Workarounds that helped here: fewer stdio servers per project (
/mcpdisable) shrinks the window; a resident HTTP-transport server removes it. Killing the PPID-1 leftovers withkill -TERMworks.
3 Comments
Update: the VS Code trigger is the extension's config probe, which runs on every window open
The report attributes the short-lived CLI to an extension-host restart. That was incomplete. The CLI is the extension's config probe, which runs whenever the extension starts up in a window — including a plain window open, with no Claude conversation started. The 19:34:55 spawn in the log excerpt above is this probe (it is immediately preceded by the probe's log line). This makes the orphaning deterministic rather than an occasional race.
What the probe does (extension 2.1.268, from
extension.js):spawnConfigProbe()logsLoading config cache by launching Claude (no channel)...and spawns a full CLI with a deny-all permission callback ("Config loading only"). It awaitsinitializationResult()(account / API provider, analytics flag, remote-control availability), callsgetSettings(), may perform one usage read, and then retires the process. Its lifetime is about one second. It also re-runs whenever the extension invalidates its config cache (invalidateConfigCache()).Because the probe CLI loads the project's full MCP configuration, it spawns every stdio server and then ends the session while they are still connecting — exactly the state this issue is about. Any stdio server that needs more than about a second to answer
initializeis therefore orphaned every time a window opens.Observation, 2026-09-11, Claude Code 2.1.268 (same machine as the report; three stdio servers enabled,
mssql-dev-{platform,my,sg}, each adab start --mcp-stdioprocess that needs several seconds for schema introspection):psabout 95 s later — the probe's three servers are PPID-1 orphans (spinning because of Azure/data-api-builder#3806); the real session's three are healthy:The same probe line recurs throughout my extension logs on both 2.1.266 and 2.1.268.
Why this raises the impact
initializetime (servers that introspect a database,npx-launched servers on a cold cache, and so on).Suggested fixes — the first is the general fix; the second removes this particular trigger independently.
extension.js, it only consumesinitializationResult(),getSettings()and a usage read; I found no use of MCP server state, though I may have missed an indirect consumer. Launching the probe with MCP disabled (for example an empty--mcp-configwith--strict-mcp-config, or an equivalent SDK option) would avoid spawning the servers in the first place and save startup work on every window open.Solid write-up — the connecting/queued gap is the important distinction.
A couple of notes that may help triage:
spawn(and cancelling the pending queue on SessionEnd) is the right fix. The already-good SIGINT→SIGTERM→SIGKILL path for connected transports shows the kill escalation works; it just never gets invoked for transports still inconnectingor waiting for a concurrency slot.initializetakes longer than the probe lifetime. Empty/--strict-mcp-configfor that probe path (if MCP state is unused) removes the trigger without waiting on the CLI lifecycle fix./mcpdisable or a leaner project MCP set), or prefer a long-lived HTTP/SSE transport for those services so a 1s probe cannot leave CPU-spinning orphans.Reproduced pattern matches what you described with the
sleepstand-in — thanks for the deterministic repro.Thanks — the process-group point (3) is the one thing the report doesn't cover, and I think it belongs in the fix. It closes two gaps a spawn registry alone can't:
npx→node), so a per-PID kill of the direct child can leave the grandchild reparented. In my trace that applies to theazureserver.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE(and group kill on POSIX) also covers the CLI itself dying hard, which an in-process registry cannot.Two corrections to the reading of my logs, so the record stays accurate:
DAB is not the grandchild case. The
psoutput above shows thedabprocesses themselves at PPID 1 — they were the direct children. A .NET tool shim (~/.dotnet/tools/dab) is an apphost that loads the runtime in-process; it doesn't fork an intermediatedotnet. So those orphans would be covered by the spawn registry alone. The grandchild argument stands on the npx server, not on these.The timestamp cited doesn't show a spawn during shutdown. At 19:34:57.496 (
mssql-dev-platformstarting) teardown hadn't begun — the first SIGINT is at 57.994 and the first SIGKILL at 58.496. The servers that do start after teardown began aremssql-dev-my(58.177),mssql-dev-sg(58.226) andazure(58.906). The point holds; those three lines are the evidence for it.On (2): agreed, and that's the useful sharpening. "Cancel the pending connection queue" in my suggestion isn't specific about the concurrency — checking a shutdown flag inside the same serialized section that pops the queue is what actually closes the window, otherwise the pop can race the teardown.
(1) is what the report already suggests: track from
spawn, with connection state deciding only the signal escalation order, never whether a child is killed.