[BUG] Claude Code sends SIGTERM to all healthy stdio MCP servers after 10-60s — root cause analysis with strace evidence
Bug Description
Claude Code sends SIGTERM to all stdio-based MCP servers simultaneously, 10–60 seconds after successful connection and handshake. No errors precede the kill — servers are healthy and actively responding to tool calls. The timeout interval shrinks over the session lifetime (60s → 30s → 10s). The only recovery is manual /mcp reconnection, which itself gets killed again.
This is a systemic issue affecting every stdio MCP server configured in the session. Cloud-hosted MCPs (Gmail, Google Calendar via claude.ai) are unaffected because they use a different transport.
Root Cause Analysis
I deployed three layers of instrumentation to trace the root cause:
1. strace on Claude Code's process tree
sudo strace -p <claude_pid> -e kill,tgkill -f -t
Captured:
1480540 21:57:45 kill(1501128, SIGINT) = 0 # Main Claude PID kills one MCP
1501518 21:57:57 kill(1501129, SIGTERM) = 0 # Child wrapper kills another MCP
1480540 21:58:02 kill(1501627, SIGINT) = 0 # Main Claude PID kills another
PID 1501518 is a short-lived Claude child process (MCP lifecycle wrapper). It spawns around each MCP server, and deliberately sends SIGTERM to kill it.
2. Watchdog process monitor
A polling script that tracks MCP child processes by PID, logs when they appear/disappear:
[21:40:25] NEW: PID=1480580 (chrome-devtools-mcp) fd0=socket fd1=/dev/null
[21:40:25] NEW: PID=1480584 (typst-mcp) fd0=socket fd1=socket
[21:40:25] NEW: PID=1480766 (fli-mcp) fd0=socket fd1=socket
[21:40:25] NEW: PID=1480803 (mcp-stdio-proxy.sh) fd0=socket fd1=socket
[21:40:25] NEW: PID=1480840 (outlook-owa) fd0=socket fd1=socket
[21:41:05] GONE: PID=1480584 (typst-mcp) — exit code: 127
[21:41:05] GONE: PID=1480580 (chrome-devtools-mcp) — exit code: 127
[21:41:05] GONE: PID=1480766 (fli-mcp) — exit code: 127
[21:41:05] GONE: PID=1480803 (mcp-stdio-proxy.sh) — exit code: 127
[21:41:05] GONE: PID=1480840 (outlook-owa) — exit code: 127
All 5 MCP servers killed at the exact same second, 40 seconds after startup.
3. JSON-RPC stdio proxy
A transparent bidirectional proxy that logs all JSON-RPC messages between Claude Code and an MCP server:
[21:40:20] C->S: initialize request
[21:40:20] S->C: initialize response (success, 16 tools listed)
[21:40:20] C->S: notifications/initialized
[21:40:20] C->S: tools/list
[21:40:20] S->C: tools/list response (success)
[21:41:01] PROXY: SIGTERM received
[21:41:01] PROXY: Server died with signal TERM (143)
No errors, no failed requests, no compaction event. Clean SIGTERM 41 seconds after a successful handshake.
Hypotheses Ruled Out
| Hypothesis | Evidence | Verdict |
|---|---|---|
| Context compaction | No PostCompact hook fired; happens too early in session | ❌ Eliminated |
| Individual MCP crashes | All 5 die simultaneously with same exit code | ❌ Eliminated |
| MCP server idle timeout | Called tools right after reconnect — still killed 10s later | ❌ Eliminated |
| Hooks killing MCPs | Audited all hooks in ~/.claude/hooks/ — none target MCPs | ❌ Eliminated |
| External process (cron/reaper) | Only systemd timer runs at 2am, only targets orphans (PPID=1) | ❌ Eliminated |
Conclusion
Claude Code has an internal stdio timeout/lifecycle mechanism that kills healthy MCP servers. Evidence:
- strace confirms CC spawns a wrapper process per MCP that sends SIGTERM
- CC changelog mentions a fix for "MCP stdio server timeout not killing child process" — confirming this timeout exists by design
MCP_TIMEOUTenv var exists to configure it, but the default appears too aggressive- The timeout fires even when MCPs are healthy and actively responding
Impact
This effectively breaks the MCP extensibility model for power users. Anyone running multiple stdio MCPs (browser automation, email, calendars, databases, custom tools) loses their entire tool surface repeatedly throughout a session. The failure is silent — no error message, no warning. Tools simply stop being available.
Prior issues reporting this symptom were auto-closed as duplicates or for inactivity, but none identified the root cause:
- #15758 — MCP tools silently disappear mid-session (closed/locked)
- #24350 — MCP connections drop silently, require manual /mcp (closed as dup of #15758, locked)
- #38395 — GitHub MCP disconnects during multi-file operations
- #7718 — SIGABRT crash during MCP shutdown (SIGINT → SIGTERM → SIGKILL cascade)
- #35287 — Stdio MCPs hang indefinitely when init fails
Reproduction
- Configure 3+ stdio MCP servers in
~/.claude.json - Start a Claude Code session
- Verify MCPs connect via
/mcp - Wait 10–60 seconds
- All stdio MCPs will disconnect simultaneously
Reproduction instrumentation
<details>
<summary><b>mcp-watchdog.sh</b> — polls child processes, detects when MCPs appear/disappear</summary>
#!/bin/bash
# Usage: mcp-watchdog.sh <claude_pid>
# Run in a separate terminal. Auto-stops when Claude exits.
LOG="$HOME/.claude/logs/mcp-disconnect-debug.log"
INTERVAL=10
CLAUDE_PID="${1:?Usage: mcp-watchdog.sh <claude_pid>}"
MCP_PATTERNS="chrome-devtools-mcp|typst-mcp|fli-mcp|google-tasks|outlook-owa/server|discord.*server"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] [watchdog] $*" >> "$LOG"; }
declare -A PREV_PIDS
log "=== WATCHDOG STARTED for Claude PID=$CLAUDE_PID ==="
while kill -0 "$CLAUDE_PID" 2>/dev/null; do
declare -A CURR_PIDS
for pid in $(pgrep -P "$CLAUDE_PID" 2>/dev/null); do
CMDLINE=$(cat /proc/$pid/cmdline 2>/dev/null | tr '\0' ' ' | head -c 200)
[ -z "$CMDLINE" ] && continue
echo "$CMDLINE" | grep -qE "$MCP_PATTERNS" || continue
FD0=$(readlink /proc/$pid/fd/0 2>/dev/null || echo "GONE")
FD1=$(readlink /proc/$pid/fd/1 2>/dev/null || echo "GONE")
STATE="$CMDLINE|$FD0|$FD1"
CURR_PIDS[$pid]="$STATE"
if [ -z "${PREV_PIDS[$pid]}" ]; then
SHORT=$(echo "$CMDLINE" | grep -oE '[^ ]*mcp[^ ]*|chrome-devtools|typst|google-tasks|outlook|discord' | head -1)
log " NEW: PID=$pid ($SHORT) fd0=$FD0 fd1=$FD1"
fi
done
for pid in "${!PREV_PIDS[@]}"; do
if [ -z "${CURR_PIDS[$pid]}" ]; then
SHORT=$(echo "${PREV_PIDS[$pid]}" | grep -oE '[^ ]*mcp[^ ]*|chrome-devtools|typst|google-tasks|outlook|discord' | head -1)
log " GONE: PID=$pid ($SHORT) — process disappeared!"
fi
done
unset PREV_PIDS; declare -A PREV_PIDS
for pid in "${!CURR_PIDS[@]}"; do PREV_PIDS[$pid]="${CURR_PIDS[$pid]}"; done
unset CURR_PIDS
sleep "$INTERVAL"
done
log "=== WATCHDOG STOPPED ==="
</details>
<details>
<summary><b>mcp-stdio-proxy.sh</b> — logs all bidirectional JSON-RPC traffic between CC and an MCP server</summary>
#!/bin/bash
# Usage: mcp-stdio-proxy.sh <logfile> <command> [args...]
# Configure in ~/.claude.json as the MCP command, wrapping the real server.
LOGFILE="${1:?Usage: mcp-stdio-proxy.sh <logfile> <command> [args...]}"
shift; COMMAND="${1:?Missing command}"; shift
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S.%3N')] $1: $2" >> "$LOGFILE"; }
log "PROXY" "=== PROXY STARTED (PID=$$, PPID=$PPID) ==="
log "PROXY" "Command: $COMMAND $*"
TMPDIR=$(mktemp -d /tmp/mcp-proxy-XXXXXX)
C2S="$TMPDIR/c2s"; S2C="$TMPDIR/s2c"
mkfifo "$C2S" "$S2C"
cleanup() {
log "PROXY" "=== CLEANUP (signal=${1:-EXIT}) ==="
if kill -0 "$SERVER_PID" 2>/dev/null; then
log "PROXY" "Server still alive at cleanup"
else
wait "$SERVER_PID" 2>/dev/null; ec=$?
[ "$ec" -gt 128 ] && log "PROXY" "Server died with signal $((ec-128)) ($(kill -l $((ec-128)) 2>/dev/null))"
[ "$ec" -le 128 ] && [ "$ec" -ne 0 ] && log "PROXY" "Server exited with code $ec"
fi
kill "$C2S_PID" "$S2C_PID" "$SERVER_PID" 2>/dev/null
rm -rf "$TMPDIR"
}
trap 'cleanup TERM' TERM; trap 'cleanup INT' INT
exec 3<&0; exec 4>&1
"$COMMAND" "$@" < "$C2S" > "$S2C" 2>> "$LOGFILE" &
SERVER_PID=$!
( while IFS= read -r line <&3; do log "C->S" "$line"; echo "$line"; done > "$C2S" ) &
C2S_PID=$!
( while IFS= read -r line; do log "S->C" "$line"; echo "$line" >&4; done < "$S2C" ) &
S2C_PID=$!
wait "$SERVER_PID" 2>/dev/null; SERVER_EXIT=$?
cleanup "server-exit"; exit "$SERVER_EXIT"
</details>
Expected Behavior
- Stdio MCP servers should remain connected for the lifetime of the session unless they crash or the user disconnects them
- If a timeout exists by design, it should only fire when the MCP server is genuinely unresponsive (not responding to ping/heartbeat), not on a wall-clock timer
MCP_TIMEOUTdefault should be documented
Environment
- Platform: Ubuntu Linux (x86_64)
- Claude Code: 2.1.86
- MCP servers tested: chrome-devtools-mcp, outlook-owa, google-tasks, typst-mcp, flights-mcp (all stdio)
- Not affected: Gmail, Google Calendar (cloud-hosted, different transport)
11 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Your debugging skill is extremely impressive — 3 layers of instrumentation to trace a root cause that 5 previous issues couldn't identify. My Claude says the prior issues #15758, #24350, #38395 were all auto-closed or locked without resolution. Your strace evidence and root cause analysis deserve serious attention from Anthropic.
Independent confirmation on macOS (arm64) with
--debug-fileevidenceConfirming this exact behavior on macOS with CC v2.1.90. We identified three independent MCP failure triggers that all produce the identical cascade described in the OP — CC sends SIGINT to ALL stdio MCP servers when any single MCP disconnects.
Our triggers (each independently causes the cascade)
@playwright/mcp@0.0.68) with--cdp-endpoint http://localhost:9333— when Chrome isn't running with remote debugging, the CDP WebSocket connects then drops after 30-45s. CC cascade-kills all servers.Debug log evidence
Using
claude --debug-file /tmp/cc-debug.log, the cascade is clearly visible:Note: Linear shows "Terminal connection error 1/3" — it was going to retry (has 3 attempts), but CC already killed all other servers and exited.
Binary search methodology
We confirmed the trigger via binary search:
.mcp.json(zero project MCPs) → survivesImpact
This makes ANY HTTP/SSE MCP server a session-killing liability. A single flaky connection (OAuth expiry, SSE drop, CDP to empty port) cascades to kill all healthy stdio servers and terminates the entire session. The
enabledPlugins: falseflag in settings.json does NOT prevent cached plugins from loading and triggering the cascade.Environment
Suggested fix
CC should handle individual MCP disconnects gracefully — reconnect the failed server without killing healthy ones. The current behavior (one disconnect → SIGINT to ALL → session exit) makes the MCP ecosystem fragile.
Additional Reproduction: Raspberry Pi 5 + HAPImatic (SDK entry point)
Environment
Reproduction
Reproduced in a clean, isolated HAPImatic session (SDK entry point
sdk-ts) with no other Claude processes running in the same project directory. Also reproduced in direct CLI terminal sessions.notebooklm-mcp(direct python binary) andplaywright-http(HTTP transport) surviveDebug Log Evidence
Captured via
/debugin an isolated session:No "Sending SIGINT" or "Sending SIGTERM" messages precede the closures. The child processes exit on their own (or are terminated via a path that doesn't log).
Separate
/debugSession (CLI entry point, same machine)Full startup captured with
--debug:The connect → immediate SIGINT → reconnect → survive pattern suggests stale detection (
excludeStalePluginClients) is running between the initial connection and the reconnect, killing the first connection.Key Observations
notebooklm-mcp) and HTTP transport (playwright-http) are immunenode(context7, tavily, wrike),uvx(serena),python(zen) — not language-specificnotebooklm-mcpremains as a child of the Claude processchild_process.spawn()and kept alive for 30+ secondsWorkaround Attempted
We migrated from
npx-based MCP servers to locally installed npm packages (~/.mcp-servers/<name>/) with directnodeexecution. This fixed the startup contention issue (npx registry lookups racing) but did NOT fix the 11–13 second kill. The servers connect faster and more reliably, but still get terminated by the same internal mechanism.Additional evidence — macOS, Claude Code 2.1.104, Telegram plugin v0.0.5
Confirming this exact issue on macOS (Apple Silicon Mac Mini). Telegram plugin was rock solid for weeks on Claude Code 2.1.101, then started dying immediately on every spawn after upgrading to 2.1.104 overnight (2026-04-12).
Timeline
| Time (UTC) | Event |
|---|---|
| Apr 12 01:46 | Claude Code auto-updated 2.1.101 → 2.1.104 |
| Apr 12 03:08 | Telegram plugin auto-updated v0.0.4 → v0.0.5 |
| Apr 12 09:00 | First crash — every instance since dies within 1-5 seconds |
Log evidence
Plugin v0.0.5 has instrumented logging. Every single crash follows the same pattern — stdin END fires 1-5 seconds after successful startup, parent PID remains alive:
Key observations:
stdin destroyed=false readableEnded=falseat time of END event — the plugin didn't close its own stdinEnvironment
This also confirms #43177 — stdio servers get no auto-reconnect after the parent kills them. The combination of aggressive stdin closure + no reconnect logic makes channel-type plugins (Telegram, Discord) completely unusable since they need persistent connections.
I thought I was losing my mind. Thanks for opening this issue. A systemic stdio failure on MCP, I'm kind of shocked this hasn't risen to the level of a serious bug to be fixed ASAP.
Linux + systemd long-running reproduction (telegram channels plugin)
Posting another data point — most reports here are macOS interactive, this is Linux as a 24/7 systemd service.
Setup: astinus (Arch/CachyOS), claude-code 2.1.133 installed via npm,
claude --channels plugin:telegram@claude-plugins-official --permission-mode bypassPermissionsrunning as asystemd --userservice. Plugin isclaude-plugins-official/telegram@0.0.6(bun MCP server using@modelcontextprotocol/sdkStdioServerTransport + grammy long-poll). One stdio MCP child only.Symptom: matches this issue. The bun MCP child dies cleanly on a recurring cadence while the parent claude process stays alive. We watch with an external cgroup-scoped watchdog every 5 min that walks
/sys/fs/cgroup/<service>/cgroup.procslooking for the bun PID — when it's missing we restart the whole service.Cadence: ~30 min between bun deaths in our deployment. That sits at the long end of @blueblueball's "60s → 30s → 10s shrinking timeout" observation rather than the short end @tylersp7 saw on macOS — same shape, longer wall-clock interval. Either Linux+systemd hits the timeout differently, or the long-poll keeps the channel busy enough that the timeout rearms. Couldn't pin which.
Plugin behavior is correct: server.ts ships
process.stdin.on('end', shutdown)plus anprocess.ppid !== bootPpidorphan check on a 5s setInterval. We see clean shutdown via the stdin-end path — the plugin self-terminates because claude closed the write end of its stdin pipe, exactly as @tylersp7 documented. The plugin's other detection paths (destroyed/readableEnded/ppid) don't fire because they're not the trigger.MCP_TIMEOUTdid not help. Confirmed by reading both #16837 (cap of 60s) and #43299 (overridden by inner SDK request timeout). Did not waste cycles testingMCP_TIMEOUT=0after that.Operational impact: Telegram messages are dropped between bun death and the next watchdog tick (in our previous setup with a 30-min watchdog cadence, that meant up to ~30 min of silent unavailability per cycle). Bumped watchdog to 5 min today to cap the worst case at 5 min — but this is firmly mitigation, not a fix.
For repro on Linux: any setup with
claude --channelsrunning long-running as a service and a single stdio MCP child whose only job is holding an external long-poll connection (Telegram, Discord, IRC) will hit this. The longer the session, the more visible the cadence.Full local writeup with strace-equivalent process-tree evidence and our watchdog source: https://github.com/JonBasse/fizbot/blob/main/docs/superpowers/plans/2026-05-08-channels-stdio-mcp-fragility.md (tracked in https://github.com/JonBasse/fizbot/issues/277).
Happy to drop more debug logs if helpful —
claude --debug-fileover a 2-3h window should capture multiple kill events.Adding production telemetry from a different angle: 14 sessions died in the 1-4 call cohort in 24h across the customer base of
@respira/wordpress-mcp-server(~180 tools, several hundred daily-active users on Claude Desktop + Claude Code + Cursor). Filed as #61146; closing that as a duplicate of this with the relevant data brought over.Customer-side telemetry signature
Server-side OTel emitter, one row per tool call, no PII (tool name, duration, success, mcp_session_id only). Last 24h:
| Calls in session | Sessions ended at this count | Avg span (s) |
|---|---|---|
| 1 | 8 | 0 |
| 2 | 2 | 26 |
| 3 | 3 | 42 |
| 4 | 1 | 177 |
The "10-60 seconds" window in @ignaciomella's strace evidence maps cleanly onto our 26-177s avg-span numbers for the 2-4 call cohort. Distribution drops off sharply after 4 calls (we have sessions reaching 13, 17, 22, 38, 46, 62, 80) — the kill cycle clearly has a probabilistic component that loosens with session age, matching the reported 60→30→10s shrinking interval.
Last tool call before death always completes successfully with normal duration (700ms-3s, no spike). Dying tools spread across many different tool names, none returning unusually large payloads. So server-side dispatch is healthy and the response stream ends cleanly — exactly what @ignaciomella's JSON-RPC proxy log confirmed independently.
In-code documentation of the issue
We had to ship diagnostics for this in our v6.17.2 release. The in-code comment, verbatim:
Customers running
Console.appand searching for our log prefix see multiplerespira-mcp ready · pid Xlines per conversation, each from a different PID. Same SIGTERM-without-reason pattern, observed from the other end of the pipe.Adjacent workaround we shipped today
A subset of the failures we see correlate with large tool result payloads. macOS pipe buffer is 64 KB; two of our tools return ~600 KB and ~76 KB. When the host's stdin drain loop falls behind, the MCP write blocks, and the supervisor (presumably) reaps the subprocess after the stall. We just shipped a 100 KiB response-size cap in v6.18.2 with a structured truncation envelope so the agent can retry with pagination. Mitigates one of the trigger paths but doesn't fix the supervisor reaping itself.
This is distinct from the bare timeout @ignaciomella described — separate trigger, same outcome.
What would unblock us
Three asks from the MCP-server-author side, in addition to fixing the underlying kill:
MCP_TIMEOUTenv var's accepted values and semantics. We can size response caps + tool envelopes around real limits instead of guessing.Happy to share the raw telemetry slice if useful and have a reproducible Studio repro (WordPress 7.0 + our plugin + local stack on
localhost:8882).Public MCP server mirror: https://github.com/respira-press/respira-wordpress-mcp · npm: https://www.npmjs.com/package/@respira/wordpress-mcp-server
Partial fix shipped: the false-positive teardown in the headless/SDK
mcp_set_serversreconcile path is fixed. Config comparison now normalizesargs: undefinedvs[], implicittype: 'stdio', and metadata fields before comparing. That covers the SDK reproduction in this thread.Not addressed: the interactive SSE-drop → SIGINT-all-stdio cascade (@prodan-s's
--debug-filelog) and the OP's shrinking 60s/30s/10s interval. Those are two separate mechanisms from the headless reconcile path. Leaving open to track them.Status from the macOS interactive report, on 2.1.174:
mcp_unauthorized_no_token) left the stdio fleet untouched, and today a launchd-managed localhost HTTP server (Playwright on :8931) restarted mid-session — CC dropped its tools and did not signal the stdio servers (n=1, localhost streamable-HTTP, not the April remote-SSE flavor).Happy to run candidate builds with
--debug-filefor the interactive path.Closing for now — inactive for too long. Please open a new issue if this is still relevant.