Claude Code MCP server Failed to connect 怎么排查:CONNECTION_CLOSED、connection timed out after 30000ms、ENOENT 逐条实测
问题背景
给 Claude Code 挂上一个 MCP server 之后,claude mcp list 可能会显示这样一行:
✘ Failed to connect — CONNECTION_CLOSED: Connection closed
也可能是 ENOENT、connection timed out after 30000ms、ECONNREFUSED,或者 claude -p 在启动时直接报 Error: Invalid MCP configuration:。这些报错都在说同一件事:「连不上」。但「连不上」本身不是原因,没法照着修。
这篇把「连不上」拆回成一个个可判定的具体原因:每种原因各自产生什么报错原文,看到报错后怎么在一分钟内判断是哪一种、怎么修。所有报错原文都来自 2026-09-24 在本机的真实运行,逐字照抄。
问题分析
MCP server 从配置到可用要走四步,每一步失败的报错都不一样:
- 读配置:
--mcp-config文件能不能找到、JSON 是否合法、结构对不对 - 起进程 / 建连接:stdio server 要 spawn 一个命令(命令找不到就是 ENOENT);http/sse server 要连 URL(端口上没服务就是 ECONNREFUSED)
- 进程活下来:进程起来后如果立刻退出,Claude Code 只能看到管道断了,也就是
CONNECTION_CLOSED - MCP 握手:进程活着,但必须在超时时间内回应
initialize,否则就是CONNECT_TIMEOUT
难排查的是第 3 步和第 4 步:报错只说明失败发生在哪一步,不说明为什么失败。实验里最重要的发现也集中在这两步。
技术方案与选型
被测 server 全部自己写,放在实验目录的 stubs/ 下,共三个:
good-server.mjs:零依赖的最小 MCP stdio server,17 行,只实现initialize/tools/list/tools/call,唯一的工具echo返回LAB-ECHO:<text>exit1-server.sh:往 stderr 打一行lab-exit1: fatal: missing LAB_API_KEY, refusing to start后exit 1hang-server.mjs:往 stderr 打一行后什么也不回,300 秒后自己退出
排除项:为什么不装第三方 MCP server 来测。 真实 server 失败时掺着各自的业务逻辑、网络和依赖版本,报错属于哪一层说不清楚;而且 npx -y 会联网下载,实验无法复现,也有供应链风险。stub 让每个 case 只改变一个变量,LAB-ECHO: 前缀还能证明工具结果确实来自这个 server,不是模型编的。
排除项:为什么不在自己的真实配置上测。 本机 ~/.claude.json 里配了真实 server,一旦混进来就分不清是谁在报错,改坏了还会影响日常使用。所以每个 case 各用一个空的 CLAUDE_CONFIG_DIR(user 级 server 写在这个临时目录的 .claude.json 里);claude -p 一律加 --mcp-config <临时 json> --strict-mcp-config,只有验证 strict 作用的那一次故意不加。
两种观测方式:
claude mcp list用来看健康检查原文,不花钱,可以随便跑。每次都加--debug-file保存完整日志,并记录退出码和墙钟claude -p用来看模型侧的影响,总共只跑 8 次。每次都加< /dev/null(不加会先白等 3 秒 stdin,延迟读数就废了),用--output-format stream-json --verbose同时拿到 init 里的 server 状态、工具列表,以及 result 里的duration_ms/num_turns/total_cost_usd
环境:Claude Code 2.1.280(Mach-O 原生二进制)、macOS 26.3.1、Node v25.9.0;-p 实际使用的模型是 claude-opus-5-5[1m],走第三方 ANTHROPIC_BASE_URL。
实测过程
Case 1:命令不存在
stdio server 的 command 指向 /nonexistent/mcp-server。claude mcp list(退出码 0,墙钟 0.16s):
Checking MCP server health…
ghost: /nonexistent/mcp-server - ✘ Failed to connect — ENOENT: ENOENT: no such file or directory, posix_spawn '/nonexistent/mcp-server'
在 claude -p 里,这个 server 和其它坏 server 放在同一组跑(见 case 7)。stderr 是空的,init 里只显示 'status': 'failed';debug 日志里是:
2026-09-24T02:06:21.642Z [DEBUG] MCP server "ghost": Connection failed after 3ms (ENOENT): ENOENT: no such file or directory, posix_spawn 'stdio'
同一个配置,mcp list 显示的是真实路径 posix_spawn '/nonexistent/mcp-server',-p 的日志里却是 posix_spawn 'stdio'。在 -p 路径下,报错里看不到你写错的那个路径,要回到 mcp list 才看得到。
⚠️ 未测:只挂这一个 server、单独跑一次
claude -p的退出码和耗时没有测到。那一次调用我忘了先生成配置文件,抓到的是「配置文件不存在」(见 case 5),而 8 次-p的预算已经用完。它在-p下的表现只有混合组里的数据:3ms 失败。
Case 2:依赖不在 PATH
模拟从 IDE、launchd 等「PATH 里没有 Homebrew」的环境启动 claude:env -i HOME=$HOME PATH=/usr/bin:/bin 运行 claude mcp list,只改 command 字段:
| case | command |
结果 | 墙钟 |
|---|---|---|---|
| 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(解释器绝对路径) |
✔ Connected |
0.76s |
| 02d | stubs/good-server.mjs(脚本绝对路径,shebang #!/usr/bin/env node) |
✘ Failed to connect — CONNECTION_CLOSED: Connection closed |
0.70s |
| 02e | node,配置里加 "env":{"PATH":"/opt/homebrew/bin:/usr/bin:/bin"} |
✔ Connected |
0.76s |
图:在 env -i HOME=$HOME PATH=/usr/bin:/bin 下运行 claude mcp list 的输出(依次为 02a / 02c / 02d / 02e,用 tail -1 取自各 case 的 stdout.txt)。
02d 是最容易踩的:把脚本改成绝对路径,看起来修好了,其实只修好一半。脚本 shebang 里的 env node 仍然按同一个 PATH 去找 node。mcp list 只显示 CONNECTION_CLOSED,真因在 debug 日志里:
2026-09-24T02:04:56.121Z [ERROR] MCP server "good" Server stderr: env: node: No such file or directory
在同样的 PATH 下直接运行这个脚本,结果是 exit=127,stderr 为 env: node: No such file or directory。
解法(02c、02e 两种实测都有效):command 写解释器的绝对路径;或者在该 server 的 env.PATH 里补上目录,配置里的 env.PATH 也会参与命令查找。
Case 3:server 启动后立刻退出
claude mcp list(退出码 0,墙钟 0.60s):
crashy: /Users/duoduo/4khz/magictools/tmp/2026-09-24-mcp-connect-lab/stubs/exit1-server.sh - ✘ Failed to connect — CONNECTION_CLOSED: Connection closed
报错里既没有退出码,也没有退出信号。加上 --debug-file 后才看到 server 自己写的那行 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:server 活着但不回握手
默认超时 30 秒,mcp list 墙钟 30.19s:
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 可以把超时缩短到 5 秒,墙钟 5.18s,报错变成 connection timed out after 5000ms,适合快速复现。
进程残留:mcp list 超时退出后,stub 进程还活着,已经被 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
再跑一次 MCP_TIMEOUT=5000,残留变成两个(72132、72250)。stub 用的是 Node 默认的 SIGTERM 处理(收到就退出),它能活下来,说明 mcp list 退出前没有给它发 SIGTERM。作为对照,4 次 claude -p 跑完后都没有残留进程。
⚠️ 适用范围:我的 hang stub 不会在 stdin EOF 时退出。正常的 MCP server 一般在 stdin 关闭后会自己退出,所以「会留下孤儿进程」这个结论只适用于卡死、不理会 stdin EOF 的 server。正常 server 超时后会不会残留,这次没有测。
Case 5:--mcp-config 本身写错
三种情况都是退出码 1、stdout 为空、耗时 0.1 秒级,连 --debug-file 指定的日志都没生成,可见在读配置阶段就退出了,没走到发请求那一步:
JSON 语法错(尾逗号,0.14s):
Error: Invalid MCP configuration:
MCP config is not a valid JSON
顶层键写成 mcpServer(0.10s):
Error: Invalid MCP configuration:
mcpServers: Invalid input
文件路径不存在(0.11s,就是 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
语法错的报错不给行号,用 jq . <file> 或 python3 -m json.tool <file> 定位。
--strict-mcp-config 的作用:在隔离 config dir 的 .claude.json 里放了一个 user 级 server usercanary。加 strict 时,init 里只有 good;不加时,init 里是 usercanary(source: user)加 good(source: dynamic),工具列表为 ['mcp__good__echo', 'mcp__usercanary__echo']。也就是说,不加 strict 时 --mcp-config 是叠加在已有配置上,不是替换。
另外,claude mcp list --help 里只有 -h 一个选项,它不接受 --mcp-config,只读已保存的配置。用 mcp list 查不到 --mcp-config 里的 server 是正常现象,不代表配置没生效。
Case 6:http / sse 打到死端口
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?
两次墙钟都是 0.16s。在 claude -p 会话中,远程 transport 失败后会反复重连:两次运行中 deadhttp 的 Starting connection 分别出现 4 次和 3 次,每次都立即 ECONNREFUSED。debug 日志还会打印 "HTTP_PROXY":"not set" 这类环境信息,排查代理问题时可以直接看。
Case 7:坏 server 对模型侧有什么影响
两组用同一个 prompt:调用 mcp__good__echo("ping")、计算 17*23、列出所有 mcp__ 工具,各跑 2 次。坏组在好 server 之外,再挂上 case 1/3/4/6 的五个坏 server。
| run | 墙钟 | 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 |
| 中位数 good | 4.63s | 4323.5 | 2 | 0.1462050 |
| 中位数 bad | 36.21s | 5930 | 2 | 0.0222042 |
4 次的结果完全一致:LAB-ECHO:ping / 391 / mcp__good__echo。坏组 init 里 5 个坏 server 都是 'status': 'failed',工具列表里只有 mcp__good__echo。任务照常完成,坏 server 只是没有贡献工具。
慢的原因在 debug 日志里:good 组从第一行日志到第一次 [API REQUEST] 约 0.2 秒,bad 组约 30.2 秒(02:06:21.489 → 02:06:51.699),正好等于 hangy 的 30 秒超时。claude -p 要等所有 server 连上或超时后,才发出第一个 API 请求。 而 ENOENT、exit 1、ECONNREFUSED 这几种失败都在 3~36ms 内结束,真正拖慢启动的只有不回握手的那一个。
⚠️ 未核实:good-1 的 $0.2702058 明显高于其它三次的 $0.0222042。我推测是这组工具定义第一次调用时的缓存写入,但没有读 usage 字段核实,所以成本中位数被它拉高,不代表「挂坏 server 更便宜」。能确认的只有:bad 两次与 good-2 的成本完全相同,连接失败的 server 没有增加 token。
Case 8:修复后链路真的通了
good 组 init 里是 {'name': 'good', 'status': 'connected'},模型实际调用了工具,拿到的 LAB-ECHO:ping 只可能来自 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
PATH 类问题的修复对照见 case 2:02a 失败,02c / 02e 修好后都是 ✔ Connected。
实践效果
同一句 CONNECTION_CLOSED,两个完全不同的原因
图:case 3 与 case 2d 的 claude mcp list 输出(tail -1 stdout.txt)完全相同;对 --debug-file 日志 grep Server stderr 后才分出原因:一个缺环境变量,一个找不到 node。
这是整篇最有用的一条经验:看到 CONNECTION_CLOSED 时别去猜,先拿 stderr:
claude --debug-file /tmp/mcp.log mcp list
grep 'Server stderr' /tmp/mcp.log
墙钟 36 秒,duration_ms 只报 5.9 秒
图:bad-1 的 claude -p debug 日志中,hangy 开始连接到超时正好 30 秒,第一次 API 请求紧跟在超时之后;下方是 bad-1 / good-1 的墙钟(meta.txt)与 stream-json 里的 duration_ms。
两组中位数:墙钟 36.21s vs 4.63s,duration_ms 5930 vs 4323.5。如果只看 JSON 里的 duration_ms 做监控或压测,这 30 秒会完全漏掉。
速查表:看到哪句报错,先查什么
| 报错原文(关键片段) | 原因 | 一分钟判定 |
|---|---|---|
ENOENT: no such file or directory, posix_spawn '<路径>'(-p 日志里显示为 'stdio') |
command 路径不存在 | ls -l <command> |
ENOENT: Executable not found in $PATH: "node" / "npx" |
启动 claude 的环境 PATH 里没有这个命令 | 看启动环境的 PATH;改用解释器绝对路径或配 env.PATH |
CONNECTION_CLOSED: Connection closed |
进程起来就退出了(exit 非 0、shebang 找不到解释器……) | --debug-file 后 grep Server stderr;或把 command+args 复制到终端直接运行 |
connection timed out after 30000ms(CONNECT_TIMEOUT) |
进程活着但不回 initialize |
MCP_TIMEOUT=5000 快速复现;pgrep -fl 查残留 |
ECONNREFUSED: Unable to connect. Is the computer able to access the url?(SSE 前面多一个 SSE error:) |
http/sse 端口上没有服务 | curl -i <url> |
MCP config is not a valid JSON |
--mcp-config 语法错(不给行号) |
jq . <file> |
mcpServers: Invalid input |
顶层键或结构写错 | 检查顶层是否为 mcpServers |
MCP config file not found: <路径> |
--mcp-config 路径写错 |
ls <path> |
| 没有报错,只是启动慢 30 秒 | 有一个 server 卡在握手 | 对比墙钟与 duration_ms;看日志里第一次 [API REQUEST] 的时间 |
踩坑
claude mcp list连接失败也返回 0。 实验里所有失败 case 的退出码都是 0。CI 或脚本里做健康检查,要解析输出里的✘,不能看退出码。- 只把脚本改成绝对路径不够。 shebang 里的
env node仍然走 PATH,结果从 ENOENT 变成更隐蔽的 CONNECTION_CLOSED。 - 不回握手的 server 会拖住
claude -p30 秒,而且duration_ms看不出来。做延迟实验一定要记墙钟。 mcp list超时后可能留下孤儿进程(只在不理 stdin EOF 的 server 上验证过),调试完用pgrep -fl <server 路径>检查一遍。- 实验本身的坑:我把
env -i HOME=.. PATH=..整串放进一个带引号的变量传给脚本,结果整串成了一个 argv 元素,第一轮 5 个 case 全报No such file or directory白跑了;另外开跑前没检查配置文件是否都存在,浪费了一次claude -p预算,case 1 的单独-p读数也因此缺失。