Study guides / CCAR-F / Domain 2

Tool Design & MCP Integration · Lesson 2 of 5

2.2 - Structured Error Responses

Implementing structured error responses for MCP tools with proper categorisation and recovery metadata

When an MCP tool fails, the error response it returns determines whether the agent can recover intelligently or fail blindly. Generic messages like "Operation failed" are useless to an LLM. No signal about what went wrong, whether to retry, or what to try instead.

The MCP protocol provides the isError flag specifically for communicating tool failures back to the agent. Set it and the model knows the execution failed, so it can reason about recovery instead of treating the error text as a normal successful result.

The Four Error Categories

Every tool failure falls into one of four categories. Each demands a different recovery strategy, and the agent needs structured metadata to distinguish them.

1. Transient Errors Timeouts, service unavailability, rate limits. The underlying system is temporarily unreachable but the request itself is valid. Recovery: retry after a brief delay.

{
  "isError": true,
  "content": [{
    "type": "text",
    "text": "Service temporarily unavailable"
  }],
  "errorCategory": "transient",
  "isRetryable": true,
  "description": "The order database is experiencing high load. The request is valid and should succeed on retry."
}

2. Validation Errors Invalid input format, missing required fields, out-of-range values. The request itself is malformed. Recovery: fix the input, then send a corrected call.

{
  "isError": true,
  "content": [{
    "type": "text",
    "text": "Invalid order ID format"
  }],
  "errorCategory": "validation",
  "isRetryable": false,
  "description": "Order ID must be in format #NNNNN (e.g. #12345). Received: 'order-abc'. Reformat the ID and call again."
}

isRetryable: false here is not "give up". It means resending this call is pointless: order-abc fails the same format check every time. The agent still recovers, just by correcting the input first - and the description tells it exactly how. The boolean says whether to resend; errorCategory says what to do instead.

3. Business Errors Policy violations, limit exceedances, business rule conflicts. The request is technically valid but violates a business constraint. Recovery: do NOT retry - the same request will always fail. The agent needs an alternative workflow.

{
  "isError": true,
  "content": [{
    "type": "text",
    "text": "Refund exceeds policy limit"
  }],
  "errorCategory": "business",
  "isRetryable": false,
  "description": "Refund amount of £750 exceeds the £500 automatic refund limit. This requires manager approval. Please escalate to a human agent with the refund details."
}

Note the isRetryable: false flag. Business errors never resolve through retrying - the same policy violation applies every time. The agent has to take a fundamentally different path, usually escalation or an alternative workflow, and a customer-friendly explanation in the description lets it communicate that properly.

4. Permission Errors Access denied, insufficient credentials, authorisation failures. The tool cannot execute because the caller lacks the required permissions. Recovery: escalate or use different credentials.

{
  "isError": true,
  "content": [{
    "type": "text",
    "text": "Access denied"
  }],
  "errorCategory": "permission",
  "isRetryable": false,
  "description": "The current service account does not have permission to access financial records. Escalate to a senior agent with financial system access."
}

What isRetryable Really Signals

isRetryable answers one narrow question: will resending this exact request work? Only transient errors get true - the call was valid, the system was briefly not. Everything else is false, because something has to change first: the input (validation), the request itself (business), or the caller (permission).

Read isRetryable to decide whether to resend as-is, then read errorCategory to decide what to do when you can't:

Category isRetryable Recovery
transient true Resend the same call after a delay
validation false Correct the input, send a new call
business false Take an alternative path or escalate
permission false Retry as a principal with the right access

The distinction that matters most is between the three false rows. Validation is recoverable by the agent alone. Business and permission are not - a policy limit applies no matter how the request is worded, and a permission error needs a different account, not a better call. false means "not this call again", not "stop".

Current state: where this table comes from

The exam guide (v1.0) states retriable: false for business rule violations and never assigns a value to validation. The false above is the convention the wider ecosystem uses - gRPC treats INVALID_ARGUMENT as non-retryable, and AWS-style retry metadata does the same - applied to a gap the guide leaves open. Expect the exam to test which category a failure belongs to and what recovery it needs, which the guide does specify. If a question turns on the boolean for validation, reason from "will resending this exact call work" and you will land on false. (Verified against the exam guide, August 2026.)

Access Failure vs Valid Empty Result

Of everything in this domain, this is the distinction to nail. The exam tests it directly.

Access failure: The tool couldn't reach the data source. A timeout occurred, authentication failed, or the service was down. The data might exist, but the tool couldn't check. The agent needs to decide whether to retry.

Valid empty result: The tool successfully queried the data source and found no matches. The query executed correctly - there simply is no data matching the criteria. The agent should NOT retry. The answer is "no results found."

Confusing the two breaks recovery logic entirely. Here's how that plays out:

A tool returns an empty array after a customer lookup. The agent retries 3 times, then escalates to a human. Analysis reveals the customer's account simply does not exist.

The tool succeeded. It queried the database, found no matching customer, and correctly returned an empty result. But because the response doesn't distinguish between "I couldn't reach the database" and "I reached the database and found nothing", the agent treats both the same way - as a failure worth retrying.

