Magic Tools
Pitfall NotesBy CooconSeptember 24, 202622 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.

Related Articles

DeepSeek Says 1M, Claude Code Says 200K: I Measured Both and Neither Number Is the Real Limit

DeepSeek advertises a 1M context window. Point Claude Code at it and Claude Code reports contextWindow 200000 for the same model. I measured what actually happens. DeepSeek's real ceiling is 1,048,576 tokens — literally 2^20, not one million — and it covers input plus your max_tokens budget, proven with a controlled pair. A needle planted at position zero was retrieved correctly at 1,039,744 tokens. Claude Code refuses client-side long before that, in 25ms with zero API calls, and its gate is not on tokens at all: it fires at roughly 480,000 characters. Feed it high-entropy text and 478,000 characters sails through carrying 309,567 real tokens — 55% past the 200K window it just claimed. And in ordinary use you reach none of these, because Bash output over exactly 30,000 characters never enters context at all.

claude-codelong-context+5
hands-onSep 20, 20267 min
79

Running Claude Code on DeepSeek: Everything Works, But the Cost Readout Lies by 38x

DeepSeek ships an Anthropic-format endpoint, so you can point Claude Code at it with three environment variables. I ran the whole thing on a real machine: every local tool (Read / Write / Bash / Glob / Edit / subagents) works and produces real side effects, so the short answer is yes, it works. The long answer is the part nobody measured — Claude Code bills DeepSeek tokens at Claude Sonnet rates. Ten identical turns: Claude Code reported $1.71, DeepSeek's actual balance dropped ¥0.32 (≈$0.045). That is a 38x over-report, measured against the invoice, not a price list. Also inside: the official docs are wrong about unknown model names (they 400, they don't fall back), v4-pro returns thinking blocks by default so a small max_tokens looks like an empty reply, and one failure that looks like DeepSeek's fault but isn't.

claude-codedeepseek+5
hands-onSep 20, 20268 min
75

Service Up, Ports Open, Certs Valid, VPN Dead for 4 Hours: Tailscale Took Over DNS and Left the Proxy Box With No Upstream

A Los Angeles VPS running sing-box (VLESS-REALITY + Hysteria2) lost its VPN the day after Tailscale was installed. systemctl, ports and certificates were all fine. The root cause was in /etc/resolv.conf: Tailscale manages DNS by default, the tailnet had no global nameservers, and when dhclient renewed its lease tailscaled read an empty resolv.conf and dropped its upstream list. From then on every public domain got SERVFAIL, and the REALITY handshake could not even resolve www.apple.com. Full timeline, the evidence for each step, three fixes, and the rules we added to CLAUDE.md so an AI assistant (Claude Code) does not walk into this again.

claude-codetroubleshooting+8
pitfallsSep 17, 20266 min
114

Reproducing an Injection Chain That Cracks Claude Code Auto Mode: the Model Refuses the Malicious Binary, Then Writes Code That Pwns Itself

In late August embracethered published an attack chain where a plain 'summarize this page' request drags auto-mode Claude Code to a 60–80% code-execution rate — while Anthropic's commissioned third-party test reported 0.00%. I took the chain apart and tested it stage by stage in an isolated environment: the endpoint that nudges the model from WebFetch to curl, and the crux — the model's own 'safe' decision to refuse the unknown binary and write its own Python decoder instead lands straight on a same-name struct.py planted in the extracted directory. The deterministic parts (branching + module-shadow poison + mitigation controls) reproduce fully on my machine with real evidence; the live end couldn't complete a full RCE here because the classifier rate-limited and failed closed — flagged honestly. Ends with mitigations that actually help.

claude-codeauto-mode+5
hands-onAug 31, 20269 min
197

Published by Magic Tools