[Bug] Since ~v2.1.228, stdio MCP first connection attempt is SIGTERMed at ~3 s and silently retried - orphans Windows-side servers under WSL and breaks single-instance servers (CONNECTION_CLOSED)
Summary
Starting with a version installed in the v2.1.228 window, Claude Code makes two
sequential spawn attempts for every stdio MCP server whose initialize takes
longer than ~3 seconds: the first attempt receives SIGTERM at ~3.0 s and a
second attempt is spawned ~17 ms later (which then gets the normal 30 s timeout).
For native Linux servers this is mostly invisible: SIGTERM propagates, the first
instance cleans up, the retry connects.
Under WSL2, when the server command launches a Windows executable via interop
(e.g. cmd.exe /c some-server.bat), the SIGTERM kills only the WSL-side interop
stub. The Windows-side child survives as an orphan. If that server is
single-instance (profile/app lock), the orphan holds the lock, the retry's
instance loses the lock race and exits, and the user sees:
Failed to reconnect to <server>: CONNECTION_CLOSED
on every attempt, with one orphaned server process leaked per attempt.
This is a regression: the same configuration connected successfully for months
up to 12 Aug 2026 (see timeline). The server itself is healthy - the official
MCP Inspector CLI (v2.2.0) connects to the identical command line from the same
WSL shell and completes tools/list (38 tools, exit 0; Evidence 3).
Environment
- Claude Code: 2.1.228, 2.1.229, 2.1.232 (all fail identically); last success
on the version installed before 2.1.228 (2.1.228 binary arrived 12 Aug 10:14,
one hour after the last successful connect at 09:14)
- OS: Windows 11 host, WSL2 (kernel 6.6.87.2-microsoft-standard-WSL2), Claude Code
running inside WSL
- Affected server: Oxygen XML Editor 28.1 MCP server (AI Positron 8.3.0) -
a JVM launched by cmd.exe /c "P:\...\oxygen_mcp.bat"; takes ~6-15 s to answer
initialize; enforces a single-instance profile lock
(%APPDATA%\com.oxygenxml\oxygen28.1.lock)
- Unaffected control in the same
.mcp.json: Sparx EA MCP (MCP3.exe, also
cmd.exe /c via interop) - answers initialize in ~1.5 s, i.e. inside the
3 s window, so the first attempt completes and the axe never falls
Regression timeline (from ~/.cache/claude-cli-nodejs/<project>/mcp-logs-oxygen-xml/)
| Date | Event |
|---|---|
| ...through 12 Aug 09:14 | Daily successful connects, Successfully connected (transport: stdio) in 9847-14176ms |
| 12 Aug 10:14 | v2.1.228 auto-downloaded (~/.local/share/claude/versions/2.1.228 mtime) |
| 13 Aug 06:11 | First failure: Connection failed after 19636ms (CONNECTION_CLOSED) |
| 13-14 Aug | Every attempt fails, 10-20 s to failure, on 2.1.228 / 2.1.229 / 2.1.232 |
Note the successful-era connect times (9.8-14.2 s) are all far above the ~3 s
first-attempt deadline observed below - under the current behaviour this server
can never complete its first attempt.
Evidence 1 - double spawn observed at the spawn boundary
.mcp.json temporarily pointed at an instrumented bash wrapper that logs and then
execs the real command. One claude mcp list health check produced two wrapper
starts, same parent, ~4 s apart:
13:54:20.892 wrapper start pid=8776 ppid=8743
13:54:24.896 wrapper start pid=8837 ppid=8743
13:54:32.122 cmd.exe exited rc=0 <- retry's JVM losing the lock race to the
orphan of the first attempt
(No trapped signal lines for the first wrapper - consistent with SIGKILL/teardown
of the WSL-side tree at the end of the check; the Windows JVM from the first
attempt was still alive afterwards, verified via tasklist.)
Evidence 2 - SIGTERM at ~3.0 s, retry 17 ms later (pure Linux, no WSL interop)
A minimal single-instance stdio MCP server in Node (script below), registered in.mcp.json, one claude mcp list run. Its event log:
2026-08-14T13:50:10.815Z pid=12285 spawned
2026-08-14T13:50:10.815Z pid=12285 lock acquired (fresh) -> winner, serving
2026-08-14T13:50:13.802Z pid=12285 got SIGTERM <- ~3.0 s after spawn
2026-08-14T13:50:13.802Z pid=12285 exit handler, removing lock
2026-08-14T13:50:13.819Z pid=12351 spawned <- 17 ms later
2026-08-14T13:50:13.819Z pid=12351 lock acquired (fresh) -> winner, serving
2026-08-14T13:50:19.862Z pid=12351 got SIGINT <- normal teardown after
successful connect
The server answers initialize after a deliberate 6 s startup delay - the report
line ✔ Connected is shown, but the log proves the first attempt was killed and
retried. On Linux the handover is clean, so the behaviour is silent; the same
kill against a WSL-interop child does not reach the Windows process.
Evidence 3 - the server itself is healthy (verified with the reference MCP Inspector)
The official MCP Inspector CLI (@modelcontextprotocol/inspector v2.2.0), run
from the same WSL shell against the identical command line, connects and completes
a full tools/list round trip - exit code 0, 38 tools returned:
npx -y @modelcontextprotocol/inspector --cli cmd.exe /c "P:\Programs\Oxygen XML Editor\oxygen_mcp.bat" --method tools/list
# exit 0; 38 tools: invoke_ai_agent, find_reusable_components,
# get_xml_refactoring_operation_params, save_document, evaluate_xpath,
# get_ditamap_structure, get_module_components, refactor_xml, ...
Additionally, driving the identical command by hand (bash pipeline, and a plainnode child_process.spawn with the same argv/cwd) yields a valid initialize
response in ~6 s, live-streamed stderr, and a stable process - including withprotocolVersion values 2024-11-05 and 2025-06-18, and with periodic ping
writes during startup. Only Claude Code's own spawn path fails.
(Side observation supporting the interop claim: the Inspector's own teardown of
its cmd.exe child also leaves the Windows JVM orphaned - process-tree kills
from the WSL side simply do not reach Windows children. The Inspector still
connects because it makes one attempt and waits, rather than killing at ~3 s
and retrying.)
Failure anatomy for the WSL + single-instance case
- Attempt #1 spawns
cmd.exe /c server.bat; the Windows JVM starts and takes
its single-instance lock (JVM needs ~6-15 s before it can answer).
- At ~3 s Claude Code SIGTERMs attempt #1. The WSL-side stub dies; **the Windows
JVM survives** (kill does not propagate across the interop boundary) and keeps
the lock.
- Attempt #2 spawns immediately. Its JVM boots, finds the lock held by the live
orphan, and exits cleanly (~10 s in). Claude Code reports
CONNECTION_CLOSED.
- One orphaned ~700 MB JVM is leaked per attempt, and its live lock also breaks
any later attempt until manually killed
(taskkill /IM java.exe + delete the lock file).
Minimal repro script (Linux-only shows the kill/retry; WSL+Windows exe shows the breakage)
#!/usr/bin/env node
// single-instance-mcp.js - minimal single-instance stdio MCP server.
// Logs spawn/signal events to /tmp/single-instance-mcp-events.log.
const fs = require('fs'); const os = require('os'); const path = require('path');
const readline = require('readline');
const LOCK = path.join(os.tmpdir(), 'single-instance-mcp.lock');
const EVENTS = path.join(os.tmpdir(), 'single-instance-mcp-events.log');
const STARTUP_MS = 6000, LOSER_EXIT_MS = 3000;
const ev = m => fs.appendFileSync(EVENTS, `${new Date().toISOString()} pid=${process.pid} ${m}\n`);
ev('spawned');
const pidAlive = p => { try { process.kill(p, 0); return true; } catch { return false; } };
let haveLock = false;
try {
const fd = fs.openSync(LOCK, 'wx'); fs.writeSync(fd, String(process.pid)); fs.closeSync(fd);
haveLock = true;
} catch {
const holder = parseInt(fs.readFileSync(LOCK, 'utf8'), 10);
if (!pidAlive(holder)) { fs.writeFileSync(LOCK, String(process.pid)); haveLock = true; }
}
if (!haveLock) {
ev('lock held by live instance -> loser, exiting in 3 s');
setTimeout(() => { ev('loser exit 0'); process.exit(0); }, LOSER_EXIT_MS);
} else {
ev('lock acquired -> winner, serving');
const cleanup = () => { ev('exit handler, removing lock'); try { fs.unlinkSync(LOCK); } catch {} };
process.on('exit', cleanup);
process.on('SIGINT', () => { ev('got SIGINT'); process.exit(0); });
process.on('SIGTERM', () => { ev('got SIGTERM'); process.exit(0); });
const started = Date.now();
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', line => {
let msg; try { msg = JSON.parse(line); } catch { return; }
const reply = result => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }) + '\n');
if (msg.method === 'initialize') {
const wait = Math.max(0, STARTUP_MS - (Date.now() - started));
setTimeout(() => reply({ protocolVersion: '2025-06-18',
capabilities: { tools: { listChanged: true } },
serverInfo: { name: 'single-instance-mcp', version: '1.0' } }), wait);
} else if (msg.method === 'ping') reply({});
else if (msg.method === 'tools/list') reply({ tools: [] });
});
}
Steps:
- Register it as a stdio server in
.mcp.json(node single-instance-mcp.js). - Run
claude mcp list(or start a session). - Read
/tmp/single-instance-mcp-events.log: on affected versions the first pid
gets SIGTERM at ~3 s and a second pid spawns ~17 ms later. On pre-regression
versions there is a single spawn.
- For the fatal variant: run Claude Code in WSL and point the server command at
any slow-starting Windows executable that enforces a single-instance lock
(Oxygen XML's oxygen_mcp.bat is the real-world case) - every connect fails
CONNECTION_CLOSED and leaks one orphaned Windows process per attempt.
Expected behaviour
- One spawn per connection attempt, with the full configured timeout applied to
it (as in versions up to ~2.1.227), or at minimum a first-attempt grace long
enough for slow-starting servers; and
- if a spawned attempt is abandoned, the kill should take the whole tree with it,
including Windows-side children when running under WSL (e.g. taskkill /T via
interop), so no orphans or held locks remain.
Workarounds attempted (all failed)
- bash wrapper interposed between Claude Code and
cmd.exe- still killed/retried - diverting the server's stderr to a file - no effect
- wrapper that kills predecessor instances before launching - force-kill leaves
the app's stale lock, the new instance then hangs on it (Request timed out)
MCP_TIMEOUT- not relevant; the failure occurs well inside 30 s
Effective workaround: pin Claude Code to <= 2.1.227.
Possibly related
- #40207 (SIGTERM sent to healthy stdio MCP servers) - may be the same underlying
lifecycle behaviour; not verified by this report's author
- #43791 (per-server timeout field not honoured for stdio)
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