The fix: structure your tool responses so a successful query with no results looks nothing like a failed query.

// Valid empty result - NOT an error
{
  "isError": false,
  "content": [{
    "type": "text",
    "text": "No customer found matching email 'john@example.com'. The query executed successfully but returned no matches."
  }],
  "resultCount": 0
}

// Access failure - IS an error
{
  "isError": true,
  "content": [{
    "type": "text",
    "text": "Could not reach customer database"
  }],
  "errorCategory": "transient",
  "isRetryable": true,
  "description": "Connection to the customer database timed out after 5 seconds. The query did not execute."
}

Error Propagation in Multi-Agent Systems

In multi-agent architectures, error handling follows a principle of local recovery with selective propagation:

  1. Subagents implement local recovery for transient failures. If a web search times out, the search subagent retries before bothering the coordinator.
  2. Only propagate errors that cannot be resolved locally. If all retries fail, the subagent reports the failure upward.
  3. Include partial results and what was attempted. The coordinator needs context: "I searched 3 of 5 sources successfully. Sources 4 and 5 timed out. Here are partial results from the 3 successful sources."

This prevents two anti-patterns: silently suppressing errors (returning empty results as success) and terminating entire workflows on a single failure. Both leave the coordinator making decisions blind.

Key Concept

The distinction between access failures (tool could not reach the data source) and valid empty results (tool successfully queried and found nothing) is critical. Confusing the two causes wasted retries and incorrect escalations. The exam tests this directly.

Exam traps

Practice question

A tool returns an empty array after a customer lookup. The agent retries 3 times, then escalates to a human agent. Analysis shows the customer's account simply does not exist. What is the root cause of this wasted effort?

  • A The retry limit is too low. Raising it to 5 attempts would give the lookup enough chances to return the account before escalation triggers.

    More retries make the problem worse. The tool succeeded - it found no matching customer. Retrying a successful query with no matches will never produce different results.

  • B The system prompt should instruct the agent never to retry a customer lookup, so that every failed search escalates to a human immediately.

    Hard-coding retry rules per tool in the system prompt is brittle and does not generalise. The proper fix is structured error metadata that tells the agent whether the result is retryable.

  • C The escalation threshold is too aggressive. The agent should exhaust more retries before involving a human in the loop.

    The problem is not the escalation threshold. The problem is that the agent retries at all. A valid empty result requires no retry and no escalation - it is the correct answer.

  • D The tool does not distinguish between access failures and valid empty results, so the agent treats no matches as a retriable failure. Correct

    The tool successfully queried the data source and found no matches. This is a valid empty result, not an access failure. Without structured metadata distinguishing these two cases, the agent treats both as failures.

Build exercise: Build Structured Error Responses for All Four Categories

Intermediate · 45 minutes

