Chapter 4 of 6 · 14 min read
Actionable tool errors
On this page
What a single error response owes its caller is settled ground from topic 6.4, on tools over MCP: a stable code, the field at fault, the value received, the permitted form, and a flag saying whether an unchanged retry could ever succeed. This chapter is about the same property one level up, as a characteristic of a server rather than of a message.
The failures that produce loops in production are rarely the ones where somebody sat down to design an error. They are the ones where a single generic handler catches everything and emits one shape, and where the fix that looks obvious operates on a channel the model never reads. Getting this right is also the part of a server that is cheapest to measure, because a set of stable codes turns failure behaviour into a distribution you can watch rather than a line on a dashboard.
The status code is a message to the client, not to the model
A tool returns {"error": "could not process request"} with a 500 for three unrelated causes: the
upstream OAuth token has expired, the project id does not exist, and the upstream API is rate
limiting. Traces show the agent responding to all three identically — a couple of retries, then a
report that the integration is broken. The move that presents itself immediately is to split the 500
into the statuses that were always correct: 401 for the token, 404 for the project, 429 for the rate
limit. Those statuses are right, and shipping them changes nothing about the loop.
The reason is that the model does not see the status. It sees whatever the client turned the response
into, and the client sits between the two with its own conventions. Under MCP the tool result crossing
to the model is a content block with an isError flag; the HTTP status of the underlying call to your
upstream never appears in it, and the status of the MCP transport itself is consumed by the client
library long before any text is assembled.
{
"content": [{ "type": "text", "text": "could not process request" }],
"isError": true
}That is the entire signal. Whatever the server knew at the moment of failure — that the token expired
40 minutes ago, that the project id was off by one character, that the rate limit resets in 12 seconds
— is discarded unless it was written into that text. The status codes are load-bearing for a client
that does token refresh or respects Retry-After at the transport layer, and they are invisible to the
component doing the deciding.
| Cause | Correct status | What the model typically sees | What it can do with that |
|---|---|---|---|
| Expired upstream token | 401 | "Request failed" or "Unauthorized" | Retry, or guess that credentials are involved |
| Unknown project id | 404 | "Not found" | Retry with the same id, or invent a different one |
| Upstream rate limit | 429 | "Too many requests" | Retry immediately, since nothing said how long to wait |
Every row of that table produces retries, and the third produces the worst kind, because retrying a rate limit at machine speed extends the window that caused it.
The belief that splitting the 500 will fix the loop is worth taking seriously rather than dismissing, because it is tempting for two good reasons. The first is that the change is correct: a 401 for an expired token really is the right status, and correctness normally counts as progress. The second is that in a human-facing API this would fix it, since the developer integrating against it reads status codes, has the documentation open, and can act on the difference. Neither reason survives the substitution of the caller. Follow the path a status takes and it stops before the deciding happens: the upstream's status is consumed by your handler, and the MCP result the client assembles has nowhere to put it, because the result type is a list of content blocks and a boolean. A server cannot smuggle it through either, since anything it invented would be a convention this client happens to implement and the next one does not. So the correct statuses are correct for the transport and the model is still reading one sentence and a flag — which is the whole of why the fix has to be in the text.
Three causes, three actors
The three failures need different treatment rather than different text, and the axis that separates them is who is capable of resolving the situation. The model can fix an argument it got wrong. It cannot mint a new OAuth token, and nothing it sends will make a rate limit expire sooner. Writing the response starts by deciding which of those three worlds the failure is in, because that decision determines the shape of every field.
# Unknown project id: the model is the actor. Give it what it needs to correct the call.
{
"code": "unknown_project",
"message": "No project with id 'prj_8Fk21'. The id may be from a different workspace.",
"field": "project_id",
"received": "prj_8Fk21",
"closest_matches": ["prj_8Fk2l (Billing Migration)", "prj_8Fj21 (Billing Reports)"],
"recover": "Call list_projects to enumerate ids in the current workspace.",
"retryable": "with_different_arguments"
}# Expired token: a human is the actor. Say so, and say the retry cannot work.
{
"code": "authorization_expired",
"message": "The connection to Datastore was authorised on 2026-06-02 and expired on 2026-08-01.",
"recover": "The user must reconnect Datastore in Settings > Integrations. "
"No arguments to this tool can restore access.",
"user_action_required": True,
"retryable": "never"
}# Rate limit: time is the actor. Carry the wait, and carry the budget.
{
"code": "rate_limited",
"message": "Upstream rate limit reached: 100 requests per 60 s.",
"retry_after_seconds": 12,
"limit": {"requests": 100, "window_seconds": 60, "remaining": 0},
"recover": "Wait 12 s before the next call to this tool. Other tools are unaffected.",
"retryable": "after_delay"
}The differences are structural. The unknown project carries closest_matches, which exists because an id
that is one character wrong is the common case and an enumeration of 300 projects is not something you want
in context — two candidates from an edit-distance search, which ranks the ids you do know by how many
character changes separate them from the one received, cost about 25 tokens and resolve most of these on the
next call. The expired token carries user_action_required, which is the field that stops the loop, since an
agent that knows no argument can help will stop composing arguments and say something accurate to the user.
The rate limit carries a number of seconds, because "too many requests" without a duration is a failure with
a synonym attached.
The two candidates in closest_matches are worth checking against the id that produced them,
because seeing why exactly those two came back is what makes edit distance more than a
buzzword. The received id was prj_8Fk21. The first candidate, prj_8Fk2l, differs from it
only in the final character — a lowercase L where a digit one was sent, which is the classic
confusion between two glyphs that look alike in most fonts and is very likely what happened
upstream. The second, prj_8Fj21, differs only in the seventh character, a j for a k.
Each is one character change away, no more, and that is the entire selection rule: rank the
known ids by how many single-character edits separate them from what arrived, and return the
nearest few. It is also why two is a reasonable number to return rather than an arbitrary
cut-off — a nine-character id has very few real neighbours at distance one, so the list
stops on its own.
The retryable field taking three values rather than two is deliberate. A binary flag forces
with_different_arguments and after_delay into the same bucket, and they call for opposite behaviour: one
wants an immediately corrected call, the other wants the same call later and nothing in between.
| Model corrects arguments | Waits and repeats | Escalates to the user | |
|---|---|---|---|
unknown_project | yes | no | only if enumeration also fails |
authorization_expired | no | no | yes, immediately |
rate_limited | no | yes, after retry_after_seconds | only if the wait exceeds the budget |
Codes you can count
Naming the failure categories does something beyond helping the caller, which is that it makes the server's
failure behaviour measurable. A single generic 500 is one line on a dashboard; a set of stable codes is a
distribution, and the distribution is where you find that 62% of your failures last week were
unknown_project and that the ids came from a tool whose output truncated them at 8 characters.
Three measurements are enough to show whether the new responses are better than the old ones, and none of them is "the agent eventually surfaced something", which the old behaviour also satisfied.
| Measurement | Definition | Before | After |
|---|---|---|---|
| Retries per failure | Calls to the same tool after a failure, before the run moves on | 6.0 mean, 4 median | 1.1 mean, 1 median |
| First-retry resolution | Share of failures where the very next call to that tool succeeded | 12% | 78% |
| False-outage rate | Runs ending with the agent telling the user the integration is down, where the upstream was healthy | 31% | 4% |
| Wasted tokens per failure | Tokens spent on calls between the first failure and recovery or abandonment | ~9,400 | ~1,300 |
The first-retry resolution rate is the one that discriminates most sharply, because it is close to a direct
measurement of whether the corrected call was determined by the response or searched for by the model. A
response that fully specifies the correction should produce a number in the eighties or nineties for
argument-fixable categories; anything near 12% means the model is sampling from a space rather than reading
an instruction. Breaking the rate down by code turns it into a work queue — invalid_timezone sitting at 94%
and unknown_project at 41% tells you which error message to rewrite next, which is not a question the old
server could even ask.
Those two per-code numbers also explain why the aggregate is the wrong thing to steer by, and
the arithmetic makes the trap concrete. Seventy-eight per cent is not a rate the server has;
it is a weighted average of the rates its codes have. If invalid_timezone sits at
ninety-four and unknown_project at forty-one, then an even mix of the two would average
somewhere around sixty-seven, so an aggregate as high as seventy-eight is telling you that
the well-behaved codes carry more of the volume. That matters because of what follows from
it. The aggregate can be improved without improving a single error message — ship a change
that makes unknown_project fire less often and the average rises, because the weight moved.
The per-code rate cannot be gamed that way, since it is measured within a fixed denominator
of failures of that kind. Report the aggregate to know whether the whole is getting better,
and work from the per-code breakdown, because that is the one that names a message to
rewrite.
The false-outage rate is worth instrumenting even though it takes effort, because it is the failure that reaches a person. An agent that reports an outage that is not happening costs an engineer twenty minutes of looking at a healthy dashboard, and it teaches the user that the integration is unreliable, which is a reputational cost that outlives the fix.
The figures in that transcript and the figures in the measurement table were gathered separately, and they can be made to check each other, which is worth doing once because it tells you the metric is measuring what its definition claims. Eleven thousand two hundred tokens across six calls is a little under nineteen hundred tokens a call. The wasted-token measurement counts only the calls between the first failure and the end of the episode, which here is five of the six. Five times nineteen hundred is about nine thousand three hundred, and the table's before-figure is roughly nine thousand four hundred. Two numbers arrived at from different directions landing within a per cent of each other is a small thing, and it licenses a specific claim: the wasted-token column is counting the retries and nothing else, so a change in it is a change in retry behaviour rather than in how expensive a call happens to be.
Where richer errors stop helping
A well-formed error cannot rescue a call that should not have been made, and the temptation once error design
starts working is to route around surface problems with better failure messages. A tool that returns
unknown_project with helpful matches on 40% of its calls has a discoverability problem in a different tool,
and the correct fix is upstream — ids that are not truncated, a description on project_id that says where ids
come from, or an enum where the set is small and stable. Errors are the recovery path, and a recovery path
carrying most of your traffic is a design smell rather than a feature.
There is a real cost to the extra fields, paid in tokens on every failure and occasionally in security. The
version of unknown_project that enumerates all valid ids rather than the two nearest costs roughly 3,000
tokens each time it fires, and at a few hundred failures a day that is a line on the bill for a case where two
candidates would have done. More seriously, error text is model-visible text, so any of it that originates
upstream — a message field proxied verbatim from a third-party API, a database error containing a row's
contents — is a channel through which content you do not control reaches the model's instruction stream. Errors
your server composes from a fixed template with interpolated, length-bounded values are safe in a way that
str(exception) is not, and the same rule keeps stack traces and connection strings out of the response by
construction.
The token line is worth pricing rather than waving at, because the ratio decides the design. Two candidates cost about twenty-five tokens; the full enumeration costs about three thousand. That is a factor of roughly a hundred and twenty for the same job, and the job is done by the cheap version in the common case, since an id one character wrong is what actually arrives. Multiply the expensive version out at a few hundred failures a day and it approaches a million tokens daily, spent on listing projects the caller was not asking about. The rule that falls out is not "keep errors short" — the whole chapter argues the opposite — it is that an error should carry what narrows the caller's next call and stop there. An enumeration does not narrow anything; it hands back the search the code was supposed to end.
Some failures are also not errors. A query_issues that matches nothing should return an empty result set with
the filters echoed back, since an error would tell the caller that something went wrong when the answer is
simply that the answer is empty. Returning no_results as an error code produces agents that retry a correct
query with progressively looser filters, which is the retry loop again, arriving from the opposite direction.
What every response in this chapter has in common is that it removes a degree of freedom from the caller. The
code says which failure this is, the received value says what the server saw, the permitted form says what
would work, and retryable says whether to try, wait or stop. A response missing any of those hands the
difference back to the model as something to guess, and the guessing is what the retries are.
Every one of them is also a branch that fires only when something has gone wrong, which raises the awkward
question of how you ever see one. An agent run reaches an error path only when the planner happens to make the
mistake that triggers it, so the authorization_expired response might not fire once in fifty evaluation runs,
and the first time it renders in production is the first time anyone reads it. Making these branches execute on
demand means calling the server directly, with no model in the loop at all.
Building a server — read the current version at https://daviddominguez.dev/learn/en/building-a-server/actionable-tool-errors