DEV Community

Cover image for Your MCP Tool Errors Are Written for the Wrong Reader
Josh
Josh

Posted on Originally published at claudecertifiedarchitects.com

Your MCP Tool Errors Are Written for the Wrong Reader

An agent called our calendar tool eleven times in ninety seconds. Same arguments, same failure, same response. It only stopped because the token budget ran out.

The tool was working exactly as designed. It returned a 409 with {"error": "Conflict"}, which is a perfectly reasonable thing for an HTTP API to say to a human holding a browser's dev tools open. The human reads "Conflict," checks the calendar, sees the event already started, and does something else.

Claude read "Conflict" and inferred that something transient had gone wrong. Transient things are worth retrying. So it retried.

That's the whole bug. The error message was addressed to a reader who wasn't there.

**

Errors are prompts

**
The thing that took me embarrassingly long to internalise: in an agentic system, every string your tool returns is model input. Your error messages are prompts. You wrote them for a developer reading a log, and then you handed them to a language model as instructions about what to do next.

A human reading {"error": "Conflict"} brings a mental model of your system, memory of the last time this happened, and the option of asking a colleague. The model brings none of that. It has your string and the tool schema. If the string doesn't say what went wrong or what to do instead, the model will guess — and the most available guess, given how much of the internet is retry logic, is "try again."

Rewriting the response fixed it in one line:

{
"error": "event_already_started",
"message": "This event has already started and cannot be modified. Use update_event to change its end time, or create_event to schedule a new one.",
"retryable": false
}

Three things changed. The error has a stable machine-readable code. The message names the constraint in plain language. And it points at the tool that would work.

The agent stopped retrying immediately. Not because we added retry logic — because we removed the ambiguity that made retrying look sensible. Writing error messages for the model rather than the operator is the single highest-leverage change I've made to a tool surface, and it sits squarely inside what the CCAR-F exam (also written CCA-F) calls its tool design and MCP domain, if you want the structured version of the same ground.

**

The three questions a tool error should answer

**
After rewriting about forty of these, the useful ones all answer the same three questions.

What class of failure is this? Not the HTTP status — a stable string the model can pattern-match on. event_already_started is a different beast from rate_limited, and both are 409-adjacent in a way that a status code flattens.

Is retrying meaningful? Say it explicitly. A retryable boolean does more work than any amount of message wording, because it removes the inference step entirely. Transient network failure: true. Business-rule violation: false. Rate limit: true, and include the wait.

What should happen instead? This is the one people skip, and it's the one that changes behaviour. An error that names the alternative tool converts a dead end into a next step. An error that doesn't leaves the model to invent one.

None of this is novel API design. What's different is the cost of getting it wrong. A human hitting an unhelpful error loses thirty seconds. An agent hitting one can burn a context window, or — worse — take a plausible wrong action that no one notices until later.

**

Retries make this worse before they make it better

**
Once you've told the model which failures are retryable, you have to be sure a retry is actually safe. Ours wasn't.

The same calendar tool had a create_event that was not idempotent. A retried call after a timeout produced two identical events. The failure mode was invisible in testing because our tests never timed out, and it was invisible in production because two identical calendar entries look like user error.

The fix is the boring one: accept a client-supplied idempotency key, store it against the created resource, and return the original result on a repeat. But the general principle is worth stating, because it applies to every tool an agent can call more than once: designing tools so a retried call doesn't produce duplicate side effects is a prerequisite for telling a model that retrying is safe, not an optimisation to add later.

If you mark something retryable and it isn't idempotent, you have built a duplication machine and given the model the keys.

**

The failure that returns nothing at all

**
There's a nastier cousin of the unhelpful error, and it's the one I'd look for first in any tool surface I hadn't written.

A subagent fails. The wrapper catches the exception, logs it, and returns an empty array. Upstream, the orchestrator receives [] and treats it as a fact about the world: there are no results. It reports confidently that nothing matched.

Nothing about that response says a failure occurred. It's the kind of thing I'd put on an anti-patterns list and expect to keep finding anyway, because the code looks defensive — it catches the error, after all — and the caller has no way to distinguish "searched and found nothing" from "search never ran."

I hit the same shape in a completely different context recently, where a search tool returned a clean zero. The lesson generalises: an empty result and a failed result must be distinguishable in the response, or every consumer downstream will treat the second as the first.

The contract between an orchestrator and its subagents needs an explicit error signal, not just an implicit success channel. That's a design decision, and it belongs to agentic architecture and orchestration rather than to whoever writes the subagent — it's easier to get right at the start than to retrofit once four call sites are treating [] as authoritative.

If you want to see how this reads as an exam problem rather than an essay, we write scenario questions on exactly this in the practice bank. It's independent preparation material. We're not affiliated with Anthropic, and we neither sell nor administer the exam.

Schemas don't save you
A reasonable objection at this point: define a strict error schema, validate everything, done.

A schema constrains the shape of what you return. It says nothing about whether the values inside it are useful. You can be perfectly compliant with an error contract and still return {"code": "ERROR", "message": "Something went wrong", "retryable": true} on a permanent business-rule failure — and you'll have built the retry loop I started this post with, but now with validation.

This is the same trap structured output sets: a schema enforces shape, not correctness. The validation passing tells you the envelope is right. It tells you nothing about the letter.

**

Where this lands in practice

**
The changes that actually moved behaviour, in the order I'd do them again:

Give every failure a stable code that isn't an HTTP status. Add an explicit retryable flag rather than making the model infer one. Name the alternative action in the message. Make anything marked retryable genuinely idempotent. And make failure distinguishable from emptiness in every response shape you return.

That's five changes, none of them clever. The reason they matter more in agentic systems than in ordinary APIs is that your error strings have been promoted from diagnostics to instructions, and nobody sends a memo when that happens.

Worth reviewing your tool surface with that framing, if you haven't. The common design mistakes in MCP servers are mostly this class — decisions that were correct for a human consumer and became wrong the moment a model started reading them. And if you're working through tool granularity at the same time, it interacts with all of this: fewer, broader tools mean more failure modes crowded into a single error surface.

Top comments (0)