You'll practice:

  1. Create an MCP tool that queries a mock customer database with simulated failure modes

    Simulating failure modes in a controlled environment lets you observe how agents behave when errors lack structure. The exam tests your understanding of how poor error responses cause wasted retries and incorrect escalations.

    You should see: An MCP server running with a customer_lookup tool that accepts a customer identifier and a failure_mode parameter to trigger specific error conditions on demand.

    Hints
    1. Design the tool to accept two parameters: an identifier for the customer lookup and a mode parameter that controls which failure scenario to simulate.
    2. Use the MCP SDK McpServer class. Define input parameters with enum values for the failure modes so you can trigger each scenario predictably.
    3. import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
      import { z } from "zod";
      const server = new McpServer({ name: "customer-db", version: "1.0.0" });
      server.tool("customer_lookup",
        "Looks up a customer by email or ID. Supports failure simulation via mode parameter.",
        {
          identifier: z.string().describe("Customer email or ID"),
          mode: z.enum(["success", "not_found", "timeout", "invalid", "business", "permission"]).default("success")
        },
        async ({ identifier, mode }) => {
          // Implementation in next steps
          return { content: [{ type: "text", text: `Mode: ${mode}` }] };
        }
      );
  2. Implement four error response types: transient (simulated timeout), validation (invalid input format), business (refund exceeds policy limit), and permission (access denied)

    Each error category demands a different recovery strategy. The exam tests whether you can identify which category an error belongs to and what recovery action is appropriate. Transient errors are retryable; business errors never are.

    You should see: Four distinct error responses, each with isError: true, a specific errorCategory value, the correct isRetryable boolean, and a descriptive message explaining what went wrong and what to do next.

    Hints
    1. Only transient errors get isRetryable: true - resending the identical call can work. Validation, business and permission are all false, because something has to change first. errorCategory is what tells the agent which recovery to run.
    2. Each error response should include three metadata fields beyond the MCP standard: errorCategory (string), isRetryable (boolean), and description (string with recovery guidance).
    3. if (mode === "timeout") {
        return {
          isError: true,
          content: [{ type: "text", text: JSON.stringify({
            errorCategory: "transient",
            isRetryable: true,
            description: "Customer database timed out after 5 seconds. The request is valid and should succeed on retry."
          }) }]
        };
      }
      if (mode === "invalid") {
        return {
          isError: true,
          content: [{ type: "text", text: JSON.stringify({
            errorCategory: "validation",
            isRetryable: false,
            description: `Invalid identifier format: ${identifier}. Expected email (user@domain.com) or ID (CUST-NNNNN). Correct the format and call again.`
          }) }]
        };
      }
      if (mode === "business") {
        return {
          isError: true,
          content: [{ type: "text", text: JSON.stringify({
            errorCategory: "business",
            isRetryable: false,
            description: "Refund of \u00a3750 exceeds the \u00a3500 automatic limit. Escalate to a manager with refund details."
          }) }]
        };
      }
      if (mode === "permission") {
        return {
          isError: true,
          content: [{ type: "text", text: JSON.stringify({
            errorCategory: "permission",
            isRetryable: false,
            description: "Current service account lacks access to financial records. Escalate to a senior agent."
          }) }]
        };
      }
  3. Include structured metadata in each error: errorCategory, isRetryable boolean, and a human-readable description

    Structured metadata is what enables intelligent recovery. Without these fields, the agent cannot distinguish a transient timeout from a permanent policy violation. The exam specifically tests whether you know that isRetryable: false means the agent must take an alternative path, not retry.

    You should see: Each error response parses to a JSON object containing exactly three fields: errorCategory (one of transient, validation, business, permission), isRetryable (boolean), and description (a sentence explaining the error and suggesting recovery).

    Hints
    1. Verify your responses by parsing them with JSON.parse() - if any field is missing or the wrong type, the agent recovery logic will break.
    2. Create a helper function that builds the error response structure consistently, ensuring every error includes all three metadata fields.
    3. function buildErrorResponse(category: string, retryable: boolean, description: string) {
        return {
          isError: true,
          content: [{ type: "text", text: JSON.stringify({
            errorCategory: category,
            isRetryable: retryable,
            description: description
          }) }]
        };
      }
  4. Implement a valid empty result response (isError: false, resultCount: 0) clearly distinguished from an access failure

    This is one of the most critical distinctions in Domain 2. Confusing access failures with valid empty results causes wasted retries and incorrect escalations. The exam tests this directly - an agent retrying a successful empty query is the canonical anti-pattern.

    You should see: Two structurally different responses: a valid empty result with isError: false and resultCount: 0 (indicating the query ran successfully but found nothing), and an access failure with isError: true, errorCategory: transient, and isRetryable: true.

    Hints
    1. The key difference is isError. A valid empty result is NOT an error - the tool succeeded. An access failure IS an error - the tool could not execute the query.
    2. Compare the two side by side: the empty result should explicitly state the query executed successfully, whilst the access failure should state the query did not execute.
    3. if (mode === "not_found") {
        return {
          isError: false,
          content: [{ type: "text", text: JSON.stringify({
            resultCount: 0,
            message: `No customer found matching ${identifier}. The query executed successfully but returned no matches.`
          }) }]
        };
      }
      // Compare with the timeout (access failure):
      if (mode === "timeout") {
        return {
          isError: true,
          content: [{ type: "text", text: JSON.stringify({
            errorCategory: "transient",
            isRetryable: true,
            description: "Connection timed out. The query did not execute."
          }) }]
        };
      }
  5. Write an agent loop that reads the error metadata and takes appropriate action: retry for transient, fix input for validation, escalate for business, and request credentials for permission

    The agent loop demonstrates the practical outcome of structured error metadata. Each error category maps to a specific recovery action, and the loop must branch correctly. This is exactly the kind of decision logic the exam expects you to design.

    You should see: An agent loop that parses the error metadata, branches on errorCategory, retries transient errors up to 3 times with backoff, reformats input for validation errors, escalates business errors to a human, and requests elevated credentials for permission errors.

    Hints
    1. Use isRetryable to decide whether resending the identical call is worth attempting at all, then branch on errorCategory for the actual recovery. Do not treat isRetryable: false as a stop signal - validation lands there and the agent recovers on its own.
    2. Implement exponential backoff for transient retries (e.g. 1s, 2s, 4s) and cap retries at 3. For validation, rebuild the arguments from the description and issue a fresh call. For business and permission, log the escalation path and stop.
    3. async function handleToolResult(result: any) {
        if (!result.isError) {
          const data = JSON.parse(result.content[0].text);
          if (data.resultCount === 0) {
            console.log("Valid empty result - no matches found. Do NOT retry.");
            return;
          }
          console.log("Success:", data);
          return;
        }
        const error = JSON.parse(result.content[0].text);
        switch (error.errorCategory) {
          case "transient":
            console.log("Retrying after delay...");
            break;
          case "validation":
            console.log("Fixing input format and retrying...");
            break;
          case "business":
            console.log("Escalating to human agent:", error.description);
            break;
          case "permission":
            console.log("Requesting elevated credentials...");
            break;
        }
      }

Sources