Every failure needs a different recovery
- Transient: timeout, rate limitRetry after a wait
isRetryable: true - Validation: bad inputFix input, try againsay what was wrong
- Business rule or permissionDon’t retry — explainor escalate to a human
- Query ran, nothing matchedNot an errorsuccess with zero results
Two kinds of error in MCP
The MCP specification separates two reporting channels. Protocol errors are standard JSON-RPC errors: an unknown tool name, a request that does not fit the tools/call schema, a server fault. Tool execution errors are reported inside a normal tool result with isError: true: an API that failed, input that broke a rule (a date in the past, a value out of range), a business-logic refusal. The spec is explicit about why the split matters: execution errors carry actionable feedback a model can use to self-correct, so clients should pass them to the model; protocol errors are less likely to lead to recovery.
Where a failure is reported
Protocol error
- JSON-RPC
error: unknown tool, bad request - The call never really ran
- The model may never see it, or can do little with it
Tool execution error
isError: true: the tool ran and failed- Returned in the result
content, where the model reads it - Worded so the model can retry, adapt or explain
The same idea exists in the Messages API: a tool_result block takes an optional is_error: true. Anthropic’s docs advise writing instructive messages — not “failed”, but what went wrong and what to try next, such as “Rate limit exceeded. Retry after 60 seconds.” They also note that when a call is invalid, for example missing a required parameter, Claude will usually retry two or three times with corrections before apologising to the user. That is useful for validation errors and wasteful for anything that can never succeed.
What a structured error contains
isError says only that something went wrong. To choose a recovery, the agent needs to know which kind of wrong. That is what “structured” in this task statement points to: return metadata alongside the flag — a category, whether retrying could help, and a human-readable explanation. For business-rule failures, add wording the agent can pass on to a customer, so it explains instead of improvising.
The same failure, reported two ways
Uniform error
isError: true
content[0].text:
"Operation failed"Structured error
isError: true
content[0].text:
{
"errorCategory": "business",
"isRetryable": false,
"reason": "Refund exceeds the $500
agent approval limit",
"customerMessage": "This refund
needs a manager's approval.
I've passed it on."
}isError: true. Only the one on the right tells the agent not to retry and gives it the words to use with the customer.| Category | Typical cause | Retryable? | What the agent should do |
|---|---|---|---|
transient | Timeout, rate limit, service briefly down | Yes | Wait and retry a bounded number of times |
validation | Wrong format, missing field, out-of-range value | After fixing the input | Correct the input from the message, then call again |
business | Policy forbids it: over a limit, outside a window | No | Explain to the user, or escalate |
permission | Caller not allowed to see or change this | No | Stop; escalate or ask for access — do not probe |
import json
from claude_agent_sdk import tool
def fail(category, retryable, reason, customer_message=None):
body = {"errorCategory": category, "isRetryable": retryable,
"reason": reason, "customerMessage": customer_message}
return {"content": [{"type": "text", "text": json.dumps(body)}],
"is_error": True} # Python key; TypeScript uses isError
@tool("billing_issue_refund", "Issue an approved refund on an order. ...",
{"order_number": str, "amount": float})
async def billing_issue_refund(args):
if args["amount"] <= 0:
return fail("validation", True, "amount must be greater than 0")
if args["amount"] > 500:
return fail("business", False, "Over the $500 agent limit",
"This refund needs a manager's approval.")
try:
ref = await payments.refund( # your payments client
args["order_number"], args["amount"])
except TimeoutError:
return fail("transient", True, "Payments timed out; retry in 30s")
return {"content": [{"type": "text", "text": f"Refunded. Reference {ref}"}]}Catching the exception yourself matters. The Agent SDK docs note that an uncaught exception in a handler does not stop the loop: the in-process MCP server turns it into an error result carrying the raw exception text. The agent then sees something like a bare TimeoutError with no guidance. Anthropic’s engineering team makes the same point about any tool: send specific, actionable messages rather than opaque error codes or tracebacks.
An empty result is not an error
The most expensive confusion in this area runs the other way. A search that ran successfully and found nothing is a valid answer — return it as a normal result that says so. A search that could not run at all (the index was offline, access was denied) is a failure and must be flagged. If both come back as an empty list, the agent will tell a customer “you have no open orders” when in fact the order system was down.
Recover locally, escalate what’s left
In a multi-agent system the same structure decides who handles a failure. Anthropic’s research system found that telling an agent a tool is failing and letting it adapt works surprisingly well, backed by retries and checkpoints so work resumes rather than restarts. A sound pattern follows from that: a subagent handles transient failures itself, and only escalates what it cannot resolve — with the partial results it did obtain and a note of what it tried, so the coordinator can decide without repeating the work.
A subagent absorbs what it can
transient, retryablepermission errorTraps the wrong answers are built from
| Tempting but wrong | Do this instead |
|---|---|
| Returning “Operation failed” for every problem | Return a category, isRetryable and a specific explanation with isError: true. |
| Catching errors and returning an empty result | Return empty only when the query ran and matched nothing; flag failures as errors. |
| Letting raw exceptions or tracebacks reach the model | Catch them in the handler and compose an actionable message. |
| Retrying business-rule or permission failures | Mark them non-retryable and give the agent wording to explain or escalate. |
| Escalating every error to the coordinator | Recover transient errors in the subagent; escalate the rest with partial results and what was tried. |
You should now be able to
- Distinguish MCP protocol errors from tool execution errors reported with
isError. - Return structured error metadata: category, retryable flag and a human-readable reason.
- Give business-rule failures a customer-safe explanation and a non-retryable flag.
- Keep valid empty results distinct from access and availability failures.
- Design subagents to recover transient errors locally and escalate the rest with context.