When an MCP tool call goes wrong, there are two completely different ways for that to happen, and mixing them up is one of the more common mistakes in a hand-written server. One is a protocol-level failure — the client asked for a tool that doesn’t exist, or sent arguments that don’t match the schema. The other is the tool running successfully as a piece of code but failing at what it was actually trying to do: the API it called returned a 404, the file wasn’t there, the account was out of credits. MCP has a specific, deliberate way to report the second kind, and a server that skips it leaves the model with no way to tell a fixable mistake from a dead end.

Two kinds of failure, two different signals

A protocol error is a JSON-RPC error response — the kind of thing that happens when the request itself is malformed: an unknown tool name, a required argument missing, invalid JSON. The client’s MCP library surfaces this as an actual error, and it usually means something is wrong with how the tool was called, not with the world the tool operates on.

A tool execution error is different. The call was valid — right tool, right arguments — but the operation itself didn’t succeed: a lookup found nothing, a write was rejected, an upstream service timed out. The spec’s answer for this case is to return a normal, successful result with isError set to true and the actual problem described in the content. From the transport’s point of view, nothing failed. From the model’s point of view, the tool told it plainly that this particular attempt didn’t work, and why.

Why this distinction is worth getting right

It’s tempting to treat every failure the same way — throw, let it become a protocol error, move on. The problem is what that does to the model on the other end. A protocol error tends to end the turn: the client sees a malformed exchange, not a business outcome, and has little basis for deciding what to try next. An isError result, by contrast, is just another message the model can read and reason about in the same conversation. “No customer found with that email” is something a model can act on — check the spelling, ask the user, try a different field. A raw exception is not.

This is also why an isError result should never quietly become a “successful” one. A tool that catches every failure and returns an empty list or a blank string instead of flagging the error looks, from the model’s side, exactly like a search that legitimately found nothing. That’s a worse outcome than a loud failure: the model has no signal that anything went wrong, so it reports the empty result to the person as if it were the truth.

What makes an error message actually useful

Since the model is the one reading it, an error string earns its keep the same way a tool description does: by being specific enough to act on, in plain language, without assuming the reader has your logs open.

  • Say what happened, not just that something failed. “Rate limited — retry after 30 seconds” gives the model a concrete next step. “Request failed” doesn’t.
  • Say what to change, when there’s something to change. A validation failure should name the field and the constraint it violated, not just report that validation failed.
  • Don’t leak internals. A stack trace, an internal error code, or a raw upstream response body isn’t useful to the model and can hand a database schema, a file path, or a partner API’s error format to whatever asked for the tool call — including, in an agent chain, content the model itself read at runtime rather than something the user typed.
  • Distinguish retryable from final. A timeout is worth trying again; an “insufficient permissions” response is not, and saying so directly heads off a model retrying the same failing call several times in a row.

Where this meets the retry problem

A clear, retryable-or-not error message matters even more once you consider that clients already retry tool calls on their own — a timeout or a dropped connection is a normal reason for a second attempt, covered in MCP tool idempotency. If your error message doesn’t say whether the underlying action might have partially succeeded, a model deciding whether to retry a write tool is guessing. For a read tool this costs a wasted call. For a write tool — send, charge, delete — it’s the same duplicate-action risk idempotency design is meant to prevent, just triggered by an unclear failure instead of a silent one.

Where gate fits: gate logs every tool call that passes through your gateway with its outcome recorded as allowed, blocked, or errored, alongside the calling client, the server and tool, and latency — so a tool that’s quietly failing (or quietly returning empty results instead of flagging a real failure) shows up as a pattern in the log instead of disappearing into a per-server API console you’d never think to check. If you’re generating a server from an existing API with our MCP Builder rather than writing error handling by hand, see turning an API into an MCP server for how the rest of that generation and review process works.

The bottom line

MCP already gives you a channel built for reporting a failed operation without ending the conversation: a normal result with isError: true and a message the model can actually use. Reserve protocol-level errors for genuinely malformed requests, never silently downgrade a failure into an empty success, and write the error text the same way you’d write a tool annotation — plainly, and for a reader that can’t see your logs. It costs a few extra minutes per tool and removes an entire class of confused, retried, or silently wrong agent behavior downstream.