Why does my MCP server say “connection closed”?
Almost always one of four things: the launcher is missing, a dependency moved under it, the transport does not match, or — on Windows only — the config names a bare bash or sh, which Windows resolves to something that is not the shell you meant. Each one is identifiable in a single command, and the client’s own error message tells you which it is in none of the four cases. Below: what each looks like, the command that names it, and what the fix is.
This is the same check we run against every endpoint on the official MCP registry, once a day — 30,838 of them, last on — pointed at one server instead of all of them, running on your machine. The daily results are at Is that MCP server actually up?.
Run it
Two files, no install, standard library only. They are short enough to read before you run them, which is the recommended order.
curl -O https://referencesource.org/mcp-health/diagnose/mcpdoctor.py
curl -O https://referencesource.org/mcp-health/diagnose/mcphealth.py
# a remote server
python3 mcpdoctor.py https://example.com/mcp
# a local one: everything after -- is the command your config runs
python3 mcpdoctor.py -- uvx mcp-server-time
# or read the config itself and check every server in it
python3 mcpdoctor.py --config .mcp.json --all
It sends no credentials, writes nothing outside a cache directory, and needs no account. mcpdoctor.py is the diagnosis; mcphealth.py is the prober it shares with the daily run, so the remote verdicts here and the verdicts on the scoreboard cannot drift apart.
The four causes
These are not a guess at what usually goes wrong. A search of GitHub for issues with both “MCP server” and “connection closed” in the title alone returned 129 of them on 2026-09-15 — across entirely unrelated projects: a chip vendor’s SDK, an editor, a browser agent, a desktop client, coding agents. Fifteen were read in full. The first three causes below are drawn from that sample, and not one of the fifteen reporters had worked out which cause they had — each thread is “it just says connection closed” followed by somebody else doing the archaeology in the comments. Re-run the search; the count moves, the pattern does not.
The fourth was added on 2026-09-29, and how it arrived is worth saying, because it is the test of whether this page is a list or a live reading. Re-running the same search over 2026-09-12 to 2026-09-28 returned eleven issues; two of them, opened a day apart in unrelated projects, turned out to describe the same Windows-only mistake, and our own tool named neither. Both were read in full and the cause was added to the tool before it was added here.
The launcher is not there
The command in your config cannot start at all, because the program that runs it — uvx, npx, a virtualenv’s python — is not on the PATH the client is using, or a ${VAR} in the command was never expanded. Nothing runs, so nothing answers, so the client says the connection closed.
What the tool prints. Real output, not a mock-up — only the header echoing the command back and the timing line, which differs every run, are trimmed:
NOT WORKING: the command cannot start, because the launcher is not there.
What happened: `uvx` is not on PATH, so the command cannot start at all.
What to do
* Install the launcher, or give the config an absolute path to it.
* Client configs do not inherit your shell's PATH. A command that works in your terminal can still be missing here -- `which uvx` in the terminal, then put that full path in the config.
This is the one that wastes the most time, because the command genuinely does work when you paste it into a terminal. A desktop client launched from the Dock or a service manager does not inherit the PATH your shell builds in .zshrc. The fix is almost always an absolute path in the config rather than a bare command name.
A dependency moved under it
The server starts, throws on import and exits before it can answer. Nothing you wrote changed: a version range like mcp>=1.0.0 picked up a new major release, and uvx and npx resolve dependencies afresh on every run, so the break arrives on a machine that worked yesterday. The traceback goes to stderr — which the client throws away.
What the tool prints. Real output, not a mock-up — only the header echoing the command back and the timing line, which differs every run, are trimmed:
NOT WORKING: it starts and dies, because a dependency broke under it.
What happened: a Python import failed on a NAME, which is what a dependency bumping a major version looks like: the module is there, the symbol moved.
Read off its own output:
ImportError: cannot import name "Server" from "mcp.server"
Its stderr (the part your client discarded), last 4 lines:
Traceback (most recent call last):
File "srv.py", line 3, in <module>
from mcp.server import Server
ImportError: cannot import name "Server" from "mcp.server"
What to do
* Pin the dependency. A range like `mcp>=1.0.0` picks up the next major release on the next clean install, and `uvx`/`npx` do a clean install every time -- which is why this breaks with no change of yours.
* Re-run with the version pinned (`uvx pkg==<last good>`) to confirm before you change the pin for good.
The useful half is the last block: the server’s own stderr. Your client captured it and discarded it, which is the entire reason this presents as a blank “connection closed” rather than as the ImportError it actually is.
The transport does not match
Something is running and healthy, and the two sides are not speaking the same way. Remotely, MCP has two transports — streamable HTTP (one POST per message) and the original HTTP+SSE (GET the stream, read the event: endpoint frame, POST there) — and a client aimed at the wrong one gets nothing back however healthy the server is. Locally, the equivalent is a server that prints a log line to stdout: on stdio, stdout is the wire, and one stray line corrupts the stream.
What the tool prints. Real output, not a mock-up — only the header echoing the command back and the timing line, which differs every run, are trimmed:
IT ANSWERED US, AND IT WILL STILL FAIL IN A STRICT CLIENT: it writes lines to stdout that are not JSON-RPC.
What happened: initialize ok; tools/list returned 1 tool; but it also wrote 1 non-JSON line to stdout, which a strict client treats as a broken stream.
Read off its own output:
[info] starting server, loading config from ~/.config
It speaks MCP 2025-06-18, server x.
Lines it wrote to stdout that are not JSON-RPC (these break the protocol):
[info] starting server, loading config from ~/.config
What to do
* Move that output to stderr. On stdio, stdout carries the JSON-RPC messages and nothing else -- one stray print closes the connection.
* In Python that is `print(..., file=sys.stderr)` or a logging handler on stderr; in Node, `console.error` rather than `console.log`.
Note what that verdict does not say: it does not say “healthy”. Our reader skips a line it cannot parse, so the server works for us — and a strict client closes the connection on it, which is what the person reporting the bug sees. Saying “working” there would have been the friendlier answer and the wrong one. This is also the class our own prober got wrong until 2026-09-08, when it published 143 servers as “answers HTTP, not MCP” that were nothing of the kind.
The launcher is a POSIX shell name, and the machine is Windows
This one only happens on native Windows, which is precisely why it earns a heading: a config naming a bare bash or sh as the command works on every machine its author tried, and on Windows that name is resolved off the Windows PATH — which reaches WSL’s shell, or nothing at all, but never Git Bash. sh is simply absent: Git for Windows ships C:\Program Files\Git\bin\sh.exe and does not put that directory on PATH, so nothing starts. bash is worse, because it does resolve — to %LOCALAPPDATA%\Microsoft\WindowsApps\bash.exe, the placeholder that forwards to WSL — so a process really starts, is handed a C:/… path WSL cannot see, and exits 127.
What the tool prints — a real run, with one fact supplied. This fault happens on native Windows and nowhere else, and there is no Windows machine here, so unlike the three blocks above this one was not produced on the platform it describes. It is still a real run of the real tool: a real bash, really spawned, handed the path out of #1233, really exiting 127 with that line on its stderr. One fact is supplied by hand — what platform.system() answers — because it is the only fact a Mac cannot give. Two lines therefore read as a Mac prints them: the shell resolves to /bin/bash where Windows shows %LOCALAPPDATA%\Microsoft\WindowsApps\bash.exe and adds that it is the WSL placeholder, and Windows prefixes the stderr line /bin/bash: rather than bash:. Everything else is what a Windows user sees, and if you are on Windows you can run it and get the real thing:
NOT WORKING: the config starts it with a bare `bash`/`sh`, which on Windows is not the shell it needs.
What happened: `bash` resolved to /bin/bash, and that shell could not find the script it was handed: exit 127.
Read off its own output:
bash: C:/Users/<user>/.claude/plugins/cache/axiomcode/axiomcode/0.1.0/mcp/launch.sh: No such file or directory
Its stderr (the part your client discarded), last 1 lines:
bash: C:/Users/<user>/.claude/plugins/cache/axiomcode/axiomcode/0.1.0/mcp/launch.sh: No such file or directory
What to do
* Do not name a bare `bash` or `sh` as the command on Windows. The client spawns it from the Windows PATH, not from Git Bash, so it reaches WSL's shell or nothing -- never the shell your script expects.
* The absolute path to Git's shell (`C:\Program Files\Git\bin\sh.exe`) works and is worth trying now to confirm the diagnosis, but it is lost on the next install or plugin update.
* The durable fix is a launcher that exists under the same name on every platform, with the shell logic moved into it -- `node` and a small script, typically. Note that `python3` has the identical trap: on Windows it is a Microsoft Store placeholder that exits 9009, while `python` works there and is missing on machines that only have `python3`. No single interpreter name is portable.
* If your client has started asking you to re-authenticate this server, that is a separate bug and not worth following. A stdio server whose process dies can be misread as an auth failure and cached as one (Claude Code writes it into `mcp-needs-auth-cache.json`); no amount of re-authenticating fixes a launcher that never ran.
The trap is that every ordinary check passes. The script exists, the config is valid JSON, and shutil.which(“bash”) — which is what a launcher-missing check does, including ours — succeeds, because the WindowsApps alias really is on the PATH. So until 2026-09-29 this was diagnosed as nothing at all: it fell through to “the process exited with code 127” with no cause named, which is the one outcome this tool is otherwise built to avoid. It also comes with a decoy. A stdio server whose process dies can be misread by the client as an authentication failure and cached as one, sending you round a re-authentication loop that cannot fix anything — #156 documents exactly that, alongside its own evidence that a direct request with the stored token returned HTTP 200 and all 41 tools. The credentials were never the problem. Nor is the one-word swap most people try next: python3 has the same trap on Windows, where it is a Store placeholder that exits 9009.
For a remote server, it also knows when it started
There are other MCP inspectors. What none of them has is a history. If the address you are diagnosing is one of the 30,838 we probe, the answer carries our own daily series for it — so you can tell “it is broken now” from “it started asking for a key on the 9th”, which are different problems with different fixes. This is one complete run, nothing removed:
$ python3 mcpdoctor.py https://api.inference.sh/mcp
https://api.inference.sh/mcp
----------------------------
WORKING, BUT IT WANTS CREDENTIALS.
What happened: HTTP 401.
Transport that answered: streamable HTTP; HTTP 401
Time to the verdict: 765 ms.
What to do
* This server works; it wants credentials. Not a fault.
* Add the header or token its documentation asks for.
We have been probing this exact address daily, last run 2026-09-15.
8 checks; up 14.3% over 7 days, 25.0% over 30.
Last seen answering with a tool list: 2026-09-09.
Oldest first: 2 days answered, tools listed, then 6 days needs credentials.
https://referencesource.org/mcp-health/
For scale: 4,725 of the 19,394 registry endpoints we probed on 2026-09-15 landed in this same bucket (24.4%) -- https://referencesource.org/mcp-health/
That history travels with the tool. Inside our own repository it is read from the day files; anywhere else it comes from the published feed, fetched once into a cache directory, reused for a day, and skipped in silence if the fetch fails — because a diagnostic tool that prints a network error of its own while you are diagnosing a network problem is worse than one that says nothing.
What this cannot tell you
A cause is never asserted without the line it was read off. If nothing in the server’s stderr matches a signature it knows, it says the process exited with code N and prints the tail verbatim. It does not guess, and a confident wrong diagnosis would be worse than none.
The comparison figures are for remote servers only. Our 30,838 endpoints are addresses on the public registry. A local stdio server is not in that population, so no “this many others are like you” percentage is printed for one — it would invite a comparison the denominator does not support.
It diagnoses starting and connecting, not behaving. Whether a tool returns the right answer, whether its schema is sane, whether it is safe to give it credentials — none of that is in scope here.
It is a common problem, not your mistake. Of the 30,838 registry endpoints probed on 2026-10-23, 2,395 (7.8%) answered HTTP and could not complete an MCP session. A reachability check — DNS, a ping, an HTTP 200 — marks every one of them healthy. Only a real handshake sees it.
The issues this was built from
The issues the causes were read off, so the reasoning can be checked rather than taken. The first five are the 2026-09-15 sample behind the first three causes; the last two are the Windows pair behind the fourth:
openclaw/openclaw#146268— a stdio server whose launcher binary (uvx) was not installed, failing silently as a generic “Connection closed”Atiqul-Islam/genesis#25— a .mcp.json baking an unexpanded ${CLAUDE_PROJECT_DIR}, which the client never substitutesisac322/kwin-mcp#49— mcp>=1.0.0 resolving to a 2.x that removed mcp.server.fastmcp, so the server dies at importsd2k/mcp-tokens#11— a server logging to stdout before its initialize response, corrupting the JSON-RPC streamespressif/esp-idf#19078— a chip vendor's own MCP server crashing on shutdown, which clients see as an immediate CONNECTION_CLOSEDAxiomCodeAI/axiomcodegraph#1233— a manifest launching its server with a bare bash, which on Windows resolves to the WindowsApps placeholder, forwards to WSL’s shell and dies on a Windows-format path with exit 127aboul3ata/lazyweb-skill#156— the same mistake in its other shape — command: "sh", which is not on the Windows PATH at all — and the client then misreading the dead process as an authentication failure