MCP error -32000: Connection closed means your server crashed before it said hello
The client reports a transport error and nothing else. The server's real traceback went to a stderr nobody is showing you. Pipe a raw initialize handshake into the exact command from your config and the crash prints in your terminal.
TL;DR · THE FIX
MCP -32000: Connection closed is almost never a transport problem. A stdio MCP server is a subprocess the client spawns, so if it dies on import (missing dependency, syntax error, unset env var, wrong interpreter) the pipe closes before the handshake and the client can only report that the pipe closed. The traceback exists, on the subprocess's stderr, which the client is not showing you. Run the exact command from your config yourself and pipe one initialize message into it: echo the JSON-RPC line into the command and read the crash.
The symptom
You add a server to your MCP config, restart the client, and get:
MCP error -32000: Connection closed
That is the whole message: no file, no line, no exception type. The client may retry a few times and hand you the same string again. Restarting does not help, and rewriting the config, the natural next move, does not help either, because the config is usually fine.
What the error means
A stdio MCP server is a subprocess the client launches, and it talks JSON-RPC over that subprocess’s stdin and stdout, one JSON object per line. So startup is: the client runs the command and args from your config, writes an initialize request to the process’s stdin, and waits for an initialize response on its stdout.
-32000: Connection closed means that response never came and the pipe went away, which in practice means the process exited. The overwhelmingly common reason a server process exits immediately is that it crashed while importing, before a single line of protocol code ran. The client has one fact, that the pipe closed, and it is reporting exactly that. The information you want is on the subprocess’s stderr, which the client is either discarding or filing somewhere you are not looking.
The probe
Do the client’s job by hand. Take the exact command and args out of your config and pipe one initialize message into them:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0.0.0"}}}' \
| python -m my_mcp_server
On Windows PowerShell:
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0.0.0"}}}' `
| python -m my_mcp_server
A healthy server answers on stdout with a single JSON line containing its serverInfo and capabilities, then waits. A broken one prints the thing the client would not show you:
Traceback (most recent call last):
File ".../my_mcp_server/__main__.py", line 3, in <module>
from .tools import register
File ".../my_mcp_server/tools.py", line 8, in <module>
import httpx
ModuleNotFoundError: No module named 'httpx'
Ten seconds.
Two details make the probe trustworthy, and both come down to using the config’s exact strings rather than something close to them. Use the same interpreter or binary the config names: a server that works when you run python -m ... in your activated virtualenv and fails from the client is very often one the client is starting with a different python, so if the config says C:\Python312\python.exe, probe with that and not with whatever python resolves to in your shell. And use the same environment: if the config sets env, export those variables in the probing shell too, and if it does not, unset the ones your shell happens to have. A server that reads os.environ["API_KEY"] at module scope and raises KeyError on import produces this exact error, and it will work in your terminal and fail from the client purely because your terminal has the key.
The usual causes
In rough order of how often they turn out to be it: a missing dependency, because the server was installed into a different environment than the interpreter the client is using, which is the most common one by a distance; a missing or empty environment variable read at import time, which is why config should be read lazily, inside the tool call or a startup function, so the failure becomes a protocol error you can see instead of a dead pipe; the wrong interpreter or a bad path, especially on Windows and especially with spaces in it, where the process fails to start at all and the client cannot tell that apart from crashing at import; a plain syntax error, if you edited the server and did not run it; and something printed to stdout at import.
That last one is nastier than a crash, because the process survives. A stray print(), a banner from a library, a warning routed to stdout: any of it lands in the middle of the JSON-RPC stream and corrupts the handshake. On a stdio MCP server, stdout belongs to the protocol, and every diagnostic goes to stderr. The probe makes this one obvious too:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0.0.0"}}}' \
| python -m my_mcp_server 2>/dev/null
With stderr discarded, the only thing left on stdout should be JSON. A “Loading model…” line or a deprecation warning there is your bug.
The fix
There is no single fix, because -32000 is a category rather than a defect. The probe gives you the actual error, and then you fix that: install the package into the right environment, pin the interpreter’s absolute path in the config, move config reads out of import scope, or send your logging to stderr.
What is worth changing permanently is on the server side. Make it capable of failing out loud:
import logging, sys
logging.basicConfig(stream=sys.stderr, level=logging.INFO) # never stdout
def main():
try:
run_server()
except Exception:
logging.exception("MCP server failed to start")
raise
And keep the one-line probe somewhere you can find it.
The lesson
When an error message describes only the transport, look at the process. The client told the truth: the connection closed. It had no way to know why, because the reason arrived on a channel it does not surface, and re-reading the config will not produce information the config does not contain.
Reproduce the client’s exact invocation by hand, with the client’s exact environment, and watch the parts the client throws away. Any time a tool spawns a subprocess for you, its error reporting is limited to what the pipe told it, and running the subprocess yourself is the cheapest way to see everything it discarded.
Discussion
Powered by GitHub. Sign in to leave a comment.