Magic Tools
Pitfall NotesBy CooconSeptember 24, 2026385 views10 min read

Claude Code MCP server Failed to connect: CONNECTION_CLOSED, connection timed out after 30000ms and ENOENT, reproduced one by one

Background

You add an MCP server to Claude Code, run claude mcp list, and get:

✘ Failed to connect — CONNECTION_CLOSED: Connection closed

Or ENOENT, or connection timed out after 30000ms, or ECONNREFUSED, or claude -p refusing to start with Error: Invalid MCP configuration:. All of them mean the same thing — "can't connect" — and none of them tells you what to fix.

This post turns "can't connect" back into specific, checkable causes: which exact error text each cause produces, how to tell them apart in about a minute, and what fixes each one. Every error string below was captured verbatim from real runs on my machine on 2026-09-24.

What can actually go wrong

Getting from a config entry to a usable MCP server takes four steps, and each fails with a different error:

  1. Read the config — does the --mcp-config file exist, is it valid JSON, is the shape right
  2. Spawn / connect — a stdio server spawns a command (missing command → ENOENT); an http/sse server connects to a URL (nothing listening → ECONNREFUSED)
  3. Stay alive — if the process exits right away, all Claude Code sees is a closed pipe: CONNECTION_CLOSED
  4. MCP handshake — the process is alive but must answer initialize before the timeout, or you get CONNECT_TIMEOUT

Steps 3 and 4 are the hard ones: the error tells you where it failed, never why. That's also where the most useful findings of this experiment are.

Approach and what I ruled out

Every server under test is a hand-written stub, three in total:

  • good-server.mjs — a 17-line, zero-dependency MCP stdio server implementing only initialize / tools/list / tools/call, with a single echo tool that returns LAB-ECHO:<text>
  • exit1-server.sh — prints lab-exit1: fatal: missing LAB_API_KEY, refusing to start to stderr, then exit 1
  • hang-server.mjs — prints one line to stderr and then never replies; exits by itself after 300 seconds

Ruled out: testing with real third-party MCP servers. When a real server fails, the failure is tangled up with its own logic, network and dependency versions, so you can't tell which layer broke. npx -y also downloads code at run time, which makes runs unreproducible and adds supply-chain risk. Stubs change exactly one variable per case, and the LAB-ECHO: prefix proves a tool result really came from the server rather than being made up by the model.

Ruled out: testing against my real config. My ~/.claude.json has real servers configured. Mixing them in would make it impossible to tell who is failing, and breaking them would break my daily setup. So each case gets its own empty CLAUDE_CONFIG_DIR (user-scoped servers go into a .claude.json inside that temp directory), and every claude -p run uses --mcp-config <temp json> --strict-mcp-config, except the one run that deliberately drops strict to test what it does.

Two ways of observing:

  • claude mcp list for the health-check text. It's free, so I ran it as often as needed, always with --debug-file, recording exit code and wall time
  • claude -p for the effect on the model side, capped at 8 runs total. Every run gets < /dev/null (otherwise the CLI waits 3 seconds for stdin first, which ruins latency numbers) and --output-format stream-json --verbose, which gives server status and the tool list from the init message, plus duration_ms / num_turns / total_cost_usd from the result

Environment: Claude Code 2.1.280 (native Mach-O binary), macOS 26.3.1, Node v25.9.0. The model actually used by -p was claude-opus-5-5[1m], through a third-party ANTHROPIC_BASE_URL.

The experiments

Case 1: the command doesn't exist

The stdio server's command points at /nonexistent/mcp-server. claude mcp list (exit 0, 0.16s wall):

Checking MCP server health…

ghost: /nonexistent/mcp-server  - ✘ Failed to connect — ENOENT: ENOENT: no such file or directory, posix_spawn '/nonexistent/mcp-server'

Under claude -p, this server ran together with the other broken ones (see case 7). stderr was empty; the init message only said 'status': 'failed'. The debug log said:

2026-09-24T02:06:21.642Z [DEBUG] MCP server "ghost": Connection failed after 3ms (ENOENT): ENOENT: no such file or directory, posix_spawn 'stdio'

