MCP error -32000 is ConnectionClosed in the client SDK, raised locally when the stdio transport's onclose fires. It is not an MCP protocol code: JSON-RPC reserves -32000 to -32099 for implementation-defined server errors, and the 2026-07-28 MCP revision leaves -32000 through -32019 implementation-defined and grandfathered while reserving -32020 through -32099 for the spec. It is also not a timeout, which is -32001 (RequestTimeout). A public bug report against Claude Code CLI 2.1.19 shows a connection failing after 6544ms against a 30,000ms budget, which rules timeouts out for that class of failure. Six causes account for nearly all of it: stdout pollution breaking newline-delimited framing, a binary the client cannot resolve, a spawned environment missing variables and a working directory, cold start exceeding the connect window, handshake ordering on 2025-11-25 and earlier, and runtime or permission drift that kills the process before the first frame. Fix the transport, then fix the operations, because a dead MCP server produces a confident answer rather than an error.
MCP error -32000: Connection closed
That string means one thing: the process on the other end of the transport is gone. MCP error -32000 is not a timeout, not a refused socket, and not an MCP protocol error. In the MCP TypeScript SDK it is ConnectionClosed, raised locally by the client when the transport's onclose fires. In the stdio case, where the client spawns the server as a child process, the server did not send this error. It sent nothing, exited, and your SDK manufactured a code to describe the silence.
So the error text is not the diagnosis. The diagnosis is in the debug log, in the exit path of the process you spawned, and in the environment the client handed it. Below are six causes ordered by how often the log points at them, plus the part other guides skip: what your agent does after the server dies.
What MCP error -32000 actually is, and why the client tells you nothing
JSON-RPC 2.0 reserves -32768 through -32000 for pre-defined errors and carves -32000 to -32099 out for implementation-defined server errors. So -32000 is the base of the vendor block, with no standardized meaning at the protocol layer.
MCP made that explicit in the 2026-07-28 revision, which added an error code allocation policy: -32000 through -32019 stays implementation-defined with existing SDK usage grandfathered in, and -32020 through -32099 is reserved for the specification. Every guide claiming "the MCP spec defines -32000" is wrong, and the distinction tells you which side of the wire to debug. Spec-reserved codes come from a server alive enough to answer. -32000 is generated by your own client about a server that is not.
Two traps. -32000 and -32001 get used in opposite directions across write-ups, and several pages ranking for this error recommend raising MCP_TIMEOUT as the fix. That is wrong in the common case. And "-32000 or -32002" appears in a surprising number of guides: -32002 was resource-not-found in older revisions, renumbered to -32602 in 2026-07-28.
Check your TypeScript SDK major version before grepping. v1 exposes ErrorCode.ConnectionClosed on McpError. v2 re-partitioned: McpError became ProtocolError carrying ProtocolErrorCode for errors that cross the wire, while RequestTimeout and ConnectionClosed moved to SdkErrorCode on a new SdkError for local errors that never do.
// Version-agnostic. v1: McpError + ErrorCode.ConnectionClosed
// v2: SdkError + SdkErrorCode.ConnectionClosed
const isConnectionClosed = (e: unknown) =>
typeof e === "object" && e !== null && (e as { code?: number }).code === -32000;The shift underneath that rename is covered in our walkthrough of the stateless MCP spec migration, and if the protocol is new to you, start with what MCP is and how it works.
| Code | Origin | Meaning | Produced by |
|---|---|---|---|
| -32000 | SDK, implementation-defined | ConnectionClosed: the transport's onclose fired | Your client, locally |
| -32001 | SDK, implementation-defined | RequestTimeout: peer alive, no answer inside the deadline | Your client, locally |
| -32601 | JSON-RPC pre-defined | Method not found | The server, over the wire |
| -32602 | JSON-RPC pre-defined | Invalid params; also resource-not-found since 2026-07-28 | The server, over the wire |
| -32020 | MCP spec reserved (2026-07-28) | HeaderMismatch | The server, over the wire |
| -32021 | MCP spec reserved (2026-07-28) | MissingRequiredClientCapability | The server, over the wire |
| -32022 | MCP spec reserved (2026-07-28) | UnsupportedProtocolVersion | The server, over the wire |
Read the failure before you guess: the 60-second triage
Do not touch a config file until you have the log.
claude mcp list # health per server claude mcp get my-server # resolved config, including which scope won claude --debug mcp # server stderr plus the connection lifecycle
In-session, /mcp shows per-server status and a Reconnect action. Health states are Connected, Needs authentication, Failed to connect, Pending approval, and Rejected. The last two are configuration, not transport: as of CLI v2.1.196, project approvals are read only from settings files not checked into the repository until the workspace is trusted, so a cloned repo cannot approve its own servers and a committed enableAllProjectMcpServers is ignored in an untrusted folder.
Claude Desktop writes files instead: ~/Library/Logs/Claude on macOS, %APPDATA%\Claude\logs on Windows, as mcp.log plus one mcp-server-<NAME>.log per server. Documented watch command: tail -n 20 -F ~/Library/Logs/Claude/mcp*.log.
Two lines decide almost everything. A public bug report against Claude Code CLI 2.1.19, filed January 2026, shows them:
Starting connection with timeout of 30000ms Connection failed after 6544ms: MCP error -32000: Connection closed
Failure at 6.5 seconds against a 30-second budget is not a timeout, and no MCP_TIMEOUT value changes it. That issue was closed as not planned and was itself a re-filing of four earlier auto-closed reports, so treat it as a clean measurement, not as something triaged or fixed.
Strike claude doctor from the list: it is an installation and update diagnostic, not an MCP one. Guides pushing it alongside invented figures like "90% of cases" or "80% of misconfigurations" have no methodology behind either number.
| What the log shows | Where to look |
|---|---|
| Failure far inside the connect budget, stderr has output | Cause 1, 5, or 6 |
spawn ENOENT or command not found | Cause 2 |
| Server logs a missing key, path, or credential | Cause 3 |
| Failure at or near the budget, no stderr at all | Cause 4 |
| Server never appears, model says it has no such tool | Cause 4, the non-blocking variant |
Causes 1 to 3: stdout pollution, a missing binary, a missing environment
# Python: route every logger to stderr, and never call bare print().
import logging, sys
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
print("starting", file=sys.stderr)
# Node equivalent: console.error, never console.log.{
"mcpServers": {
"my-server": {
"command": "cmd",
"args": ["/c", "npx", "-y", "@scope/my-mcp-server"]
}
}
}{
"mcpServers": {
"records": {
"command": "/usr/local/bin/uv",
"args": ["run", "--directory", "/opt/mcp/records", "server.py"],
"env": {
"RECORDS_DB_URL": "${RECORDS_DB_URL:-postgresql://localhost/records}",
"RECORDS_CA_BUNDLE": "/etc/ssl/certs/internal-ca.pem"
}
}
}
}Cause 1: something wrote to stdout and corrupted the frame stream
The stdio rule is normative and unchanged across the 2025-06-18 and 2026-07-28 revisions: the server MUST NOT write anything to stdout that is not a valid MCP message, and messages are newline-delimited and MUST NOT contain embedded newlines. One stray byte desynchronizes the client's frame parser, and the transport closes. The culprits are consistent: a leftover print(), a dependency's startup banner, an ORM or telemetry SDK installing a stdout log handler, a shell wrapper echoing before exec, a .bashrc that prints on non-interactive shells. The 2026-07-28 revision strengthened stderr in your favor: the server MAY write UTF-8 to stderr for any logging purpose including informational, debug, and error messages, and the client MAY capture, forward, or ignore it and SHOULD NOT assume stderr output indicates an error condition. The same revision deprecated protocol-level notifications/message logging in favor of stderr or OpenTelemetry.
Cause 2: the binary is not where the client looks
The client's PATH is not your login shell's. nvm, pyenv, asdf, and mise install shims through shell rc files that a GUI-spawned process never reads. Use absolute paths in command, resolved with which node or which uv from the shell that works. Windows has a specific mechanism worth naming. npx and uvx are not native executables, and child_process.spawn does not invoke a shell by default, so a bare {"command": "npx"} entry fails with spawn ENOENT. Several clients log a bare "Client closed" just before the JSON-RPC error. That wrapper is community-established from client repositories, not official documentation, and it cuts both ways: the cmd process has its own lifecycle and can report a premature close while the child is still alive. If it makes things worse, point command at the absolute path of npx.cmd, or at the interpreter and script directly.
Cause 3: the environment the client spawns is not your shell
Official MCP guidance covers the two facts behind most "works manually, fails in the client" reports: a stdio server launched from a client config inherits only a limited, platform-dependent subset of environment variables, and its working directory may be undefined, showing as / on macOS. So relative paths break, dotenv-style discovery breaks, and credential lookups next to the script break. None of it looks like an environment problem from the client, because the process is dead before you see the error. Use ${VAR:-default} deliberately. An unset variable with no default does not fail the load: Claude Code warns in claude mcp list and passes the literal ${VAR} through, so your server receives ${RECORDS_DB_URL} as a connection URL and dies on connect. The other silent source of "I fixed it and it still runs the old command" is scope precedence. Servers resolve local, then project, then user, matched by name, and the entire entry from the winning scope is used with no field merging. claude mcp get tells you which one is live.
Causes 4 to 6: the connect window, handshake ordering, and runtime drift
Set MCP_TIMEOUT=10000 for a ten-second startup budget, and only when the log shows failure at or near the budget. The per-server timeout is a hard wall clock per tool call that progress notifications do not extend. Values below 1000 are ignored and fall through to MCP_TOOL_TIMEOUT, or to its roughly 28-hour default when unset; before CLI v2.1.162 they were floored to one second. That 28-hour default is real, not a typo, and it is a monitoring hazard: an unattended agent can sit on a hung call longer than most on-call rotations. The per-request timer covers HTTP, SSE, and connector servers only. Setting the per-server timeout or MCP_TOOL_TIMEOUT to 60 seconds or more raises it; a lower value does not shorten it, and an unset MCP_TOOL_TIMEOUT never feeds it.
The nastier variant produces no error at all. A report filed in July 2026 documents that the roughly two-second MCP pre-wait became non-blocking in CLI 2.1.144, so a server whose cold start exceeds about two seconds is still pending when the tool snapshot is taken. The model gets zero MCP tools, answers "I don't have that tool," and nothing is logged anywhere. In single-turn and headless sessions the tools are lost for the whole run. It reproduces with a three-second sleep before start and disappears with sleep(0). That issue is open with no maintainer response.
# Gate the first query on connection state instead of trusting startup order.
import asyncio
async def wait_for_mcp(client, timeout_s: float = 15.0) -> None:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s
while loop.time() < deadline:
status = await client.get_mcp_status()
if all(s.status == "connected" for s in status.servers):
return
await asyncio.sleep(0.25)
raise RuntimeError("MCP servers not connected before first query")Cut cold start (lazy imports, no database connections at import time), raise MCP_TIMEOUT when you cannot, and gate the first query either way.
Cause 4: the server starts slower than the client's connect window
Three distinct clocks exist in Claude Code, spread across the four settings below, and sources conflate them.
Cause 5: initialization ordering and handshake races
Version-scope this one. On clients and servers speaking 2025-11-25 and earlier, MCP has an initialize request answered by the server and a notifications/initialized from the client. The client SHOULD NOT send requests other than pings before initialize returns, and the server SHOULD NOT send requests other than pings and logging before initialized arrives. Framework-embedded servers break this most: one mounted inside an existing HTTP or CLI application answers tools/list before init state is set, or emits its initialize response after a stray write already desynchronized the stream. Version mismatches surface separately as -32602. The 2026-07-28 revision deletes the class by removing the handshake and making MCP stateless. Every request carries io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities in _meta, and servers MUST implement server/discover. That helps only once both ends move.
Cause 6: runtime version drift and permission denials
Everything here looks identical from the client: the process exits before the first frame, and you get -32000.
- SDK v2 requires Node.js 20 or newer, is ESM-first with a CommonJS build, and requires Zod 4.2.0 or later (with a documented fallback for 4.0 to 4.1). A pinned Node 18 crashes on import, and an ESM against CommonJS resolution failure throws before your code runs.
- A Python virtualenv not activated under the spawn resolves the wrong interpreter. Use the absolute path to the venv's
python, oruv run --directory. - The executable bit is missing on a script invoked directly.
- macOS quarantine, Gatekeeper, or TCC blocks file and network access for a process spawned by a GUI application rather than a terminal, which is why the same command succeeds by hand.
- A container or sandbox has no write access to the path the server logs to, and the logging library raises on first write.
| Clock | Governs | Default | Applies to |
|---|---|---|---|
MCP_TIMEOUT | Server startup and connection | Debug output shows 30,000ms (not documented) | All transports |
Per-server timeout (ms) | One tool call, hard wall clock | Falls through to MCP_TOOL_TIMEOUT | All transports |
MCP_TOOL_TIMEOUT | One tool call | About 28 hours when unset | All transports |
| Per-request timer to first byte | One request, to first response byte | 60 seconds | HTTP, SSE, connectors only |
Reproduce it outside the client in under a minute
Test 1: run the exact command with the exact args. A healthy stdio server prints nothing and blocks on stdin. Claude Code's own claude mcp serve behaves this way and its documentation says so. A blocked, silent terminal is a pass. A banner, a prompt, or an immediate exit is Cause 1 or Cause 6.
Test 2: feed it one frame and check the first byte of stdout.
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0.0.1"}}}' \
| /opt/mcp/records/.venv/bin/python /opt/mcp/records/server.py \
2>/tmp/mcp-stderr.log \
| head -c 1{ means clean framing. Anything else, or nothing while /tmp/mcp-stderr.log fills with a traceback, gives you the cause without opening the client. For servers on 2026-07-28 the probe frame is server/discover, not initialize.
Test 3: the MCP Inspector, in CLI mode.
npx @modelcontextprotocol/inspector --cli \ /opt/mcp/records/.venv/bin/python /opt/mcp/records/server.py
The official debugging documentation calls the Inspector your first stop. One binary ships a web UI (the default), a scriptable --cli mode, and a --tui mode. Put --cli in CI so a dependency bump that breaks the handshake fails the build instead of an agent run. It pairs with the broader MCP server build and test workflow.
The production fix: stop spawning a subprocess per client
stdio is a development transport. One process per client, no health endpoint, no restart policy, stderr going wherever the parent decided, and a failure mode invisible to the calling application. Acceptable on a laptop, indefensible in a system answering questions about patient records or trade positions.
Streamable HTTP behind a gateway gives you a readiness probe, a supervisor that restarts, centralized authentication, and one place to see every tool call. It is not inherently more reliable: it trades process-death failures for status-code and session failures. The specification carries a MUST that servers validate the Origin header on all incoming connections to prevent DNS rebinding attacks, a SHOULD to bind only to 127.0.0.1 locally rather than 0.0.0.0, and a SHOULD to authenticate all connections. The 2026-07-28 revision also removed the Mcp-Session-Id header along with SSE resumability and redelivery, so a broken stream loses the in-flight request outright and the client MUST re-issue it with a new request ID.
The honest claim for HTTP is observable and supervisable, not more reliable. That is still the right trade, because you cannot operate what you cannot see. Which gateway goes in front is covered in our comparison of MCP gateways for governing agent tool access, the exposure you inherit on leaving stdio is in our MCP server security hardening checklist, and whether MCP is the right integration layer at all is covered in MCP against a plain API.
Health checks, structured stderr logging, and paging when a server dies
Six requirements for any MCP server a production agent depends on.
notifications/message, stderr and OpenTelemetry are the sanctioned paths, and Roots, Sampling, and Logging sit under a minimum twelve-month deprecation window.tools/list, not a TCP connect.** A port that accepts connections proves nothing about whether the server can enumerate its tools.MAX_MCP_OUTPUT_TOKENS raises the cap; the warning threshold is fixed.What to alert on is in production monitoring for quality drift and cost, why a passing eval suite does not predict operational behavior is in agent reliability against accuracy, and the wider architecture sits in our AI agents pillar guide.
Why a silently toolless agent is a compliance problem, not just a bug
This is what makes -32000 worth a post rather than a forum reply. The failure is not that the agent errors. The failure is that the agent answers. With the MCP server to your system of record dead, the model falls back to context and training data and produces a fluent, confident response with no citation to the authoritative source and no record that the lookup never happened. That is not an outage. It is an unlogged control failure, and it is worse than a 500, because a 500 gets investigated and a plausible answer gets acted on.
Across the on-premise deployments we audit at Particula Tech, this is the MCP failure mode that survives longest undetected, precisely because every dashboard stays green. The process count is right, latency is fine, the tool was not there, and nothing in the trace says so.
Three requirements close it. Fail closed when a required tool is missing, so a run without the record system aborts rather than improvises. Give the model an explicit refusal path with wording your compliance function has read, because a model with no sanctioned way to say "I cannot verify this" will invent one. And persist, per run, the tools available at the start, so an auditor can answer "was the system of record reachable at 14:32" without reconstructing it from process logs. The escalation design is in fallback patterns from rules to human escalation, the regulatory framing is in AI compliance for financial services, and the adjacent failure where the tool exists but the agent misuses it is in making AI agents use tools correctly and stopping agents that loop on the same tool call.
The triage order
claude --debug mcp, or tail -n 20 -F ~/Library/Logs/Claude/mcp*.log. Compare the connect budget against elapsed time at failure. Far inside it means the process died, not timed out.claude mcp list for state and missing-variable warnings, claude mcp get NAME for which scope's entry won.{ means clean framing. Anything else, read the stderr you redirected.command, args, and the server's own config loading, and pass every variable through env with ${VAR:-default}.MCP_TIMEOUT if and only if the failure lands near the budget, and check separately whether the server is stuck in pending and handing the model zero tools.tools/list, alerting on tool-count delta, fail-closed behavior on missing tools, and a per-run record of what was available.Steps 1 through 6 get the agent working again. Step 7 decides whether you find out next time from an alert or from a customer quoting an answer your system never had the data to give.
FAQ
Quick answers to the questions this post tends to raise.




