MCP error -32000 is not in the MCP specification

18 August 2026

MCP stdio Session Debugger The print statement that broke the stream. Runs in your browser.

If you search for MCP error -32000 you will find a great deal of advice and very little agreement, because the code is not defined by the Model Context Protocol at all. -32000 is an SDK error code meaning the transport closed, and knowing that is most of the diagnosis: it is never “the server is slow” and it is never “the model refused”. It is “the process on the other end of the pipe is gone”.

Here is the enum from the published @modelcontextprotocol/sdk package, with the SDK’s own comments left in:

// SDK error codes
ConnectionClosed        = -32000
RequestTimeout          = -32001

// Standard JSON-RPC error codes
ParseError              = -32700
InvalidRequest          = -32600
MethodNotFound          = -32601
InvalidParams           = -32602
InternalError           = -32603

// MCP-specific error codes
UrlElicitationRequired  = -32042

Three groups, and the code everybody searches for is in the first one. That grouping is the answer to a question people ask constantly: no, you will not find -32000 in the MCP specification, and no, your server is not required to return it. JSON-RPC 2.0 reserves the range -32000 to -32099 for implementation-defined server errors, and the SDK used the first slot in that range for “the connection went away”.

What it actually means

The client launched your server as a subprocess, connected to its stdin and stdout, and started speaking JSON-RPC. Then the pipe ended. Either the process exited, or it wrote something to stdout that was not a JSON-RPC message and the framing broke.

So the useful reframing is: -32000 is a process problem, not a protocol problem. Nothing about the message tells you which process problem, which is why the error is so unhelpful on its own and why the same code shows up in threads with completely unrelated fixes.

Its neighbour is worth knowing for contrast. -32001 is RequestTimeout, and that one really does mean the server is alive and did not answer in time. If you are getting -32001, stop reading the stdout advice below: your problem is duration, not death.

How it shows up

In Claude Desktop, the server appears in the config and does not appear in the tool list, or appears and immediately disappears. In Claude Code and Cursor, you get the error text with a server name attached and no further detail. In every case the tools simply are not there, and the model behaves as if the server was never configured, because from its point of view it was not.

The confusing part is that it is often intermittent. A server that writes a banner to stdout only on first run, or only when a cache is cold, will connect perfectly most days.

Why the first instinct is wrong

The first instinct is to restart the client, and the second is to raise a timeout. Restarting genuinely does fix a class of these, which is exactly what makes it a bad habit: it teaches you that the error is flaky rather than that your server is.

Raising a timeout does nothing at all for -32000. A timeout is -32001. If the connection closed, waiting longer for it is waiting for a process that has already exited.

The third instinct, and the most expensive, is to start rewriting the server’s tool definitions. Nothing in -32000 is about the content of your messages. The handshake did not fail on semantics, it failed on transport.

The causes, in the order they actually occur

1. Something wrote to stdout that was not a protocol message

This is the big one, and it is the reason the stdio transport catches people who have written network services for years.

On the stdio transport, stdout is the protocol The client launches the server as a subprocess. The client writes JSON-RPC to the server's stdin and reads JSON-RPC from its stdout. Diagnostics belong on stderr, which the client captures as logs. Anything else written to stdout breaks the message framing and closes the connection. MCP client your server launches it as a subprocess stdin: JSON-RPC only stdout: JSON-RPC only stderr client log: put diagnostics here One print to stdout and the stream stops being parseable the client reports error -32000 stdio transport three pipes, and only one of them is yours
The stdio transport gives you three pipes. Two belong to the protocol and one belongs to you, and writing to the wrong one closes the connection.

On stdio, stdout is the wire. It is not a log stream, it is not a console, it is the transport. Every byte your process writes there has to be a framed JSON-RPC message. A single print("starting up"), a console.log left in a handler, a dependency that prints a deprecation notice, a progress bar, a banner: any of them appears in the middle of the stream, the client cannot parse it, and the connection dies.

The traps that catch people who already know this rule:

  • A library printing on import, before any of your code runs.
  • A Python warning, which goes to stderr, and a print inside a dependency, which does not.
  • Node’s console.log in a callback, long after startup, so the server connects fine and dies twenty minutes later.
  • A debugger or profiler attached through an environment variable.
  • npm or uv output when the command in your config is a package runner rather than the server itself, and it decides to install something.

The fix is one line of discipline: every diagnostic goes to stderr. Clients capture stderr and put it in their logs, so you lose nothing by moving it there. In Python, print(..., file=sys.stderr) or a logging handler bound to stderr. In Node, console.error, and check that no dependency is writing to stdout on import.

