MCP error shapes
The server returns standard JSON-RPC error objects, plus one MCP-specific code, -32020, for a header that disagrees with the body. The HTTP status carries information too: a transport problem gets a real 4xx, while a problem inside a valid call — an unknown tool, say — comes back as 200 with an error object in it.
The codes#
| Code | HTTP | Cause | What to do |
|---|---|---|---|
-32700 | 400 | The body is not valid JSON | Fix the serialiser. Retrying sends the same broken bytes |
-32600 | 403 | A present Origin header is not recognised | Send no Origin from a non-browser client, or call from an allowed origin |
-32600 | 413 | The request body is over 256 KiB | Split the call. No documentation tool needs an argument that large |
-32600 | 400 | A JSON-RPC batch was sent | Send one message per request; batches are refused outright |
-32600 | 400 | An unsupported protocol version was asked for | Read error.data.supported and pick one of the four listed revisions |
-32020 | 400 | A header disagrees with the request body | Make the header match, or send neither |
-32601 | 404 | The JSON-RPC method does not exist | Check the spelling against tools/list, resources/list, prompts/list |
-32601 | 200 | The tool, resource or prompt named does not exist | List them and use a real name; the method itself was valid |
-32602 | 200 | params or arguments is not an object | Send an object, not an array or a string |
-32603 | 429 | Over 120 requests in the last minute | Back off, then retry with a widening delay |
-32603 | 200 | The server could not complete the request | Retry once; if it persists the fault is not yours |
Why an unknown tool is an HTTP 200#
The split is deliberate and it is the one most likely to catch a client out. 404 means *this JSON-RPC method is not a method here*. Naming a tool that does not exist is not that: tools/call is a perfectly valid method, the request reached the dispatcher and was understood, and the answer is an error object at HTTP 200. The same is true for resources/read with an unknown URI and prompts/get with an unknown name.
A client that treats HTTP status as the whole answer will therefore record a call to a hallucinated tool as a success. Parse the body: a response containing error is a failure whatever the status line says.
Two failures that are not protocol errors#
First, a tool that runs and cannot answer returns a *result*, not an error object: content carrying a sentence, and isError: true. A missing query, or a page reference matching nothing, arrives this way. That is the MCP convention, and it exists so a model reads the explanation and corrects itself instead of the client aborting the turn.
{"jsonrpc": "2.0", "id": 12, "result": {
"content": [{"type": "text",
"text": "No public page for '/docs/nope/'. Use search_public_docs first."}],
"isError": true}}Second, a notification — a message with no id — is answered with HTTP 202 and an empty body. There is no result and no error to read. A client that waits for a response to a notification waits forever, which looks exactly like a hung server and is not one.
The header-mismatch code#
-32020 fires in three situations, all of them the same underlying problem: MCP-Protocol-Version disagreeing with the version in params._meta, Mcp-Method disagreeing with the body's method, or Mcp-Name disagreeing with the body's name or uri. The Mcp-Name header may be Base64-wrapped as =?base64?…?=, and is decoded before the comparison, so an encoded header and a plain body still match.
The reason the specification asks for this check, and the reason it is enforced here rather than logged, is that infrastructure between client and server routes on headers while the server executes on the body. If the two can disagree, a proxy and an application can be looking at different requests. Sending no optional headers at all is a valid and simpler choice.
Questions#
Should I retry a -32603 at HTTP 200?
Once, after a short pause. It means the server caught something unexpected while handling a valid request; the message is deliberately generic and carries no internal detail. If it repeats with the same arguments, the problem is not in your client.
How do I tell a rate limit from a server fault? Both are -32603.
By the HTTP status: 429 is the limit, 200 is the fault. This is one place where the transport status is the more informative half of the answer, which is why a client should keep both.
Does the server ever return a partial result?
No. A call either returns a complete result object or an error. There is no streaming, no chunked tool output and no resumable stream — the transport removed those, so there is no half-delivered state to reconcile.