[BUG] Stdio MCP servers still connecting (or queued) at session end are never terminated and are left orphaned

Status Open
Reported on v2.1.266
Maintainer reply None cached
Activity 3 comments · opened Sep 9, 2026

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 list orphans; on 2.1.266 claude mcp list does 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 (from extension.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 (/mcp disable) shrinks the window; a resident HTTP-transport server removes it. Killing the PPID-1 leftovers with kill -TERM works.

View original on GitHub ↗

3 Comments

kenspc · 2 days ago

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() logs Loading config cache by launching Claude (no channel)... and spawns a full CLI with a deny-all permission callback ("Config loading only"). It awaits initializationResult() (account / API provider, analytics flag, remote-control availability), calls getSettings(), 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 initialize is 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 a dab start --mcp-stdio process that needs several seconds for schema introspection):

17:18:05      VS Code process starts (from ps lstart); no Claude conversation started
17:18:11.026  Loading config cache by launching Claude (no channel)...
17:18:11.029  Spawning Claude with SDK query function ... version: 2.1.268
17:18:11.303  MCP server "mssql-dev-platform": Starting connection with timeout of 30000ms
17:18:11.306  MCP server "mssql-dev-my": Starting connection with timeout of 30000ms
17:18:11.310  MCP server "mssql-dev-sg": Starting connection with timeout of 30000ms
17:18:12.184  SessionEnd:other [...] completed with status 0
17:18:12.282  SessionEnd:other [...] completed with status 0
              (no "Sending SIGINT" or any other kill line for the mssql-dev-* servers)
17:18:28.859  MCP server "mssql-dev-platform": Starting connection ...   <- first real session

ps about 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:

  PID  PPID  %CPU  ELAPSED  STARTED   COMMAND
65672     1 602.0    01:35  17:18:11  dab start --mcp-stdio ... --config .../platform.dab.json
65673     1 508.4    01:35  17:18:11  dab start --mcp-stdio ... --config .../regional.dab.json
65678     1 510.5    01:35  17:18:11  dab start --mcp-stdio ... --config .../regional.dab.json
68541 68492   0.0    01:18  17:18:28  dab start --mcp-stdio ... --config .../platform.dab.json
68542 68492   0.0    01:18  17:18:28  dab start --mcp-stdio ... --config .../regional.dab.json
68546 68492   0.0    01:18  17:18:28  dab start --mcp-stdio ... --config .../regional.dab.json

The same probe line recurs throughout my extension logs on both 2.1.266 and 2.1.268.

Why this raises the impact

  • Opening a folder in VS Code is enough; no Claude conversation needs to be started.
  • It is deterministic for any stdio server with a non-trivial initialize time (servers that introspect a database, npx-launched servers on a cold cache, and so on).
  • Orphans accumulate: every window open, and every config-cache invalidation, leaves another set behind. Combined with a server that misbehaves after stdin EOF (DAB here), several cores are pegged within about a minute of opening VS Code.

Suggested fixes — the first is the general fix; the second removes this particular trigger independently.

  1. CLI: as proposed in the report — track transports from spawn, and on session end cancel the connection queue and terminate connecting servers as well as connected ones.
  2. Extension: the probe does not appear to need MCP at all. From reading the minified extension.js, it only consumes initializationResult(), 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-config with --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.
stonianua · 2 days ago

Solid write-up — the connecting/queued gap is the important distinction.

A couple of notes that may help triage:

  1. Spawn-set vs connected-set. Tracking every child from 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 in connecting or waiting for a concurrency slot.
  1. Probe CLIs amplify it. Short-lived "load settings / account" processes that inherit the full project MCP config will deterministically orphan any stdio server whose initialize takes longer than the probe lifetime. Empty/--strict-mcp-config for that probe path (if MCP state is unused) removes the trigger without waiting on the CLI lifecycle fix.
  1. Operator workaround meanwhile. Until either lands: keep slow-init stdio servers disabled in projects you open often in VS Code (/mcp disable 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 sleep stand-in — thanks for the deterministic repro.

kenspc · 2 days ago
Solid write-up — the connecting/queued gap is the important distinction. A couple of notes that may help triage: 1. Spawn-set vs connected-set. Tracking every child from 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 in connecting or waiting for a concurrency slot. 2. Probe CLIs amplify it. Short-lived "load settings / account" processes that inherit the full project MCP config will deterministically orphan any stdio server whose initialize takes longer than the probe lifetime. Empty/--strict-mcp-config for that probe path (if MCP state is unused) removes the trigger without waiting on the CLI lifecycle fix. 3. Operator workaround meanwhile. Until either lands: keep slow-init stdio servers disabled in projects you open often in VS Code (/mcp disable 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 sleep stand-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-style servers do spawn grandchildren (npxnode), so a per-PID kill of the direct child can leave the grandchild reparented. In my trace that applies to the azure server.
  • 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 ps output above shows the dab processes 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 intermediate dotnet. 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-platform starting) 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 are mssql-dev-my (58.177), mssql-dev-sg (58.226) and azure (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.