If you want to see this rather than take my word for it, the stdio session debugger reads a captured session and points at the frame where the stream stopped being JSON-RPC.

2. The command in the config is not runnable the way the client runs it

Your client does not launch the server from your shell, with your shell’s environment, in your project directory. It launches it from wherever it happens to be, usually with a much smaller PATH and often as a GUI process with no login shell.

That produces three failures that all look identical:

  • A relative path in command or args. It works when you test it in the project root, and the client is not in your project root.
  • A binary that is on your PATH only because your shell profile puts it there. Version managers for Node and Python are the usual culprits, and a GUI application never sourced your profile.
  • A command that prompts. npx will offer to install a package it does not have, and there is no terminal to answer, so it waits and then fails.

Use an absolute path, or a command that exists on the system PATH without help. If you are using a package runner, pass the flag that makes it refuse to install rather than prompt.

3. The process started and then exited on its own

Missing environment variable, unreadable config file, database that is not running, port already bound. From the outside all of these look the same as the first two.

The difference is that this class leaves evidence, and the evidence is in stderr. Your client keeps those logs, and reading them is faster than any amount of guessing.

4. Windows, which fails in its own ways

Worth separating out, because the advice above is written by people on macOS and half of it does not transfer.

  • The command is not the command. Node package runners are batch files on Windows, so the executable a client needs to spawn is npx.cmd, not npx. A spawn without shell resolution finds nothing and the process never starts.
  • Spaces in the path. C:\Program Files\ and any user profile with a space in the account name break a command that was quoted for a POSIX shell.
  • Backslashes in JSON. A Windows path in a config file needs escaping, and a config that fails to parse gives you a server that never appears rather than a syntax error you can see.

The diagnosis is the same as everywhere else, but step one becomes decisive: run the exact command, exactly as written in the config, from a directory that is not your project.

Which symptom means which cause

The error text is identical in every case, so match on behaviour instead.

What you observeMost likely causeFastest test
Fails every time, immediatelyCommand not runnable as the client runs itRun the command by hand from another directory
Fails every time, after a pauseServer starts, then exits on a missing dependencyRead the client’s stderr log for that server
Works alone, fails with other servers configuredClient-side startup contentionRemove the others and reintroduce one at a time
Works for a while, dies laterA console.log on a code path that is not startupPipe stdout to a file and search for a line that is not JSON
Only in one client, fine in the InspectorThe client, not your serverReport it upstream with the Inspector result attached
-32001 rather than -32000Not this problem at allIt is a timeout, so look at duration

An ordered way to diagnose it

Do these in order. Each step eliminates a class rather than testing a guess.

  1. Run the exact command from the config, by hand, from a different directory. Not from your project root. If it fails here, you have your answer in the first ten seconds and none of the rest matters.
  2. Watch what it prints on stdout. Anything that is not JSON-RPC is the bug. Pipe stdout to a file and look at the first bytes: if the file does not start with {, stop and fix that.
  3. Check the client’s stderr log for that server. Every major client keeps one per server. This is where the stack trace is.
  4. Reproduce it with the MCP Inspector, which speaks the same stdio transport and shows you the exit code and every frame. It removes the client from the picture entirely, which tells you which side to keep debugging.
  5. Only now, look at your handshake. If the process stays up and the frames are clean, the problem is protocol, and it is a different error class with a different code.

What changed recently, and what did not

Two things worth knowing if you are diagnosing this in 2026.

Some -32000 reports are client-side races rather than server bugs. There are open issues against major clients where a server that starts correctly is torn down during initialisation under specific conditions, particularly with many servers configured at once. If your server passes step 1 and step 4 above cleanly and only fails inside one client, you may be looking at the client. That is worth knowing before you spend a day on your own code.

The transport situation is more forgiving than it was, and stdio is not. Remote servers have moved to Streamable HTTP, which fails in visible ways: an HTTP status, a CORS error, a 404. Stdio has no status codes. It has a pipe that either carries JSON-RPC or does not, and that has not changed and will not.

When this advice is wrong

If your server is remote, most of this does not apply. There is no subprocess and no stdout to corrupt. A closed connection there is a network, proxy or authentication problem, and the useful diagnosis is HTTP-shaped: check the status code, check whether the path is the one your client expects, check the token audience. The two transports share an error code and share almost nothing else.

If you are getting -32000 from a server you did not write and cannot run, skip the diagnosis and check the version pinning. A package runner that resolves to a new release on every start is a moving target, and the fastest fix is to pin the version and see if the error follows.

And if a print statement is genuinely the most useful debugging tool you have, do not give it up. Keep printing, print to stderr, and read the client’s log for that server. The rule is not “do not log”. The rule is that on stdio, stdout belongs to the protocol.