Same config, but mcp list shows the real path (posix_spawn '/nonexistent/mcp-server') while the -p log shows posix_spawn 'stdio'. Under -p, the error does not include the path you got wrong — go back to mcp list to see it.

⚠️ Not measured: the exit code and timing of a standalone claude -p run with only this server configured. I forgot to create that config file before the run, so that call captured "config file not found" instead (see case 5), and it used up the last of my 8 -p runs. The only -p data for this server comes from the mixed group: it failed in 3ms.

Case 2: the interpreter isn't on PATH

To simulate launching claude from somewhere without Homebrew on PATH (an IDE, launchd…), I ran claude mcp list under env -i HOME=$HOME PATH=/usr/bin:/bin and changed only the command field:

case command result wall
02a node ✘ Failed to connect — ENOENT: Executable not found in $PATH: "node" 0.69s
02b npx ✘ Failed to connect — ENOENT: Executable not found in $PATH: "npx" 0.67s
02c /opt/homebrew/bin/node (absolute interpreter path) ✔ Connected 0.76s
02d stubs/good-server.mjs (absolute script path, shebang #!/usr/bin/env node) ✘ Failed to connect — CONNECTION_CLOSED: Connection closed 0.70s
02e node, plus "env":{"PATH":"/opt/homebrew/bin:/usr/bin:/bin"} in the config ✔ Connected 0.76s

Real output of four claude mcp list runs under PATH=/usr/bin:/bin Output of claude mcp list run under env -i HOME=$HOME PATH=/usr/bin:/bin — cases 02a / 02c / 02d / 02e in order, taken with tail -1 from each case's stdout.txt.

02d is the one that bites. Switching to an absolute script path looks like a fix, but it only gets you halfway: the env node in the shebang still searches the same PATH. mcp list only says CONNECTION_CLOSED; the real cause is in the debug log:

2026-09-24T02:04:56.121Z [ERROR] MCP server "good" Server stderr: env: node: No such file or directory

Running the script directly under the same PATH gives exit=127 with env: node: No such file or directory on stderr.

Fix (both verified, 02c and 02e): use the absolute path of the interpreter as command, or add the directory to that server's env.PATH — the config's env.PATH is used for command lookup too.

Case 3: the server exits immediately

claude mcp list (exit 0, 0.60s wall):

crashy: /Users/duoduo/4khz/magictools/tmp/2026-09-24-mcp-connect-lab/stubs/exit1-server.sh  - ✘ Failed to connect — CONNECTION_CLOSED: Connection closed

No exit code, no signal. Only with --debug-file do you see what the server actually wrote to stderr:

2026-09-24T02:04:50.394Z [DEBUG] MCP server "crashy": Starting connection with timeout of 30000ms
2026-09-24T02:04:50.397Z [ERROR] MCP server "crashy" Server stderr: lab-exit1: fatal: missing LAB_API_KEY, refusing to start
2026-09-24T02:04:50.398Z [DEBUG] MCP server "crashy": Connection failed after 4ms (CONNECTION_CLOSED): Connection closed

Case 4: the server is alive but never answers the handshake

The default timeout is 30 seconds; mcp list took 30.19s wall:

hangy: /Users/duoduo/4khz/magictools/tmp/2026-09-24-mcp-connect-lab/stubs/hang-server.mjs  - ✘ Failed to connect — MCP server "hangy" connection timed out after 30000ms
2026-09-24T02:04:56.854Z [DEBUG] MCP server "hangy": Starting connection with timeout of 30000ms
2026-09-24T02:05:26.859Z [DEBUG] MCP server "hangy": Connection timeout triggered after 30005ms (limit: 30000ms)
2026-09-24T02:05:26.865Z [DEBUG] MCP server "hangy": Connection failed after 30007ms (CONNECT_TIMEOUT): MCP server "hangy" connection timed out after 30000ms

MCP_TIMEOUT=5000 cuts this to 5.18s wall, with the message changing to connection timed out after 5000ms — handy for reproducing quickly.

Leftover processes: after mcp list timed out and exited, the stub was still running, re-parented to init:

  PID  PPID ELAPSED COMMAND
72132     1   00:36 node /Users/duoduo/4khz/magictools/tmp/2026-09-24-mcp-connect-lab/stubs/hang-server.mjs

After the MCP_TIMEOUT=5000 run there were two of them (72132 and 72250). The stub keeps Node's default SIGTERM behavior (exit on receipt), so its survival means mcp list did not send it SIGTERM before exiting. By contrast, none of the four claude -p runs left a stub process behind.

⚠️ Scope: my hang stub does not exit on stdin EOF. Well-behaved MCP servers usually exit when stdin closes, so the "leaves orphan processes" finding only applies to servers that are stuck and ignore stdin EOF. I didn't test whether a normal server is left behind after a timeout.

Case 5: the --mcp-config itself is wrong

All three variants exit with code 1, empty stdout, in about a tenth of a second — not even the --debug-file log gets created — so they fail while reading the config, before getting anywhere near a request:

Invalid JSON (trailing comma, 0.14s):

Error: Invalid MCP configuration:
MCP config is not a valid JSON

Top-level key misspelled as mcpServer (0.10s):

Error: Invalid MCP configuration:
mcpServers: Invalid input

File path doesn't exist (0.11s — this is the accidental call from case 1):

Error: Invalid MCP configuration:
MCP config file not found: /Users/duoduo/4khz/magictools/tmp/2026-09-24-mcp-connect-lab/configs/ghost-only.json

The syntax error comes without a line number — use jq . <file> or python3 -m json.tool <file> to find it.

What --strict-mcp-config does: I put a user-scoped server called usercanary into the isolated config dir's .claude.json. With strict, init lists only good. Without it, init lists usercanary (source: user) plus good (source: dynamic), and the tools are ['mcp__good__echo', 'mcp__usercanary__echo']. So without strict, --mcp-config is added on top of your existing config, not a replacement.

Also: claude mcp list --help shows -h as its only option. It doesn't accept --mcp-config and only reads saved configuration, so servers passed via --mcp-config not showing up in mcp list is expected — it doesn't mean the config didn't take effect.

Case 6: http / sse pointed at a dead port

deadhttp: http://127.0.0.1:9/mcp (HTTP) - ✘ Failed to connect — ECONNREFUSED: ECONNREFUSED: Unable to connect. Is the computer able to access the url?
deadsse: http://127.0.0.1:9/sse (SSE) - ✘ Failed to connect — SSE error: ECONNREFUSED: Unable to connect. Is the computer able to access the url?

Both took 0.16s wall. Inside a claude -p session, failed remote transports keep reconnecting: deadhttp logged Starting connection 4 times in one run and 3 in the other, each failing instantly with ECONNREFUSED. The debug log also prints environment details such as "HTTP_PROXY":"not set", useful when chasing proxy problems.

Case 7: what broken servers do to the model side

Both groups got the same prompt — call mcp__good__echo("ping"), compute 17*23, list every mcp__ tool — twice each. The "bad" group added the five broken servers from cases 1/3/4/6 on top of the good one.

run wall duration_ms num_turns total_cost_usd
good-1 5.36s 5056 2 0.2702058
good-2 3.90s 3591 2 0.0222042
bad-1 37.90s 7622 2 0.0222042
bad-2 34.52s 4238 2 0.0222042
median, good 4.63s 4323.5 2 0.1462050
median, bad 36.21s 5930 2 0.0222042

All four runs returned the identical result: LAB-ECHO:ping / 391 / mcp__good__echo. In the bad group, all five broken servers showed 'status': 'failed' in init and the tool list contained only mcp__good__echo. The task still completes; broken servers just contribute no tools.

The slowdown is visible in the debug log: in the good group, the first [API REQUEST] comes about 0.2s after the first log line; in the bad group it comes about 30.2s later (02:06:21.489 → 02:06:51.699) — exactly hangy's 30-second timeout. claude -p waits for every server to connect or time out before sending its first API request. ENOENT, exit 1 and ECONNREFUSED all failed within 3–36ms; the one server that never answers the handshake is the only thing slowing startup down.

⚠️ Not verified: good-1 cost $0.2702058, far more than the $0.0222042 of the other three. My guess is a cache write on the first call with this tool set, but I didn't check the usage fields, so the good-group cost median is inflated by that one run — it does not mean broken servers make runs cheaper. What is established: both bad runs cost exactly the same as good-2, so failed servers add no tokens.

Case 8: proving the fixed path actually works

In the good group, init shows {'name': 'good', 'status': 'connected'} and the model actually called the tool. The LAB-ECHO:ping it got back can only come from good-server.mjs:

2026-09-24T02:06:21.661Z [DEBUG] MCP server "good": Successfully connected (transport: stdio) in 23ms
2026-09-24T02:06:55.788Z [DEBUG] MCP server "good": Calling MCP tool: echo
2026-09-24T02:06:55.795Z [DEBUG] MCP server "good": Tool 'echo' completed successfully in 7ms

For the PATH problems, the before/after is in case 2: 02a fails, 02c and 02e both show ✔ Connected.

Results

One CONNECTION_CLOSED, two unrelated causes

claude mcp list and the debug log: exit 1 and a shebang missing node print the same Connection closed Case 3 and case 2d produce identical claude mcp list output (tail -1 stdout.txt). Only grepping the --debug-file log for Server stderr tells them apart: one is missing an env var, the other can't find node.

If you take one thing from this post: when you see CONNECTION_CLOSED, don't guess — get the stderr first:

claude --debug-file /tmp/mcp.log mcp list
grep 'Server stderr' /tmp/mcp.log

36 seconds of wall time, 5.9 seconds of duration_ms

Debug-log timeline and timings for claude -p with one non-handshaking server From the bad-1 claude -p debug log: hangy goes from starting the connection to timing out in exactly 30 seconds, and the first API request follows right after. Below: wall time (meta.txt) and stream-json duration_ms for bad-1 and good-1.

Medians across both groups: wall time 36.21s vs 4.63s, duration_ms 5930 vs 4323.5. If your monitoring or benchmarks only read duration_ms from the JSON output, those 30 seconds never show up.

Cheat sheet: error text → what to check first

Error text (key fragment) Cause One-minute check
ENOENT: no such file or directory, posix_spawn '<path>' (shows as 'stdio' in -p logs) command path doesn't exist ls -l <command>
ENOENT: Executable not found in $PATH: "node" / "npx" the command isn't on the PATH claude was launched with check that environment's PATH; use the interpreter's absolute path or set env.PATH
CONNECTION_CLOSED: Connection closed process started and exited (non-zero exit, shebang can't find its interpreter…) --debug-file, then grep Server stderr; or run command+args directly in a terminal
connection timed out after 30000ms (CONNECT_TIMEOUT) process is alive but never answers initialize reproduce fast with MCP_TIMEOUT=5000; pgrep -fl for leftovers
ECONNREFUSED: Unable to connect. Is the computer able to access the url? (SSE adds a SSE error: prefix) nothing listening on the http/sse port curl -i <url>
MCP config is not a valid JSON --mcp-config syntax error (no line number) jq . <file>
mcpServers: Invalid input wrong top-level key or shape make sure the top-level key is mcpServers
MCP config file not found: <path> wrong --mcp-config path ls <path>
no error, startup just takes 30s longer one server stuck in the handshake compare wall time with duration_ms; check when the first [API REQUEST] appears in the log

Gotchas

  • claude mcp list exits 0 even when servers fail. Every failing case in this experiment exited 0. For health checks in CI or scripts, parse the ✘ in the output; the exit code tells you nothing.
  • An absolute script path isn't enough. The shebang's env node still walks PATH, and you trade ENOENT for the far less obvious CONNECTION_CLOSED.
  • A server that never answers the handshake stalls claude -p for 30 seconds, invisibly to duration_ms. Always record wall time in latency experiments.
  • mcp list can leave orphan processes after a timeout (verified only with a server that ignores stdin EOF). After debugging, check with pgrep -fl <server path>.
  • Mistakes in the experiment itself: I passed env -i HOME=.. PATH=.. to my script as one quoted variable, so the whole string became a single argv element and the first round of 5 cases all failed with No such file or directory. I also didn't check that every config file existed before starting, which cost one of the 8 claude -p runs — and that's why the standalone -p numbers for case 1 are missing.

Get field notes like this every Saturday

Subscribe to Dev Breakfast: daily AI coding picks at 8:00, plus a Saturday roundup of this week's hands-on tests with Claude Code / Codex / local models. Written in Chinese.

Related Articles

Blocking rm -rf in Claude Code: A Deny Rule Stopped 14 Deletes in Two Weeks, 7 Other Spellings Still Get Through, and a Hook That Closes the Gap

To keep Claude Code from deleting things by accident, I added a deny rule, Bash(rm -rf *), to my global settings. Across my sessions from October 7 to 21 it blocked 14 deletes. After each denial the model picked a new directory name 8 times and dropped the delete 6 times. It never tried another spelling. Then I replayed 22 delete commands verbatim against Claude Code 2.1.285 through a stub. rm -fr, rm -r -f, rm -Rf and rm -r deleted the directory in manual, auto and bypass mode alike. /bin/rm, find -delete and bash -c deleted it in manual and bypass mode. In auto mode, rm -fr inside the working directory skips the classifier because it 'would be allowed in acceptEdits mode'. A second, built-in dangerous-rm check fires on targets like $D/*, even in bypass mode, but loses track of a variable assignment when a heredoc comes earlier in the same command. The post ends with a PreToolUse hook that blocks every recursive spelling in all three modes and tells the model what to do instead.

claude-codehooks+5
pitfallsOct 21, 20268 min
626

Claude Code Auto Mode with DeepSeek, GLM or Kimi: What Each "temporarily unavailable" Reason Means and When Retrying Is Pointless (Fault-Injection Test)

On a third-party model, Claude Code auto mode sends one extra classifier request per side-effecting action, from your session model to the same backend: a 132,470-character system prompt, about 29.7K input tokens per check. When it fails, you see "xxx is temporarily unavailable, so auto mode cannot determine the safety of Bash". We injected faults and recorded each message: 500 (server error), 429 (rate-limited), 529 (overloaded) and connection resets (connection failed) are retried 5 times; no reply in 60 s gives (timed out). **No parentheses means a 4xx rejection: tried once, and retrying never helps.** A reply with no verdict gives "could not evaluate"; three in a row bring back manual approval. CLAUDE_CODE_AUTO_MODE_MODEL has no effect. Claude Code 2.1.285, real DeepSeek v4-pro and MiMo v2.5 backends.

claude-codedeepseek+7
pitfallsOct 11, 202612 min
616

Claude Code "Prompt is too long" vs "maximum context length": Why Auto-Compact Works for One and Not the Other (Tested)

Claude Code decides a request was too long by matching the error text. If the backend says prompt is too long or input is too long for requested model, it auto-compacts the conversation and retries — invisible to you. DeepSeek and OpenAI-style gateways say This model's maximum context length is …, which it doesn't recognize: you get API Error: 400 on every turn. Manual /compact works; the better fix is to declare the real window with CLAUDE_CODE_MAX_CONTEXT_TOKENS (non-claude- model IDs) or CLAUDE_CODE_AUTO_COMPACT_WINDOW (claude- IDs) so it compacts before hitting the limit. Tested on Claude Code 2.1.285 against a local stub.

claude-codedeepseek+6
pitfallsOct 5, 20267 min
641

Claude Code Stuck on a Spinner With No Response: It Waits 6 Minutes per Attempt, and 10 Retries Can Hang It for Over an Hour (Tested)

When Claude Code spins without output or sits at Retrying in 0s, the backend is usually not sending anything. Tested on 2.1.285 (API key + ANTHROPIC_BASE_URL): with no response headers it waits 6 minutes (360 s) per attempt before timing out — setting API_TIMEOUT_MS to 600000 or 900000 doesn't change that; only lower values work. With the default 10 retries it can hang for over an hour. A gateway that buffers the reply until generation finishes will never deliver a reply that takes over 6 minutes. Press Esc to interrupt. All tested against a local stub.

claude-codetroubleshooting+5
pitfallsOct 5, 20269 min
550

Published by Magic Tools