AI how-to notes · 2026-09-17 · last run 2026-09-17 · 한국어 원문
When an MCP server says ✘ Failed to connect — telling a bad launch path (CONNECTION_CLOSED) from stdout pollution (connection timed out)
The short version
In our reproduction (Claude Code 2.1.274, Windows), registering a file that does not exist made claude mcp list report ✘ Failed to connect — CONNECTION_CLOSED: Connection closed. Registering an executable that does not exist produced identical wording in claude mcp get. When a server wrote characters to stdout without a newline and corrupted the initialize response line, we got connection timed out after 30000ms instead. A server with a single console.log showed as ✔ Connected in both placements (at startup and inside the tool), so this listing alone never revealed it.
Symptoms
claude mcp addprintsAdded stdio MCP server …and exits. It does the same with a wrong path.claude mcp listorclaude mcp get <name>shows✘ Failed to connect, andgetadds anIssue:line.- The
Issue:text was one of two things in this reproduction:CONNECTION_CLOSED: Connection closed, orMCP server "<name>" connection timed out after 30000ms.
What we tested on
| Item | Value |
|---|---|
| OS | Windows 11 Home (10.0.26200) |
| Shell | Git Bash (PowerShell 7 for process inspection only) |
| Node.js | v24.19.0 |
| Claude Code | 2.1.274 (Claude Code) |
| Server | the add-tool server built with @modelcontextprotocol/sdk 1.30.0 (confirmed via npm ls). Full source is in the minimal MCP server article, and it is also preserved as cat server.mjs output in this article's raw material |
| Project folder | C:\Users\User\AppData\Local\Temp\longtail_D2\proj2, every server added with --scope project |
| Approval | claude --settings '{"enableAllProjectMcpServers": true}' mcp … |
The server variants used in the reproduction
Variants that add a log line to the working server.mjs, plus cases where only the registered argument or command is wrong.
| Registered name | What changed |
|---|---|
d2ok | nothing |
d2missing | argument points at a nonexistent file, no_such_server.mjs |
d2nocmd | command points at a nonexistent executable, C:/nope/node.exe |
d2logstart | console.log('add-server starting'); before connecting |
d2logtool | console.log('add called', a, b); inside the tool |
d2stderr | the two lines above, as console.error |
d2nonlstart | process.stdout.write('add-server starting '); before connecting (no newline) |
d2nonltool | process.stdout.write('add called '); inside the tool (no newline) |
The variant with a log inside the tool looks like this (diff server.mjs server_log_tool.mjs).
14,16c14,17
< async ({ a, b }) => ({
< content: [{ type: 'text', text: String(a + b) }]
< })
---
> async ({ a, b }) => {
> console.log('add called', a, b);
> return { content: [{ type: 'text', text: String(a + b) }] };
> }Case 1 — the launch path is wrong
The add command succeeded without checking the path.
$ claude mcp add d2missing --scope project -- node C:/Users/User/AppData/Local/Temp/longtail_D2/srv/no_such_server.mjs
Added stdio MCP server d2missing with command: node C:/Users/User/AppData/Local/Temp/longtail_D2/srv/no_such_server.mjs to project configTurn approval on and check the status:
d2missing:
Scope: Project config (shared via .mcp.json)
Status: ✘ Failed to connect
Issue: CONNECTION_CLOSED: Connection closed
Type: stdio
Command: node
Args: C:/Users/User/AppData/Local/Temp/longtail_D2/srv/no_such_server.mjsd2nocmd, where the executable itself is wrong (Command: C:/nope/node.exe), gave the same Issue: CONNECTION_CLOSED: Connection closed. That wording alone cannot tell you whether the file or the command is missing.
The cause shows up when you run the registered command yourself. Launching the nonexistent file from our test script, node wrote this to stderr and exited immediately (92ms) with code 1.
Error: Cannot find module 'C:\Users\User\AppData\Local\Temp\longtail_D2\srv\no_such_server.mjs'
at Module._resolveFilename (node:internal/modules/cjs/loader:1517:15)
…
code: 'MODULE_NOT_FOUND',
requireStack: []
}
Node.js v24.19.0
[child exit 92ms] code=1 signal=nullConnecting with the official SDK client (Client + StdioClientTransport) raised McpError: MCP error -32000: Connection closed. claude mcp get d2missing took about 4.2 seconds.
The nonexistent executable behaved differently. Typed straight into Git Bash it gave bash: line 1: C:/nope/node.exe: No such file or directory (exit code 127), and launched through node's spawn the process never started at all — an error event with ENOENT spawn C:/nope/node.exe ENOENT. Even so, Claude Code's Issue: line matched the missing-file case exactly.
Case 2 — the server writes plain text to stdout
The stdio section of the MCP specification (modelcontextprotocol.io/specification/2025-06-18/basic/transports, checked 2026-09-17) says "The server MUST NOT write anything to its stdout that is not a valid MCP message.", and that logs may go to stderr. The build-a-server doc (modelcontextprotocol.io/docs/develop/build-server, checked the same day) says in its TypeScript section "Never use console.log()", and to use console.error() instead.
We looked at what actually breaks, three ways.
Raw JSON-RPC (`rpc_test.mjs`, which flags non-JSON lines and moves on)
$ node rpc_test.mjs server_log_start.mjs
[send 12ms] initialize
[stdout 1] (NOT-JSON) add-server starting
[stdout 2] (JSON) {"result":{"protocolVersion":"2025-06-18",…},"jsonrpc":"2.0","id":1}
…
[stdout 4] (JSON) {"result":{"content":[{"type":"text","text":"5"}]},"jsonrpc":"2.0","id":3}
$ node rpc_test.mjs server_write_nonl_start.mjs
[send 11ms] initialize
[stdout 1] (NOT-JSON) add-server starting {"result":{"protocolVersion":"2025-06-18",…},"jsonrpc":"2.0","id":1}
[timeout 10021ms] id=1 no response (10s)console.log writes a trailing newline, so the log took its own line and the JSON response behind it stayed intact. process.stdout.write without a newline left the response glued onto the end of the log, making that whole line non-JSON. This script never received a response for id=1.
The official SDK client (the installed 1.30.0)
# server_log_start.mjs
[client onerror] SyntaxError: Unexpected token 'a', "add-server starting" is not valid JSON
[connect] ok
[listTools] add
[callTool] [{"type":"text","text":"5"}]
# server_write_nonl_start.mjs
[client onerror] SyntaxError: Unexpected token 'a', "add-server"... is not valid JSON
[catch] Error: connect exceeded 10sThe SDK client reported a SyntaxError through onerror for every non-JSON line. With a single newline-terminated log, connecting and calling carried on regardless. With the response glued on, it stalled at the connect step (the 10 seconds is our own limit). server_write_nonl.mjs, which writes without a newline inside the tool, connected and got through listTools, then hit the 10-second limit at callTool.
What Claude Code's status display showed
$ claude --settings '{"enableAllProjectMcpServers": true}' mcp list
Checking MCP server health…
d2ok: node C:/Users/User/AppData/Local/Temp/longtail_D2/srv/server.mjs - ✔ Connected
d2missing: node C:/Users/User/AppData/Local/Temp/longtail_D2/srv/no_such_server.mjs - ✘ Failed to connect — CONNECTION_CLOSED: Connection closed
d2logstart: node C:/Users/User/AppData/Local/Temp/longtail_D2/srv/server_log_start.mjs - ✔ Connected
d2logtool: node C:/Users/User/AppData/Local/Temp/longtail_D2/srv/server_log_tool.mjs - ✔ Connected
d2stderr: node C:/Users/User/AppData/Local/Temp/longtail_D2/srv/server_log_stderr.mjs - ✔ Connected
d2nonlstart: node C:/Users/User/AppData/Local/Temp/longtail_D2/srv/server_write_nonl_start.mjs - ✘ Failed to connect — MCP server "d2nonlstart" connection timed out after 30000ms
d2nonltool: node C:/Users/User/AppData/Local/Temp/longtail_D2/srv/server_write_nonl.mjs - ✔ Connected(Lines for servers in other scopes are filtered out.) Checking all seven in one command took 57.8 seconds; the single claude mcp get d2nonlstart took 33.6 seconds. The other get calls ran 4 to 6 seconds.
d2logtool and d2nonltool, which only pollute stdout during a tool call, both read ✔ Connected. We did not check how far Claude Code goes with its requests during a status check. What this run establishes is that a ✔ Connected in this listing can still be a server that breaks when a tool is called.
Does fixing it restore the connection?
We registered server_fix.mjs, whose line 19 was process.stdout.write('add-server starting ');, as d2fix. Before the fix its status was Issue: MCP server "d2fix" connection timed out after 30000ms. We changed line 19 and nothing else.
19:process.stdout.write('add-server starting '); ← before
19:console.error('add-server starting'); ← afterAfter the fix, the direct calls put the log on its own [stderr] add-server starting line and all three responses came back as JSON. We ran claude mcp get d2fix immediately afterwards:
d2fix:
Scope: Project config (shared via .mcp.json)
Status: ✔ Connected
Type: stdio
Command: node
Args: C:/Users/User/AppData/Local/Temp/longtail_D2/srv/server_fix.mjsResults
| Server | Raw JSON-RPC | SDK client | claude mcp list/get |
|---|---|---|---|
d2ok | all three responses fine | fine | ✔ Connected |
d2missing | no response, node exit code 1 (MODULE_NOT_FOUND) | MCP error -32000: Connection closed | ✘ Failed to connect / CONNECTION_CLOSED: Connection closed |
d2nocmd | (node spawn) process never started, ENOENT | not run | get: ✘ Failed to connect / CONNECTION_CLOSED: Connection closed |
d2logstart | 1 non-JSON line + responses fine | 1 onerror, call succeeded | ✔ Connected |
d2logtool | 1 non-JSON line at call time + responses fine | 1 onerror, call succeeded | ✔ Connected |
d2stderr | log went to stderr, responses fine | fine | ✔ Connected |
d2nonlstart | initialize response line corrupted, id=1 never received | connect exceeded 10s | ✘ Failed to connect / connection timed out after 30000ms |
d2nonltool | tools/call response line corrupted, id=3 never received | callTool exceeded 10s | ✔ Connected |
d2fix (after the fix) | all three responses fine | not run | ✔ Connected |
Here is the distinction this reproduction supports. CONNECTION_CLOSED came up in two cases: a file that does not exist (run it directly and node exits 1 immediately) and an executable that does not exist (run it directly and the launch itself is ENOENT). connection timed out after 30000ms came from the server whose startup log ends up on the same line as the initialize response, where the direct calls also failed to read that line as JSON. So: on CONNECTION_CLOSED, type the registered command into a shell as-is; on a timeout, drive the server over JSON-RPC yourself and inspect the stdout lines. That order matches what we saw. The official docs (code.claude.com/docs/en/mcp, checked 2026-09-17) explain that on ✘ Failed to connect, claude mcp get <name> shows the failure detail on the Issue: line.
What didn't work
- Registering the first seven, we got the shell quoting wrong and every argument came through as
C:\Users\User\AppData\Local\Temp\longtail_D2\srv$f. Even then, all sevenclaude mcp addcalls ended withAdded …. We removed them all and re-registered with the paths expanded. - A single newline-terminated
console.lognever reproduced a failure inclaude mcp list. Among the stdout-log variants, the only one that failed to connect was the newline-freeprocess.stdout.writeat startup. - During cleanup, the first command for finding leftover node processes had broken quoting and did not run properly. Re-running it from a script file confirmed that zero node processes contained
longtail_D2.
What we did not check
- What happens, and what the error text is, when Claude actually calls the
d2logtoolandd2nonltooltools in an interactive Claude Code session. - How this appears on the
/mcpscreen, and where Claude Code records a server's stderr. - Whether changing
MCP_TIMEOUTchanges the 30000ms wording. - How clients on SDK versions other than the installed 1.30.0 treat non-JSON lines. Servers in other languages, such as Python.
- Multi-line logs (printing an expanded object) or logs that look like JSON.
- Registering from Command Prompt (cmd.exe) or PowerShell, and macOS or Linux.