Implement structured error responses


08 Aug 2026  Istvan Dobrentei  12 mins read.

Part 9 · Domain 2, Task 2.2 - Implement structured error responses for MCP tools

After the tool is picked, it can still fail

Task 2.1 was about the moment before a tool runs: which one does the model reach for. Task 2.2 picks up right after that moment, once the chosen tool has actually been called and something goes wrong. A refund can exceed a policy limit. A lookup service can time out. A search can come back with nothing. All three are “the tool didn’t return what you wanted,” but they call for completely different reactions — and the model can only react correctly if the failure tells it which one just happened.

This is where MCP’s isError flag comes in. When a tool call fails, the result doesn’t come back as a broken request — it comes back as an ordinary tool result, with a flag set on it saying this one didn’t succeed. The Anthropic API has the exact same mechanism under the same idea: a tool_result content block can carry is_error: true (isError in the PHP SDK, camelCase again). The model sees the failure sitting right there in the conversation, in plain content it can read and reason about, instead of the call disappearing into an exception nobody in the conversation ever finds out about.

Whether that flag alone is enough for the model to react well is a separate question — and the demos below test it directly.

One string covers every kind of failure

The simplest way to report a tool failure is a flat string. It’s also the shape that gives the model the least to work with:

def process_refund_generic(order_id: str, amount: float) -> tuple[str, bool]:
    if amount > 500:
        return "Operation failed.", True
    return f"Refunded ${amount:.2f} for order {order_id}.", False

Ask an agent using this tool to refund $650 on an order — an amount that’s over a $500 auto-approval limit on purpose — and here’s what actually came back, live:

process_refund({'order_id': 'A778', 'amount': 650}) -> is_error=True 'Operation failed.'

Final answer: Unfortunately, the refund for order A778 in the amount of
$650.00 failed to process. Here are a few possible reasons this may have
occurred:
- The order ID may not exist or may have already been refunded.
- There may be a temporary issue with the payment system.
...

Neither guess is true. The refund failed because it’s over a policy limit, not because of a bad order ID or a system hiccup — but "Operation failed." never said that, so the model filled the gap with plausible-sounding reasons instead of the real one. Now compare the same failure, reported with a category, a retryability flag, and an actual explanation:

def process_refund_structured(order_id: str, amount: float) -> tuple[str, bool]:
    if amount > 500:
        error = {
            "errorCategory": "business",
            "isRetryable": False,
            "message": (
                f"Refund amount ${amount:.2f} exceeds the $500 auto-approval "
                "limit. This requires manual review by a human agent."
            ),
        }
        return json.dumps(error), True
    return f"Refunded ${amount:.2f} for order {order_id}.", False
process_refund({'order_id': 'A778', 'amount': 650}) -> is_error=True
'{"errorCategory": "business", "isRetryable": false, "message": "Refund
amount $650.00 exceeds the $500 auto-approval limit. This requires manual
review by a human agent."}'

Final answer: Unfortunately, the refund of $650.00 for order A778
couldn't be processed automatically. This is because the amount exceeds
the $500 auto-approval limit and requires manual review by a human
agent.
...

Same failure, same tool, same order. The only thing that changed is what the error result actually said — and the model’s answer went from three guesses to the real reason, stated correctly. errorCategory and isRetryable aren’t there to be clever. They’re the two questions any caller — human or model — needs answered before deciding what to do next: what kind of problem is this, and is trying again even worth it.

Who retries: the tool or the model

A transient failure — a timeout, a service blip — is usually worth retrying. The question is where that retry happens. It can happen inside the tool, before the model ever sees a failure, or it can happen in the conversation, with the model deciding to call the tool again after seeing isRetryable: true.

Here’s a tool that reports the raw failure and leaves the retry to the model:

def lookup_order_no_local_recovery(order_id: str) -> tuple[str, bool]:
    _lookup_attempts["count"] += 1
    if _lookup_attempts["count"] == 1:
        error = {
            "errorCategory": "transient",
            "isRetryable": True,
            "message": f"Order lookup service timed out for order {order_id}. Retry may succeed.",
        }
        return json.dumps(error), True
    return f"Order {order_id}: shipped, arriving Friday.", False
turn 0: lookup_order({'order_id': 'B221'}) -> is_error=True
  '{"errorCategory": "transient", "isRetryable": true, ...}'
turn 1: lookup_order({'order_id': 'B221'}) -> is_error=False
  'Order B221: shipped, arriving Friday.'

Final answer: Great news! Your order B221 has been shipped and is
expected to arrive this Friday.

It worked — but it cost two full turns, two model calls, and the model had to correctly read isRetryable: true and decide to act on it. Nothing forces that; it’s a convention the model has to notice and choose to honor. Now the same failure, retried locally inside the tool instead:

