Why architecture matters here
Architecture matters here because MCP sits at a boundary where a probabilistic caller meets a deterministic protocol. The model generates tool calls from natural language; the server enforces schemas and reaches real systems. Errors are the only feedback crossing that boundary, so how you shape them determines whether the agent converges on a correct action or spirals.
The economics are unforgiving. Every error routed to the model costs tokens, latency, and a chance the model misreads it. Every error hidden from the model costs a retry loop the model cannot escape because it never learns what went wrong. The correct split is not a style preference — it is the difference between an agent that self-corrects in one turn and one that burns a retry budget on the same malformed request.
There is also a security dimension that only shows up under load. Error payloads are the easiest accidental exfiltration channel in an MCP server: an unhandled exception that stringifies a database URL with credentials in it goes straight into the model's context and, from there, into logs, traces, and possibly the user's screen. Error handling is where correctness, cost, and confidentiality intersect.
Debuggability is the fourth force, and it is the one that decays quietly. An agent failure reported by a user is almost always described in terms of behaviour — 'it kept saying it couldn't find my order' — and reconstructing what actually happened requires correlating a model-visible message with the upstream event that caused it. If every failure class collapses into the same generic string, that correlation is impossible and you are left reading transcripts. A disciplined error architecture is what makes an agent's misbehaviour a traceable fact rather than an anecdote, and that property has to be designed in before the incident, not reconstructed after it.
The architecture: every piece explained
Channel A — JSON-RPC errors. MCP speaks JSON-RPC 2.0, which defines an error object with a numeric code, a message, and optional data. The reserved codes carry precise meanings: -32700 parse error (the bytes were not valid JSON), -32600 invalid request (valid JSON, wrong shape), -32601 method not found, -32602 invalid params (the arguments failed schema validation), and -32603 internal error. Servers may define application codes outside the reserved range.
The governing fact about Channel A is that it means the call did not happen. No side effect occurred. That is what makes these errors safe to retry at the transport layer and wrong to hand to the model — there is nothing the model can do about -32603, and telling it that the server has an internal error invites it to hallucinate a workaround.
Channel B — execution errors. When a tool runs and fails, MCP does not return a JSON-RPC error. It returns a successful result whose body carries isError: true alongside content describing the failure. This looks strange until you internalise the audience rule: the protocol succeeded in delivering the call, so the protocol reports success; the tool failed, so the tool reports failure in the payload the model reads.
Why the split is load-bearing. Channel B errors are the ones the model can act on. 'Order 8813 not found' tells the model its assumed order ID is wrong and it should ask the user or search first. 'Rate limited, retry after 30s' tells it to wait or pick another path. These are recoverable in the model's own vocabulary, which is exactly why they belong in the content stream rather than in a protocol frame the model never sees.
The surrounding machinery. Three other mechanisms interlock with both channels. Cancellation is a notification, not an error — a cancelled request has no response at all, so clients must reconcile outstanding IDs themselves. Timeouts are a client-side construct the server never learns about unless you pair them with cancellation. And progress notifications let a long tool reset the client's timer instead of tripping a false timeout.
The taxonomy layer. Neither channel tells you what kind of failure occurred, and that gap is where most servers get sloppy. A useful server defines a small closed set of failure classes — not_found, permission_denied, rate_limited, upstream_unavailable, invalid_input, unknown — and maps every code path onto exactly one. The class determines everything downstream: the wording the model sees, whether the client retries, whether it counts toward a circuit breaker, and which dashboard it lands on. Without this layer, error strings are free text, and free text cannot be aggregated, alerted on, or reasoned about.
Where the two channels legitimately meet. There is exactly one sanctioned crossing, and it belongs to the client. When a -32602 invalid params comes back, the model produced the bad argument, so the model is the only actor who can fix it — but the server was right to use Channel A, because from its side the call genuinely did not execute. The client resolves this by catching the protocol error and synthesising a short model-readable note. Note the asymmetry: servers never cross the channels, clients occasionally do, and that rule keeps the responsibility unambiguous.
End-to-end flow
Follow a single call. The model emits a tools/call for refund_order with {order_id: '8813', amount: 40}. The client host serialises it as a JSON-RPC request with a fresh ID, starts a timeout timer, and writes it to the transport — stdio pipe or streamable HTTP.
The server parses the frame. If the bytes are not JSON, it replies -32700 and the story ends at the transport: the client logs a protocol fault and does not tell the model, because a parse error is a bug in the client or a corrupted stream, not something the model caused or can fix.
Assume it parses. The server looks up refund_order in its tool registry. If it is absent — perhaps the client cached a tool list from before a server redeploy — the server returns -32601. This one is interesting: it is the model's problem in a sense, but the right fix is a capability refresh, so a good client re-runs tools/list, reconciles, and only then surfaces a synthesised message if the tool is genuinely gone.
Next, schema validation. amount is declared as a number and the model sent the string '40'. Strict servers return -32602 invalid params. This is the most contested boundary in MCP error design: the model produced the bad argument, so shouldn't it see the error? In practice the client should catch -32602, reformat it as a short model-readable note, and let the model retry — a rare, deliberate crossing from Channel A to the model, done by the client, not the server.
Now the tool executes. It calls the payments API, which answers 404 because order 8813 belongs to a different tenant. This is Channel B territory. The server returns a success result with isError: true and content reading 'Order 8813 not found in this account.' The client passes that content to the model as the tool result. The model reads it, realises it guessed the ID, and asks the user to confirm — one turn, no retry storm.
Finally the failure that fits neither channel cleanly: the tool hangs. The payments API stalls for ninety seconds. The client timer fires at thirty. The client sends notifications/cancelled for that request ID and synthesises a Channel B style result for the model ('the refund tool timed out; the refund may or may not have been applied'). The honesty matters — a timeout is not a rollback, and the model must not assume the side effect did not happen.
Follow that last case one step further, because it is where the design pays for itself. The server does not stop working when the client cancels; cancellation is advisory, and the refund may well land at second ninety. If refund_order accepts an idempotency key the client generated before the first attempt, the model's retry carries the same key, the payments API recognises it, and the customer is refunded exactly once regardless of how many timeouts intervened. Without that key, the well-behaved retry the model was told to make is the mechanism that double-refunds. Idempotency is not a nicety bolted onto error handling — it is the precondition that makes any retry advice safe to give.