MCP Error -32000 Connection Closed: What It Actually Means (2026)
MCP error -32000: Connection closed is generated inside the client SDK and is never sent by a server. It means your MCP server process died. Here is how to recover the real error, and a measured correction to the stdout advice (2026).
On this page
Quick Answer (2026)
MCP error -32000: Connection closed almost never means your MCP server returned an error. It means your server process died, or never started, and the client gave up on the request it had in flight.
The -32000 code is generated inside the MCP SDK on the client side. The Python SDK says so in its own source, in the docstring attached to the constant:
CONNECTION_CLOSED = -32000
"""SDK-only: the connection closed before a response arrived; never emitted on the wire."""
"Never emitted on the wire" is the whole story. No server sent you that code, so no server told you what went wrong. The real message went to the child process's stderr, which the SDKs route to the host application rather than into the protocol, and which most hosts write to a log file you have not opened yet.
So the fix is never to debug -32000. The fix is to go and read the message that -32000 replaced. This guide shows you how to recover it in about two minutes, then works through the five failures that actually kill the process, with a measured correction to the most-repeated piece of advice about this error.
Verified against @modelcontextprotocol/sdk 1.30.0 (TypeScript), mcp 2.1.1 (Python), and MCP protocol revision 2026-07-28, in August 2026.
What we measured
Everything below was reproduced locally against the official TypeScript SDK, with a control in every case. The reproduction set is four servers that differ in exactly one behaviour:
Scroll to see more
| Server | connect() | tools/list | parse errors | Result |
|---|---|---|---|---|
| clean | OK | OK | 0 | baseline |
| writes one junk line to stdout | OK | OK | 1 | survives |
| writes junk to stdout with no newline | OK | fails | 1 | -32001 Request timed out |
| file does not exist, process dies | fails | fails | 0 | -32000 Connection closed |
Two things fall out of that table immediately, and both contradict the usual advice. Process death produces -32000. Stdout pollution does not.
-32000 is not an error your server sent
MCP speaks JSON-RPC 2.0, which reserves the band
-32000 to -32099 for "implementation-defined server-errors". MCP then subdivides that band. The Python wire-types package documents the split in a comment above the constants:
-32000..-32019 implementation-defined
-32020..-32099 reserved for spec-defined codes, allocated sequentially from -32020
-32000 sits in the first range, the part reserved for the SDK's own internal use. It is not in the schema and no server implementation is expected to return it.
Here is where it is manufactured. In the TypeScript SDK, Protocol._onclose() runs when the transport closes, builds one error object locally, and hands that same object to every request still waiting for a reply:
_onclose() {
const responseHandlers = this._responseHandlers;
this._responseHandlers = new Map();
// ...
const error = McpError.fromError(ErrorCode.ConnectionClosed, 'Connection closed');
this._transport = undefined;
this.onclose?.();
for (const handler of responseHandlers.values()) {
handler(error);
}
}
That is the entire origin of the string you pasted into Google. The client noticed the pipe was gone and rejected its own pending promises with a fixed constant. It carries no diagnostic content because it never had any.
Where the real error went
The stdio transport launches your server as a child process. In the TypeScript SDK, the spawn options are:
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
The child's stdin and stdout are piped into the protocol. Its stderr is inherited by default, meaning it flows to the host application's own stderr and never touches MCP at all.
The Python SDK makes the same choice, just more visibly, as a default argument:
async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stderr):
This is deliberate, and the stdio transport specification is explicit about why:
"The server MAY write UTF-8 strings to stderr for any logging purposes including informational, debug, and error messages. The client MAY capture, forward, or ignore the server's stderr output and SHOULD NOT assume stderr output indicates error conditions."
So your stack trace is not lost. It is somewhere specific, and which place depends on the host.
Get the real message back
Pick the row that matches how you are running the server.
Claude Code. Run
claude mcp list for a health line per server, then ask for the detail:
claude mcp list
claude mcp get my-server
Per Anthropic's MCP documentation, a failing server shows ✘ Failed to connect, and claude mcp get surfaces the reason on an Issue: line carrying the status or error code plus any text the server returned. Inside a session, /mcp shows the same status.
Claude Desktop. The host writes per-server logs to disk:
tail -n 20 -F ~/Library/Logs/Claude/mcp*.log
On Windows those live in %APPDATA%\Claude\logs. This is where the inherited stderr lands, and it is almost always where the real message is sitting.
Your own client code. Stop inheriting stderr and capture it. In TypeScript, ask for a pipe:
const transport = new StdioClientTransport({
command: "node",
args: ["server.js"],
stderr: "pipe",
});
transport.stderr?.on("data", (d) => console.error("[server]", d.toString()));
In Python, pass your own writable stream:
from mcp.client.stdio import stdio_client
with open("mcp-server.log", "w") as errlog:
async with stdio_client(params, errlog=errlog) as (read, write):
...
Fastest of all: run the command yourself. Take the exact command and args from your config, paste them into a terminal, and press enter. A server that dies on startup dies just as fast under your own shell, and there it prints the real error to your screen with nothing swallowing it. This one step resolves most -32000 reports, and it is the step people skip.
The five things that actually kill the process
Once you can see stderr, the cause is usually one of these.
1. The command or file does not exist. A typo in a path, a package that was never installed, or a binary that is not on the host's PATH. Our reproduction used exactly this, and the underlying message was an ordinary Cannot find module that never reached the protocol.
2. The working directory is not what you think. The MCP debugging guide warns that for a client-launched server the working directory "may be undefined (like / on macOS) since the client could be started from anywhere". Every relative path in your config and in your .env breaks. Use absolute paths.
3. Environment variables are missing. Servers launched over stdio inherit only a limited, platform-dependent subset of the environment. If your server exits because an API key is unset, it will exit before the handshake finishes. Declare what it needs explicitly:
{
"mcpServers": {
"myserver": {
"command": "mcp-server-myapp",
"env": { "MYAPP_API_KEY": "some_key" }
}
}
}
4. Startup is slower than the client's patience. If the process is alive but still installing or compiling when the client gives up, the transport closes and you get -32000. In Claude Code, raise the startup timeout:
MCP_TIMEOUT=10000 claude
A server fetched with npx on a cold cache is the usual offender here. Installing it locally and pointing command at the real binary removes the variable entirely.
5. The process is crashing after it starts. An unhandled rejection or a segfault in a native dependency will kill it mid-session, not at launch, so the connection looks healthy right up until the first tool call. The spec's guidance is that clients SHOULD restart a server that exits unexpectedly, so an intermittently crashing server can present as intermittent -32000 rather than a hard failure.
What stdout pollution really does
Nearly every write-up on this error lists "printing to stdout" as a primary cause. The specification is certainly strict about it:
"The server MUST NOT write anything to its stdout that is not a valid MCP message."
But we measured what actually happens when you break that rule, and the popular advice is imprecise in a way that will cost you an afternoon.
A server that prints one banner line to stdout and then starts normally connects fine and serves tools fine. The junk line is read, fails JSON.parse, and the SDK's processReadBuffer catches that failure per line and reports it without closing anything:
processReadBuffer() {
while (true) {
try {
const message = this._readBuffer.readMessage();
if (message === null) break;
this.onmessage?.(message);
}
catch (error) {
this.onerror?.(error);
}
}
}
In our run that produced exactly one onerror event, and the tool call after it succeeded. Most hosts do not display onerror, which is why this is invisible rather than fatal.
Where it does bite is when the junk has no trailing newline. Then it is not a separate line, it is a prefix glued onto a real JSON-RPC message, and that message is destroyed. The pending request is never answered. Our interleaved-write server produced:
MCP error -32001: Request timed out
That is -32001, not -32000. Different code, different cause, different fix.
The practical rule: keep stdout clean, because silently discarding protocol traffic is a genuine bug and it will eventually eat a response you needed. But if the code in front of you is -32000, stop looking at your print statements. Your process died. Go and read stderr.
Tell -32000 apart from its neighbours
The codes near -32000 are easy to confuse and each points somewhere different.
Scroll to see more
| Code | Name | What it actually means |
|---|---|---|
-32000 | ConnectionClosed | SDK-internal. The transport closed with requests in flight. |
-32001 | RequestTimeout | SDK-internal. The connection is alive; no reply arrived in time. |
-32021 | MissingRequiredClientCapability | Spec code. The server needs a capability your request did not declare. |
-32022 | UnsupportedProtocolVersion | Spec code. Version mismatch. The data field lists what the server supports. |
-32601 | MethodNotFound | Standard JSON-RPC. The method is not available on that server. |
The first two are yours to debug locally. The rest came over the wire and describe a real disagreement between two live processes.
One more, and it is new. Protocol revision 2026-07-28 moved MCP to a stateless, per-request-metadata model in which the initialize handshake is the legacy path. A modern client is now expected to probe with server/discover first, and the specification warns that era detection cannot be keyed to a single code:
"The fallback MUST NOT be keyed to one specific error code: legacy servers respond to unknown pre-initialize requests with implementation-defined errors (commonly -32601 or -32602) or not at all."
"Or not at all" is the phrase that matters here. A legacy server that simply ignores the probe leaves the client waiting, and a client that closes the transport on that timeout surfaces the result as -32000. If your server worked last quarter and started failing after a client upgrade, check which protocol era each side is speaking before you touch anything else.
Common mistakes
- Searching for
-32000in your server's logs. It is not there and never will be. It is a client-side constant. - Adding retries around the failing call. Restarting a process that dies deterministically just produces the same error more often.
- Reading
connect()succeeded as proof the server is healthy. Our chatty server connected cleanly while silently discarding data on every read. - Assuming stderr means failure. The spec explicitly says clients SHOULD NOT assume stderr output indicates error conditions. Plenty of healthy servers log there.
- Editing config without restarting properly. For Claude Desktop, fully quit and reopen; closing the window is not enough.
Where to go next
If you are wiring a server up for the first time, the walkthrough on adding an MCP server to Claude Desktop covers the config shape this article assumes. If you are writing the server, building an MCP server in Python with FastMCP starts from an empty file. And when you reach the point of asking the user for input mid-tool-call, MCP elicitation covers what changed in the current revision.
FAQ
What does MCP error -32000 Connection closed mean?
It means the MCP client had one or more requests in flight when the transport closed, usually because the server process exited. The code is defined inside the MCP SDKs, not in the MCP schema, and the Python SDK's own source describes it as "SDK-only: the connection closed before a response arrived; never emitted on the wire". Your server did not send it.
Why is there no useful detail in the error?
Because there was no response to take detail from. The client fabricated the error locally after the pipe closed. The real message went to the server process's stderr, which the SDKs inherit or forward to the host rather than routing through the protocol.
Where do I find the real error?
Run the server's exact command and arguments yourself in a terminal. Failing that, read the host's logs: claude mcp get my-server in Claude Code, or tail -n 20 -F ~/Library/Logs/Claude/mcp*.log on macOS for Claude Desktop. In your own client, set stderr: "pipe" in TypeScript or pass errlog= in Python.
Does printing to stdout cause -32000?
Usually not. We measured it: a server that writes one complete junk line to stdout still connects and still serves tool calls, because the SDK catches the parse failure per line and continues. Junk written without a trailing newline corrupts a real message and produces -32001 Request timed out instead. Keep stdout clean regardless, but -32000 specifically points at a dead process.
What is the difference between -32000 and -32001?
-32000 means the connection closed. -32001 means the connection is still open but a request did not get a reply in time. Both are SDK-internal codes in the implementation-defined -32000 to -32019 band, and neither is ever sent by a server.
My server connects and then fails on the first tool call. Is that still -32000?
It can be. A server that starts cleanly and crashes later closes the transport at that moment, and every in-flight request is rejected with the same constant. Check whether the process is still running, and look at stderr from the point of the crash rather than from startup.
Written by
Sofia NievesSofia works on agent evaluation and reliability. She writes about measuring LLM systems before and after they reach production.
Frequently asked questions
What does MCP error -32000 Connection closed mean?
It means the MCP client had one or more requests in flight when the transport closed, usually because the server process exited. The code is defined inside the MCP SDKs, not in the MCP schema, and the Python SDK's own source describes it as "SDK-only: the connection closed before a response arrived; never emitted on the wire". Your server did not send it.
Why is there no useful detail in the error?
Because there was no response to take detail from. The client fabricated the error locally after the pipe closed. The real message went to the server process's stderr, which the SDKs inherit or forward to the host rather than routing through the protocol.
Where do I find the real error?
Run the server's exact command and arguments yourself in a terminal. Failing that, read the host's logs: `claude mcp get my-server` in Claude Code, or `tail -n 20 -F ~/Library/Logs/Claude/mcp*.log` on macOS for Claude Desktop. In your own client, set stderr to "pipe" in TypeScript or pass errlog= in Python.
Does printing to stdout cause -32000?
Usually not. We measured it: a server that writes one complete junk line to stdout still connects and still serves tool calls, because the SDK catches the parse failure per line and continues. Junk written without a trailing newline corrupts a real message and produces -32001 Request timed out instead. Keep stdout clean regardless, but -32000 specifically points at a dead process.
What is the difference between -32000 and -32001?
-32000 means the connection closed. -32001 means the connection is still open but a request did not get a reply in time. Both are SDK-internal codes in the implementation-defined -32000 to -32019 band, and neither is ever sent by a server.
My server connects and then fails on the first tool call. Is that still -32000?
It can be. A server that starts cleanly and crashes later closes the transport at that moment, and every in-flight request is rejected with the same constant. Check whether the process is still running, and look at stderr from the point of the crash rather than from startup.
Related tutorials
How to Add an MCP Server to Claude Desktop (2026)
The exact claude_desktop_config.json setup to connect a local MCP server to Claude Desktop, verify it with the MCP Inspector, and fix a server that will not show up.
How to Build an MCP Server in Python with FastMCP (2026)
A runnable 2026 tutorial: build a write-capable MCP server in Python with FastMCP and the official MCP SDK. Add tools with @mcp.tool(), back them with SQLite, test in the Inspector, and connect it to Claude for Desktop.
MCP Elicitation in 2026: URL Mode, the Guard Pattern, and What Changed
MCP elicitation now has two modes, and the specification revision Google ranks first is superseded. What form mode allows, why URL mode is mandatory for credentials, why ctx.elicit() stopped working on 2026-07-28, and a runnable schema linter in Python and TypeScript.