def lookup_order_with_local_recovery(order_id: str) -> tuple[str, bool]:
    for attempt in range(2):
        succeeded = attempt == 1  # first call times out, retry works
        if succeeded:
            return f"Order {order_id}: shipped, arriving Friday.", False
    error = {
        "errorCategory": "transient",
        "isRetryable": False,
        "message": f"Order lookup service timed out twice for order {order_id}. Already retried locally.",
    }
    return json.dumps(error), True
turn 0: lookup_order({'order_id': 'B221'}) -> is_error=False
  'Order B221: shipped, arriving Friday.'

Final answer: Great news! Your order B221 has been shipped and is
expected to arrive this Friday.

One turn. The model never learned anything failed — it just got the answer. This is the “local recovery within a subagent for transient failures” the objective describes: retry what’s cheap and likely to succeed at the source, and only turn a failure into something the model has to reason about once retrying locally has actually been tried and didn’t help. isRetryable: false on the way out of lookup_order_with_local_recovery means exactly that — not “this never works,” but “this was already retried once, trying again from here won’t help.”

An empty result is not a failure

Zero matches and a broken lookup produce the same kind of disappointing news for the user, but they are not the same event. One is a successful query that happens to have nothing to report. The other is a query that never actually completed. Mixing them up is its own failure mode:

def search_orders_valid_empty(email: str) -> tuple[str, bool]:
    return "No orders found for this email address.", False

def search_orders_access_failure(email: str) -> tuple[str, bool]:
    error = {
        "errorCategory": "transient",
        "isRetryable": True,
        "message": "Order search service timed out. Unable to confirm whether matching orders exist.",
    }
    return json.dumps(error), True
--- Valid empty result ---
search_orders({'email': 'alice@example.com'}) -> is_error=False
  'No orders found for this email address.'
Final answer: It looks like there are no orders associated with the
email address alice@example.com. ...

--- Access failure ---
search_orders({'email': 'alice@example.com'}) -> is_error=True
  '{"errorCategory": "transient", "isRetryable": true, ...}'
Final answer: It looks like the order search service timed out while
trying to retrieve your orders. This appears to be a temporary issue.
Would you like me to try the search again?

Told correctly, the model gives two answers that are actually different: a confident “you have no orders” for the real empty case, and “the search didn’t complete, want me to retry” for the real failure. That’s the correct behavior in both directions, and it only works because each case was reported as what it actually was.

The dangerous version of this mistake can’t be shown with a side-by-side run, and that’s exactly why it’s dangerous: if the access failure above had been swallowed and reported as "No orders found for this email address.", False — the exact content and flag of the genuinely empty case — the model would have produced the same confident “you have no orders” answer it gave for the real empty result. Nothing in the conversation would look wrong. The model can’t recover information the tool never sent, and neither can anyone reading the transcript afterward. Getting isError right at the source is the only point where this failure is even visible.

You can find the full file on GitHub. A PHP port is available too, built the same way as the earlier parts, using isError on the same tool_result block through Anthropic’s official PHP SDK.

Critical design decisions

  • isError is the delivery mechanism, not the explanation: the flag tells the model something failed. It says nothing about why, or what to do next. Everything past that has to live in the content next to it.
  • A category and a retryability flag answer the two questions that actually matter: what kind of problem this is, and whether trying again is worth it. A flat string answers neither, so the model ends up guessing at both.
  • Retry close to the failure when the failure is cheap to retry: a transient hiccup a tool can resolve locally shouldn’t cost the model a turn and a guess about whether isRetryable was worth acting on.
  • isRetryable: false after a local retry means “already tried,” not “never possible”: it’s reporting what already happened once retrying locally didn’t help, not making a permanent claim about the failure.
  • A valid empty result and an access failure need different content, not just different flags: “no matches” and “couldn’t check” both deserve to reach the model in words that actually say which one happened.
  • Getting isError wrong at the source is invisible everywhere downstream: a suppressed failure dressed up as a valid empty result produces a transcript that looks completely normal. There’s no later point where the mistake becomes visible again.

Consequences

  • A uniform error string for every failure type -> the model can’t tell a policy violation from a timeout from a bad input, so it fills the gap with speculation — wrong reasons, unhelpful advice, or an apology for a problem it doesn’t understand.
  • No retryability signal -> the model either retries a failure that will never succeed, wasting a turn on something already known to be hopeless, or gives up on a failure that would have worked the second time.
  • No local recovery for cheap transient failures -> every timeout costs an extra model call and depends on the model correctly noticing and acting on a flag, instead of just being handled where it happened.
  • An access failure reported as a valid empty result -> the agent confidently tells the user “there’s nothing here” when the truth is the system never actually checked. Nothing in the conversation can catch this after the fact.
  • A valid empty result reported as a failure -> the model treats a completed, correct answer as something broken, retrying a query that was never going to return anything different or escalating a case that didn’t need one.

Task 2.1 was about giving the model enough information to pick the right tool. Task 2.2 is the same lesson one step later: giving the model enough information to understand what happened once that tool actually ran. Both come down to the same thing — the model only knows what’s in the content it’s handed, and a flag by itself is never the whole story.