Tutorials
Sofia Nieves10 min read13 views

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).

A diagram of one process emitting two data channels: the protocol channel to the client is severed before it arrives, while a second channel carries its packets away off frame, evoking an MCP server whose real error left over stderr while the client reported only -32000.
A diagram of one process emitting two data channels: the protocol channel to the client is severed before it arrives, while a second channel carries its packets away off frame, evoking an MCP server whose real error left over stderr while the client reported only -32000.
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:

python
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

Serverconnect()tools/listparse errorsResult
cleanOKOK0baseline
writes one junk line to stdoutOKOK1survives
writes junk to stdout with no newlineOKfails1-32001 Request timed out
file does not exist, process diesfailsfails0-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

JSON 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:

text
-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:

js
_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

Node.js The stdio transport launches your server as a child process. In the TypeScript SDK, the spawn options are:

js
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.

Python The Python SDK makes the same choice, just more visibly, as a default argument:

python
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. Claude Run claude mcp list for a health line per server, then ask for the detail:

bash
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:

bash
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:

js
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:

python
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:

json
{
  "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:

bash
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:

js
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:

text
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

CodeNameWhat it actually means
-32000ConnectionClosedSDK-internal. The transport closed with requests in flight.
-32001RequestTimeoutSDK-internal. The connection is alive; no reply arrived in time.
-32021MissingRequiredClientCapabilitySpec code. The server needs a capability your request did not declare.
-32022UnsupportedProtocolVersionSpec code. Version mismatch. The data field lists what the server supports.
-32601MethodNotFoundStandard 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 -32000 in 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.

S

Written by

Sofia Nieves

Sofia 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.