Curriculum › Tool Design & MCP Integration · 18% of the exam

Structured error responses for MCP tools

What you’ll be able to do

  • Return tool failures with the MCP isError flag and structured metadata: error category, a retryable flag, and a human-readable description
  • Classify failures as transient, validation, business, or permission errors, and give business-rule violations a customer-friendly explanation
  • Distinguish an access failure that needs a retry decision from a valid empty result
  • Recover from transient failures inside a subagent and propagate only unresolved errors, with partial results and what was attempted

What you need to know

isError tells the agent the call failed

In MCP, a tool reports a failure by returning a normal result with the isError flag set, so the failure reaches the agent as content it can reason about instead of disappearing into a crash. The flag only says that something went wrong. What the agent can do next depends on what else the result contains.

A uniform response such as "Operation failed" for every failure leaves the agent guessing. It can't tell a timeout that will probably succeed in a few seconds from a request the business rules will never allow. It either retries things that can't succeed, wasting calls and time, or gives up on things that would have worked.

Four kinds of failure

  • Transient: timeouts, service unavailable. The same call may succeed later. Retryable.
  • Validation: the input was invalid (malformed order ID, missing field). Retrying the same call won't help. The agent should fix the input first.
  • Business: the request is valid but a policy forbids it (refund over the limit, return window closed). Not retryable. The agent needs to explain the situation to the customer or escalate.
  • Permission: the caller isn't allowed to do this. Not retryable as-is. It needs a different route or a human.

Return the category, a retryable flag, and a description

Structured metadata turns those categories into something the agent can act on. The guide's pattern is an errorCategory (transient, validation, permission), an isRetryable boolean, and a human-readable description. For business-rule violations, mark the error as not retryable (retriable: false in the guide's wording) and include an explanation written so the agent can pass it on to a customer. The example below gives them a business category of their own; the field names matter less than the agent being able to tell the cases apart.

before — every failure looks the same
except Exception: return {"isError": True, "content": [{"type": "text", "text": "Operation failed"}]}
after — the agent can tell what to do next
except UpstreamTimeout: return error_result(errorCategory="transient", isRetryable=True, description="Order service timed out after 5s. Safe to retry.") except RefundLimitExceeded: return error_result(errorCategory="business", isRetryable=False, description="Refunds over $500 need manager approval.", customer_message="This refund needs a manager's approval. " "I can pass it to a specialist now.")

error_result(…) builds {“isError”: True, “content”: […]} with

the metadata serialized into the content the agent sees

With this, a retry loop can skip anything marked not retryable, and the agent can respond to the customer accurately instead of reporting a vague system problem.

A failed lookup is not an empty result

Two outcomes that both return "no data" mean very different things:

  • Access failure: the tool couldn't reach the source (timeout, permission denied). The answer is unknown. This is an error, and the agent has to decide whether to retry, try another route, or report the gap.
  • Valid empty result: the query ran and nothing matched. That is a real answer ("this customer has no orders in that range"), and it should come back as a success with an empty list.

If a tool catches a timeout and returns [], it turns "we don't know" into "there are none". The agent then tells the customer something false, and nothing upstream knows a retry was needed. The opposite mistake, marking a genuine no-match as an error, makes the agent retry a query that already gave the right answer.

Recover locally, escalate what you can’t fix

In a multi-agent system, a subagent that hits a transient failure should handle it itself, for example by retrying the timed-out call, instead of sending every hiccup up to the coordinator. Only errors the subagent can't resolve locally go up. When they do, they go with context: the error category, what was attempted (queries, retries, alternatives tried), and any partial results gathered before the failure. The coordinator can then decide to retry differently, use another source, or continue with what it has.

Both extremes fail. Escalating every transient error adds round trips the subagent could have avoided. Retrying until exhausted and then returning a generic "unavailable" status throws away the context the coordinator needs. Lesson 5.3 covers how that context flows through the coordinator.

Key concept

A failure result should say what kind of failure it was, whether retrying can help, and what happened, in words the agent can use. Keep “couldn’t check” separate from “checked, found nothing”, and fix transient errors locally before escalating the rest with partial results.

When a scenario describes wasted retries, a misleading "nothing found", or an agent that can't explain a refusal to a customer, look at the shape of the error result first.

Practice scenario

ScenarioA support agent's lookup_order tool wraps its database call in a catch-all that returns an empty list on any exception. During a database slowdown, the agent tells several customers they have no orders on file. Separately, process_refund returns "Operation failed" when a refund exceeds the policy limit, and the agent retries it three times before apologizing for "a technical issue."
Work it through, then open this

Both are error-shape problems. lookup_order is reporting an access failure as a valid empty result. It should return isError with a transient category and isRetryable: true on timeout, and keep the empty list only for queries that actually ran and found nothing. process_refund is reporting a business-rule violation as a generic failure. It should return a non-retryable business error with a customer-friendly explanation, so the agent stops retrying and tells the customer what the limit is and what can happen next (for example, escalation for approval).

Build exercise — Give one tool a real error contract

Intermediate · 30 min

What you’ll learn

  • Classifying a tool’s failures into transient, validation, business, and permission
  • Returning structured error metadata with isError
  • Separating access failures from valid empty results
  1. List every way one of your tools can fail or return nothing, and label each case transient, validation, business, permission, or valid empty result.

    • Why: You can’t design the response until you know which outcomes need which treatment.
    • You should see: A short table where several cases currently share one generic error path, and probably one case where a failure returns an empty success.
  2. Rewrite the failure paths to return isError with errorCategory, isRetryable, and a description, and add a customer-friendly message to the business-rule case.

    • Why: The agent’s next move depends on the category and retryability, and customers need an explanation, not an error code.
    • You should see: A retry loop that can skip non-retryable errors, and an agent that explains a policy limit instead of blaming a technical issue.
  3. If the tool runs inside a subagent, add local retry for transient errors, and on final failure return the category, what was attempted, and any partial results.

    • Why: Local recovery avoids unnecessary escalation, and the context lets the coordinator make a good decision when escalation is needed.
    • You should see: Transient blips resolved without the coordinator noticing, and unresolved failures arriving with enough detail to act on.

Exam traps

Returning a generic “Operation failed” for every kind of failure

A uniform error hides whether to retry, fix the input, explain a policy, or escalate.

Retrying errors that are not retryable, such as policy violations or permission denials

These won’t succeed on retry. An isRetryable: false flag prevents the wasted attempts.

Returning an empty result marked as success when the lookup actually failed

That turns “couldn’t check” into “there are none”, and the agent states something false.

Treating a successful query with no matches as a failure to retry

An empty result from a query that ran is a valid answer. Retrying it wastes calls.

Escalating every transient failure to the coordinator instead of recovering locally

The subagent should retry transient errors itself and pass up only what it can’t resolve.

Propagating an unresolved failure without partial results or a record of what was attempted

Without that context the coordinator can’t choose between retrying differently, switching sources, or continuing.

Sources

Quick check

An extraction pipeline must call extract_metadata before any enrichment tools, because they need its output. In about 10% of runs the model calls an enrichment tool first. What is the most reliable fix?