[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)

Status Open
Reported on v2.1.228
Maintainer reply None cached
Activity 1 comment · opened Aug 14, 2026

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 plain
node child_process.spawn with the same argv/cwd) yields a valid initialize
response in ~6 s, live-streamed stderr, and a stable process - including with
protocolVersion 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

  1. 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).

  1. 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.

  1. 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.

  1. 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:

  1. Register it as a stdio server in .mcp.json (node single-instance-mcp.js).
  2. Run claude mcp list (or start a session).
  3. 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.

  1. 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)

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