A tool error that says "invalid input" ends the run. One that quotes the rejected value, lists the accepted set and names the next move lets the assistant repair the call itself. Four real messages from one codebase, read closely.
An error an assistant can act on has three parts: the value that was rejected, quoted back verbatim; the set of values that would have been accepted; and the move that fixes the call. Miss the first and the assistant does not know which argument to change. Miss the second and it guesses, usually by trying a synonym. Miss the third and it retries the same call with the same input, because nothing in the message told it to do anything different.
23Places in this product's server code where a rejected value is answered with the list of accepted values in the same sentence
What an unactionable error looks like
An unactionable error is one whose text does not narrow the search for a fix. "Invalid input" names nothing. "Validation failed" names nothing. A 400 relayed straight from an upstream API names the upstream's opinion of a request the assistant never saw in that form. An error that names an internal symbol, such as a class or a schema key that appears nowhere in the tool's own arguments, is worse than silence, because it sends the caller looking for a parameter that does not exist.
The failure mode this produces is repetition. An assistant that cannot tell what to change will call the tool again with a small variation, and on a tool that spends money or writes to a customer's site, the second call is a second charge or a second write. Shaping the error is the cheaper half of that problem, and shaping the response so a second call is unnecessary is the other half.
Four messages, taken apart
These four are validation errors from the Search Console layer of this product, quoted as they are thrown. Each one is one sentence, and each one carries all three parts.
The "hour" dimension needs dataState "HOURLY_ALL" — Google publishes hourly rows only in that state, and only for the last 10 days.
dataState "HOURLY_ALL" is only meaningful with the "hour" dimension — use "ALL" to include provisional days in a daily report.
DISCOVER cannot be grouped by "query" — it supports: page, country, device, date. Discover and Google News are feed surfaces: nobody typed a query and there is no ranked result page.
Both can hand a model a block of JSON, so the choice looks cosmetic. It is not. A tool call is a decision the model makes with arguments it invents; a resource read is a fetch the client performs against an address it already holds.
Market4 turns one release note into a changelog page, a blog post, a mail-out and a week of social posts — and then tells you which of them brought anyone back.
No card to start. Cancel from the settings screen, not from an email.
An error an assistant can act on has three parts
Message
Rejected value
Accepted set or constraint
The fix it names
Unknown dimension
The exact string that was sent
The seven groupable dimensions, listed
Send one of the listed names
hour needs HOURLY_ALL
The dataState that was sent with hour
Hourly rows exist only in that state
Add dataState HOURLY_ALL, and expect ten days
HOURLY_ALL without hour
The dataState that was sent alone
That state is about hourly rows
Use ALL instead for a daily report
Type cannot be grouped by query
The dimension and the search type together
The dimensions that type does support
Drop the dimension, or change the type
Quote the value back, do not describe it
Quoting the rejected value back is the part most often skipped, and it is the part that costs least. A message that says the dimension is unknown leaves an assistant comparing its own request against a list. A message that says Unknown dimension "searchApearance" shows the typo, and the correction takes no reasoning at all. The same applies to an enum: naming the value that arrived, rather than the field it arrived in, is the difference between one repair and a sequence of guesses.
The second part, the accepted set, has to be the actual set rather than a pointer to it. "See the documentation for valid values" is not an accepted set: the assistant cannot open the documentation mid-call, and a tool that could have listed seven words instead sent a caller away. This is the rule that produced twenty-three separate places in this codebase where the rejected value and the legal list appear in the same sentence.
Say why, when the why changes what to do next
A short reason belongs in the message when it changes the next action, and not otherwise. "Discover and Google News are feed surfaces: nobody typed a query and there is no ranked result page" tells the caller that no retry, no scope change and no permission will produce query rows for that surface. Without it, an assistant reasonably assumes the dimension is unavailable for an accidental reason and tries again with a different date range. A reason that only explains the implementation, on the other hand, adds tokens and nothing else.
Where to put the check
Validate before the network call. An error thrown locally can name the tool's own argument names; a relayed upstream error names the upstream's.
Build the accepted set from the same constant the code validates against, so the message cannot drift away from the rule it describes.
Keep the message to one sentence with the three parts in order: what was rejected, what is accepted, what to do.
Use the same shape for every tool in the server. An assistant that has repaired one call learns the shape and repairs the next one faster.
Do not put a stack trace, an internal identifier or a support code where the fix should be. None of the three is actionable by the caller.
What makes an error message actionable for an AI agent?
Three things: the rejected value quoted verbatim, so the agent knows which argument to change; the accepted set or the constraint, listed in the message rather than referenced, so it does not have to guess; and the next move stated plainly, so it does not retry the same call. An agent reads the error text as its only feedback channel, and anything not written there is not available to it.
Should a tool error include the reason it failed?
Include the reason when it changes what the caller should do next. Telling a caller that a surface has no query dimension because nobody typed a query stops a pointless retry. Telling a caller which internal validator threw does not, and it lengthens a message an agent has to read on every failure. One sentence of cause is usually enough; a paragraph of implementation detail is not.
Is it better to return an error or an empty result?
Return an error when the request could not be satisfied as asked, and an empty result when it was satisfied and there was nothing there. Confusing the two is expensive in both directions: an empty result for an invalid request reads as a genuine zero, and an error for a legitimately empty range sends a caller to fix something that is not broken. Say which of the two happened, in the message itself.
How do I stop an assistant retrying a failed tool call?
Tell it, in the error, that a retry will not help and what would. A message that names an unfixable condition, such as a dimension a surface does not report, ends the loop. A message that names a repairable one, such as an unknown enum value with the legal list beside it, produces exactly one more call. Silence, or a bare status code, produces retries until something else stops them.
The consumer of an MCP tool result is a model with a fixed budget for reading. Paging for that reader is a different design from paging for a web page.
When an assistant works through dozens of your tools in a single turn, there is no transaction around them. Everything before the failure has already happened, and the turn can still end by reporting success.