[Regression] Claude Code 2.1.x silently exits with code 1 during HTTP MCP init — loop-protection swallows the underlying exception stacks
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code
What's Wrong?
Claude Code CLI 2.1.x introduced an "uncaught exception loop" self-protection
mechanism. With ≥3 HTTP transport MCP servers configured, this creates a
diagnostic dead-end:
- During MCP server initialization, multiple unhandled promise rejections
are emitted (apparent path: transport _onclose rejecting awaited
Promises with no .catch()).
- The self-protection fires once 10 uncaught exceptions occur within 5000ms.
- The original stack traces are swallowed by the handler, which logs
only: [ERROR] Uncaught exception loop detected (10 in 5000ms) — forcing shutdown.
- The process exits with code 1. SDK consumers (Claude Agent SDK) receive
only Error: Claude Code process exited with code 1 with no actionable
information.
This is an observability regression in the 2.1.x line. The same family
of unhandled MCP rejections in 2.0.76 surfaced as visible stack traces (see
#3184, #2263); the 2.1.x self-protection turns that into a silent exit.
Users have no escape hatch for self-diagnosis: claude ships as a
Bun-compiled binary, so NODE_OPTIONS=--trace-uncaught and Node-level patches
do not apply. The only signal available is the single "loop detected" line.
Steps to Reproduce
- Configure
mcpServerswith three reachable Streamable-HTTP MCP servers.
Each should complete the initialize handshake successfully:
``json``
{
"mcpServers": {
"server-a": { "type": "http", "url": "https://example.com/a/mcp",
"headers": { "Authorization": "Bearer ..." } },
"server-b": { "type": "http", "url": "https://example.com/b/mcp",
"headers": { "Authorization": "Bearer ..." } },
"server-c": { "type": "http", "url": "https://example.com/c/mcp",
"headers": { "Authorization": "Bearer ..." } }
}
}
- Invoke the CLI:
``bash``
claude --debug-to-stderr -p "hi" --mcp-config "$(cat config.json)"
- Observe
exit 1and the singleUncaught exception loop detectedline.
No JS stack is printed.
In my own environment, the three HTTP MCP servers used to reproduce this
were web-search-prime and web-reader (both https://open.bigmodel.cn)
and WebSearch (https://dashscope.aliyuncs.com). Bisection suggests the
failure is correlated with the number of concurrently initialized HTTP
transports rather than one specific server, but I have not verified with
HTTP servers outside my configuration.
Downstream SDK impact
The SDK is not required to reproduce the underlying failure, but this is how
the same CLI shutdown surfaces to SDK consumers:
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({
prompt: "hi",
options: {
mcpServers: {
"server-a": { type: "http", url: "...", headers: { Authorization: "Bearer ..." } },
"server-b": { type: "http", url: "...", headers: { Authorization: "Bearer ..." } },
"server-c": { type: "http", url: "...", headers: { Authorization: "Bearer ..." } },
},
},
})) {
console.log(message);
}
// → throws: Error: Claude Code process exited with code 1
Expected Behavior
Claude Code should handle both cases gracefully:
- The transport's connection-close path should attach a
.catch()or wrap
awaits in try/catch, so quickly closed transports do not surface as
uncaughtException events.
- If the loop-detection handler still needs to force shutdown, it should log
the first 1–3 exception stacks before exiting, so users and SDK consumers
can see the underlying McpError: -32000 or other root cause.
Actual Behavior
Bisection matrix (each row is one full CLI startup; "plugin" = any single--plugin-dir <path>; baseline stdio MCP servers held constant):
| HTTP MCP servers | Plugin | Result |
| ---------------- | :----: | :----: |
| 0 | yes | exit 0 |
| 1 | yes | exit 0 |
| 2 | no | exit 0 |
| 2 | yes | exit 1, loop detected |
| 3 | no | exit 1, loop detected |
| 3 | yes | exit 1, loop detected |
Stdio MCPs alone never trigger the failure regardless of count.
Concrete debug-log fingerprint (HTTP MCPs reach Connection established,
then within ~1s the loop fires):
[DEBUG] MCP server "WebSearch": Successfully connected (transport: http) in 546ms
[DEBUG] MCP server "WebSearch": Connection established with capabilities: {...}
[DEBUG] MCP server "web-search-prime": Successfully connected (transport: http) in 703ms
[DEBUG] MCP server "web-reader": Successfully connected (transport: http) in 928ms
[DEBUG] MCP server "bb-browser": UNKNOWN connection closed after 0s (cleanly)
[ERROR] MCP server "bb-browser" Failed to fetch tools: MCP error -32000: Connection closed
[ERROR] Uncaught exception loop detected (10 in 5000ms) — forcing shutdown
[DEBUG] MCP server "WebSearch": HTTP connection closed after 3s (cleanly)
[DEBUG] MCP server "web-search-prime": HTTP connection closed after 3s (cleanly)
[DEBUG] MCP server "web-reader": HTTP connection closed after 3s (cleanly)
[DEBUG] SessionEnd:other [...] completed with status 0
Note:bb-browseris a stdio MCP server (bb-browser --mcp) that emits-32000 Connection closedimmediately after a successfulinitializehandshake in my setup. It does not trigger the loop by itself, but appears to contribute rejection events when HTTP transports are also present.
Suspected Root Cause
This looks closely related to #3184 and #2263, where MCP transport
close handling surfaced as unhandled promise rejections through stack frames
including _transport.onclose → client.transport.onclose →l9.onCloseHandler. In the 2.1.x line, the globalprocess.on('uncaughtException') handler counts events and self-kills at
10/5000ms. Three concurrent HTTP transports each appear to emit ~3–4
unhandled rejections during their tools/list race against early connection
close, comfortably crossing the threshold. The "Uncaught exception loop
detected" string is not present in 2.0.76, which suggests the new shutdown
path / observability regression was introduced in the 2.1.x line.
Proposed Fix
- **Attach
.catch()(or wrap intry/await) on every transport
_onclose-triggered Promise** in the MCP transport layer. This is the
underlying root-cause fix — same area as #3184 / #2263.
- **Log the first 1–3 exception stacks before the loop-detection handler
forces shutdown.** Even without (1), this single change would let users
and SDK consumers diagnose the actual failure instead of seeing only
exit 1.
Related Issues
- #3184 — MCP error -32000: Connection closed - Node.js uncaught exception
(closed stale, multiple +1s)
- #2263 — Unhandled Promise Rejection: MCP Connection Closed Error
(closed stale, same family)
- #55992 — Streamable HTTP MCP: in-flight tool call Promise orphaned forever
on connection drop (open; same transport-layer Promise hygiene weakness,
different lifecycle phase)
- #56456 — streamable-http MCP sessions never terminated on client shutdown
- anthropics/claude-agent-sdk-typescript#308 — Silent process exit (code 1)
when calling query() (open; SDK-side perspective on the same opacity)
Environment
- Platform: darwin (macOS arm64)
- Claude Code: 2.1.129
- Claude Agent SDK: 0.2.128 (consumer side; reproducible via the CLI alone
with --mcp-config, so the SDK is not required to trigger)
- Runtime packaging: Claude Code is a Bun-compiled binary;
NODE_OPTIONS=--trace-uncaughtdoes not expose the swallowed stacks
4 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Hi — thanks for the triage. I don't think any of the three is an exact duplicate of #56520.
#56520 appears distinct on three axes:
tools/listrace, before any user tool call.exit 1, with no actionable stack. The CLI also ships as a Bun-compiled binary, soNODE_OPTIONS=--trace-uncaughtis not a workaround. The SDK is not required to reproduce this —claude --mcp-configalone is sufficient.Mapping the suggested issues:
handleControlRequestcatch block; fires after the CLI subprocess has exited, inside the consumer's Node process. The stack surfaces normally there. That seems to be a different failure boundary from the loop-handler / Bun observability gap in #56520._onclosePromise-hygiene root cause, and a single transport-layer fix may address both. But the user-visible surfaces materially differ: single in-flight orphan → silent hang, versus multi-transport init storm → swallowed-stack process kill. #56520 also carries an independent loop-handler observability defect that should probably be tracked separately even if the transport fix lands jointly.elicitationHandler.ts:onAbortresolves the Promise correctly with{ action: 'cancel' }but forgets to filter the entry out ofAppState.elicitation.queue. No unhandled rejection, no transport involvement — different layer, different failure mode.Happy to fold any of this into the issue body if it helps.
Adding a corroborating data point and a small refinement to the bisection in the original report.
Same failure mode, stdio + HTTP combination on macOS
Hit this on Claude Code in the VS Code extension over the past week (workspaces under
~/Documents/Github/brik/). Identical signature —Uncaught exception loop detected (10 in 5000ms) — forcing shutdown, exit 1, the loop-guard swallows the underlying stacks. Full crash analysis with three-cycle log evidence: brikdesigns/brik-llm#414.Stdio servers contribute when HTTP transports are present
The original report says "Stdio MCPs alone never trigger the failure regardless of count." Our reproduction confirms the corollary your
bb-browsernote hinted at: adding a single stdio server on top of a baseline of HTTP/streamable-http servers tips the threshold.Configuration:
brik-rag— Pythonfastmcp,mcp.run(show_banner=False, log_level="WARNING"), connects in ~400ms cleanly)ClickUp,Notion,Figma) — all completeinitializesuccessfullyGmail,Google Drive,Google Calendar,Netlify,Slack,Canva— all in "cached needs-auth" after the first cycle)Without the stdio server enabled (5+0 stdio): stable across many workspace opens.
With the stdio server enabled (5+1 stdio): crashes every workspace open, every cycle, even after the OAuth-failers are cached and produce zero visible
[ERROR]lines.Empirical evidence the loop-guard counter persists invisibly
Three crash cycles in a single workspace open (VS Code spawns 3+ Claude channels per workspace, each running full extension init):
| Cycle | OAuth-fail [ERROR] count | stdio MCP connected | Result |
| --- | --- | --- | --- |
| 1 | 6 | yes | crash @ ~600ms after first MCP success |
| 2 | 0 (all cached) | yes | crash @ ~135ms after ClickUp success |
| 3 | 0 (all cached) | yes | crash @ ~200ms after ClickUp success |
Cycle 2 and 3 have no visible
[ERROR]lines in the 5s window before the crash. The threshold is being tripped by exceptions that aren't being logged at any verbosity level we can see. Consistent with your "stacks swallowed" framing and with the DEBUG-levelFailed to stat/Broken symlink/rg errorcontributors that the brik-llm post-mortem identifies.Compounder: multi-channel init
workspace openin VS Code spawns 3+ Claude Code channel processes (chat view + sessions sidebar + secondary sidebar, each running its own extension init independently). If a single channel's init contributes N exceptions within 5000ms, all 3 channels are doing the same thing in parallel — anything per-channel gets multiplied. This is structurally invisible to anyone running the bareclaudeCLI; we only see it because we have logs across~/Library/Application Support/Code/logs/<timestamp>/window1/exthost/Anthropic.claude-code/Claude VSCode.log.Our workaround (not a fix)
After spending a workday on this we moved the affected MCP server (
brik-rag) off the Claude Code MCP path entirely. Wrapped its Voyage+Supabase operations in a standalone CLI and a Claude skill that calls the CLI via Bash. Sidesteps the extension's MCP plumbing without losing the functionality. Details: brikdesigns/brik-llm#417, PR #419.We're not the only team that's going to hit this — every workflow that wants a local stdio MCP plus any claude.ai cloud connector is in the firing line.
Endorsing the proposed fix
The two-step fix in the original report is exactly right from where we're sitting:
.catch()(or wrap intry/await) on every transport_onclose-triggered Promise. Same area as #3184 / #2263.Even just (2) would have saved us a workday. The "exit 1, no stack" path is genuinely unrecoverable for downstream debugging — Bun-compiled binary means
NODE_OPTIONS=--trace-uncaughtis dead, and the post-tool log shows successful connections right up to the crash with nothing in between.Environment
Anthropic.claude-code)This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.