Skip to content

Structured Error Responses for MCP Tools

This module, D2.2, is mainly about this distinction:

Use JSON-RPC protocol errors when the MCP protocol call itself is invalid. Use isError: true tool results when the tool was invoked correctly but the operation failed.

1. Transient errors

A transient error means the request itself is valid, but some temporary system condition prevented the tool from completing.

Examples:

Timeout
Service unavailable
Database overloaded
Rate limit
Temporary network failure
Upstream API not responding

Agent behaviour:

Retry, usually with backoff. 
Do not ask the user to fix input. 
Do not immediately escalate unless retries fail.

Example:

{  
    "isError": true,  
    "errorCategory": "transient",  
    "isRetryable": true,  
    "description": "The order database timed out after 5 seconds. The request is valid and may succeed if retried after a short delay."
}

Exam clue:

If the system is temporarily unavailable but the user’s request is valid, classify it as transient.


2. Validation errors

A validation error means the tool received invalid or malformed input.

Examples:

Invalid date format
Missing required field
Wrong ID format
Amount is negative
Enum value is not allowed
Date range has start_date after end_date

Agent behaviour:

Fix the input and retry.
Do not blindly retry the same call.
Ask the user only if the correct value cannot be inferred.

Example:

{  
    "isError": true,  
    "errorCategory": "validation",  
    "isRetryable": true,  
    "description": "Order ID must be in format #NNNNN, such as #12345. Received 'order-abc'. Reformat the order ID and retry."
}

Important nuance:

isRetryable: true here means retry after correction, not “retry the exact same request.”


3. Business errors

A business error means the request is technically valid, but it violates a business rule, policy, or domain constraint.

Examples:

Refund exceeds automatic limit
Order cannot be cancelled after shipment
Customer is outside the return window
Discount is not allowed for this plan
Account cannot be deleted while invoices are unpaid

Agent behaviour:

Do not retry.
Explain the rule in customer-friendly language.
Use an alternative workflow, such as escalation or manager approval.

Example:

{  
    "isError": true,  
    "errorCategory": "business",  
    "isRetryable": false,  
    "description": "The requested refund of $750 exceeds the $500 automatic refund limit. Retrying will not succeed. Escalate to a manager for approval."
}

Exam clue:

If the same request will fail every time because of policy, it is business, not transient.


4. Permission errors

A permission error means the tool cannot complete because the caller lacks the required access.

Examples:

Insufficient role
Missing OAuth scope
Service account lacks access
User cannot view financial records
Credential expired or invalid

Agent behaviour:

Do not retry with the same credentials.
Ask for elevated access, use an approved alternate credential path, or escalate.
Do not reveal sensitive data.

Example:

{  
    "isError": true,  
    "errorCategory": "permission",  
    "isRetryable": false,  
    "description": "The current user does not have permission to access financial records. Request elevated access or escalate to a user with financial-system permissions."
}

Subtlety:

An expired token may be retryable after refresh, but not by blindly repeating the same failed call.

1. Use the canonical error fields

You can use a richer schema with fields like, Claude will understand and work:

{  
    "category": "validation",  
    "recoverable": true,  
    "automatic_retry_allowed": true
}

But the exam statement specifically calls out:

{  
  "isError": true,
  "errorCategory": "transient | validation | business | permission",
  "isRetryable": true,
  "description": "Human-readable explanation and recovery guidance"
}
}

For exam answers, prefer the official names:

isError
errorCategory
isRetryable
description

You can and should still add richer metadata, but do not miss those three.

A better exam-shaped error looks like this:

{  
    "isError": true,  
    "content": [    
        {      
            "type": "text",      
            "text": "Refund exceeds automatic approval limit."    
        }  
    ],  
    "errorCategory": "business",  
    "isRetryable": false,  
    "description": "Refund amount exceeds limit. Escalate to a manager."
}

Add subagent local recovery as a major concept

The official statement makes this a skill-level requirement. Add this directly to your guide:

Subagents should handle transient failures locally when possible. Only unrecoverable errors should be propagated to the coordinator. When propagating, include partial results and what was attempted.

Example:

{  
    "isError": true,  
    "errorCategory": "transient",  
    "isRetryable": false,  
    "description": "Research subagent retried two sources continued timing out.",  
    "partialResults": [    
        {      
            "source": "knowledge_base",      
            "status": "success",      
            "resultCount": 4    
        },    
        {      
            "source": "support_tickets",      
            "status": "success",      
            "resultCount": 2    
        }  
    ],  
    "attempted": [    
        "Searched knowledge_base once: succeeded",    
        "Searched support_tickets once: succeeded",    
        "Searched web_search three times: timed out each time"  
    ]
}

The coordinator can then decide:

Proceed with partial results.Ask the user whether partial results are acceptable.Try an alternate tool.Escalate.Report that some sources could not be checked.

Bad pattern:

{  
    "isError": false,  
    "content": [    
        {      
            "type": "text",      
            "text": "No results found."    
        }  
    ]
}

when the subagent failed to search some sources. That hides failure and creates false confidence.

Exam-ready answer pattern

When you see an MCP tool failure scenario, classify it in this order:

1. Did the tool successfully execute and find no matches?   → isError: false, resultCount: 0

2. Did the tool fail because the system was temporarily unavailable?   → isError: true, errorCategory: transient, isRetryable: true.

3. Did the tool fail because Claude passed invalid input?   → isError: true, errorCategory: validation, isRetryable: true after input correction.

4. Did the request violate a business rule?   → isError: true, errorCategory: business, isRetryable: false.

5. Did the caller lack access?   → isError: true, errorCategory: permission, usually isRetryable: false.

6. Is this happening inside a subagent?   → retry transient failures locally, then propagate only unresolved failure plus partial results and attempted actions.

5. MCP envelope for structured errors

MCP tool results can include unstructured content; they may also include structuredContent, and if a tool returns structured content, ==the spec says it should also return serialized JSON in a text content block for backward compatibility==. Tools can define an outputSchema, and when they do, structured results must conform to that schema. (Model Context Protocol)

A strong MCP-style tool execution error:

{
  "jsonrpc": "2.0",
  "id": 12,
  "result": {
    "isError": true,
    "content": [
      {
        "type": "text",
        "text": "{\"ok\":false,\"error\":{\"code\":\"INVALID_DATE_RANGE\",\"category\":\"validation\",\"message\":\"start_date must be before end_date.\",\"recoverable\":true,\"automatic_retry_allowed\":true,\"suggested_action\":\"Retry with start_date earlier than end_date.\",\"example_arguments\":{\"start_date\":\"2026-07-01\",\"end_date\":\"2026-07-10\"}}}"
      }
    ],
    "structuredContent": {
      "ok": false,
      "error": {
        "code": "INVALID_DATE_RANGE",
        "category": "validation",
        "message": "start_date must be before end_date.",
        "recoverable": true,
        "automatic_retry_allowed": true,
        "suggested_action": "Retry with start_date earlier than end_date.",
        "example_arguments": {
          "start_date": "2026-07-01",
          "end_date": "2026-07-10"
        }
      }
    }
  }
}

Exam nuance: Output schema must allow error shape

If your tool has an outputSchema that only describes success output, then returning error-shaped structuredContent may violate that schema. Better options:

  1. Define a union-like schema that supports both success and error results.
  2. Return error details in content only.
  3. Use a consistent result envelope where both success and failure conform to the same schema.

Recommended pattern:

{
  "ok": true,
  "data": {
    "ticket_id": "SUP-1042",
    "status": "open"
  },
  "error": null
}

or:

{
  "ok": false,
  "data": null,
  "error": {
    "code": "TICKET_NOT_FOUND",
    "category": "not_found",
    "message": "No support ticket exists with ID SUP-9999.",
    "recoverable": true,
    "suggested_action": "Search tickets by customer or keyword."
  }
}


6. TypeScript helper pattern

This is the kind of implementation pattern you should recognize for the exam.

type ToolErrorCategory =
  | "validation"
  | "authorization"
  | "not_found"
  | "confirmation_required"
  | "conflict"
  | "rate_limit"
  | "upstream"
  | "policy"
  | "internal";

type FieldError = {
  field: string;
  received?: unknown;
  expected: string;
};

type StructuredToolError = {
  ok: false;
  data: null;
  error: {
    code: string;
    category: ToolErrorCategory;
    message: string;
    recoverable: boolean;
    automatic_retry_allowed: boolean;
    suggested_action: string;
    field_errors?: FieldError[];
    example_arguments?: Record<string, unknown>;
    retry_after_seconds?: number;
    requires_user_action?: boolean;
    correlation_id?: string;
  };
};

type ToolSuccess<T> = {
  ok: true;
  data: T;
  error: null;
};

function textBlock(payload: unknown) {
  return {
    type: "text" as const,
    text: JSON.stringify(payload)
  };
}

function toolSuccess<T>(data: T) {
  const payload: ToolSuccess<T> = {
    ok: true,
    data,
    error: null
  };

  return {
    isError: false,
    content: [textBlock(payload)],
    structuredContent: payload
  };
}

function toolError(error: StructuredToolError["error"]) {
  const payload: StructuredToolError = {
    ok: false,
    data: null,
    error
  };

  return {
    isError: true,
    content: [textBlock(payload)],
    structuredContent: payload
  };
}

This helper enforces consistency:

Success → isError: false, ok: true, data present, error null
Failure → isError: true, ok: false, data null, error present

7. Example MCP tool with structured errors

async function searchSupportTickets(args: {
  query: string;
  status?: "open" | "pending" | "solved" | "closed" | "any";
  maxResults?: number;
}) {
  const query = args.query?.trim();
  const maxResults = args.maxResults ?? 5;

  if (!query) {
    return toolError({
      code: "MISSING_QUERY",
      category: "validation",
      message: "query is required and must be a short, focused search phrase.",
      recoverable: true,
      automatic_retry_allowed: true,
      suggested_action: "Retry with a focused query such as 'SSO timeout after redirect'.",
      field_errors: [
        {
          field: "query",
          received: args.query,
          expected: "Non-empty string search phrase"
        }
      ],
      example_arguments: {
        query: "SSO timeout after redirect",
        status: "any",
        maxResults: 5
      }
    });
  }

  if (maxResults < 1 || maxResults > 20) {
    return toolError({
      code: "INVALID_MAX_RESULTS",
      category: "validation",
      message: "maxResults must be between 1 and 20.",
      recoverable: true,
      automatic_retry_allowed: true,
      suggested_action: "Retry with maxResults between 1 and 20.",
      field_errors: [
        {
          field: "maxResults",
          received: maxResults,
          expected: "Integer from 1 to 20"
        }
      ],
      example_arguments: {
        query,
        status: args.status ?? "any",
        maxResults: 5
      }
    });
  }

  try {
    const tickets = await fakeTicketSearch(query, args.status ?? "any", maxResults);

    if (tickets.length === 0) {
      return toolError({
        code: "NO_TICKETS_FOUND",
        category: "not_found",
        message: "No support tickets matched the query and filters.",
        recoverable: true,
        automatic_retry_allowed: false,
        suggested_action: "Ask the user for another keyword, customer, date range, or broader filter."
      });
    }

    return toolSuccess({
      tickets,
      result_count: tickets.length,
      truncated: tickets.length === maxResults
    });
  } catch (error) {
    const correlationId = crypto.randomUUID();

    // Log full internal details server-side only.
    console.error({
      correlationId,
      message: "Ticket search upstream failure",
      error
    });

    return toolError({
      code: "SUPPORT_API_UNAVAILABLE",
      category: "upstream",
      message: "The support ticket API is temporarily unavailable.",
      recoverable: true,
      automatic_retry_allowed: true,
      suggested_action: "Retry once. If the failure repeats, report the outage with the correlation ID.",
      correlation_id: correlationId
    });
  }
}

Notice what this does not do:

It does not throw raw exceptions to the model.
It does not expose stack traces.
It does not return isError: false with an error payload.
It does not say only “failed.”
It tells Claude what to do next.

8. Bad vs good examples

Bad: Protocol error for a normal validation failure

{
  "jsonrpc": "2.0",
  "id": 8,
  "error": {
    "code": -32602,
    "message": "Invalid date"
  }
}

Why this is weak:

The tool exists.
The tool handler received the call.
The date failed business/input validation.
Claude could fix it if told the expected format.
Therefore this should be an isError: true tool result.

Good

{
  "jsonrpc": "2.0",
  "id": 8,
  "result": {
    "isError": true,
    "content": [
      {
        "type": "text",
        "text": "{\"ok\":false,\"error\":{\"code\":\"INVALID_DATE\",\"category\":\"validation\",\"message\":\"updated_after must be an ISO date in YYYY-MM-DD format.\",\"recoverable\":true,\"automatic_retry_allowed\":true,\"suggested_action\":\"Retry using a date such as 2026-06-01.\",\"example_arguments\":{\"updated_after\":\"2026-06-01\"}}}"
      }
    ]
  }
}

9. Security and privacy rules

Structured errors should be helpful, but not leaky.

Do not return:

Stack traces
Database queries
Access tokens
API keys
Raw upstream request headers
Sensitive customer data
Full permission policy internals
Private infrastructure names unless necessary

Better:

{
  "ok": false,
  "error": {
    "code": "INTERNAL_ERROR",
    "category": "internal",
    "message": "The operation failed due to an internal server error.",
    "recoverable": true,
    "automatic_retry_allowed": false,
    "suggested_action": "Do not retry repeatedly. Report the correlation ID to support.",
    "correlation_id": "err_01HX8QR2J9"
  }
}

Use logs for developer diagnostics; use structured error responses for agent recovery.


10. Exam decision table

Scenario Best answer
Unknown MCP tool name JSON-RPC protocol error
Request malformed against tools/call schema JSON-RPC protocol error
Missing required business field isError: true with validation error
Date format wrong isError: true with expected format and example
User lacks permission isError: true, no automatic retry
User has not confirmed destructive action isError: true, ask user for confirmation
Upstream API rate limited isError: true, include retry guidance
Upstream timeout isError: true, include retry guidance and correlation ID
Tool returns partial results Usually isError: false with warnings, or isError: true if task cannot be completed
Internal exception Sanitized isError: true or protocol error only if execution could not be safely represented
Output has structuredContent Ensure it conforms to outputSchema if one exists

11. High-yield checklist

Before the exam, memorize this:

1. Tool execution failures should usually return result.isError = true.
2. Do not use JSON-RPC protocol errors for normal business or validation failures.
3. Error messages should be actionable for Claude.
4. Include stable error codes.
5. Include field-level validation details when arguments are wrong.
6. Include expected formats and example corrected arguments.
7. Distinguish retryable, recoverable, and user-action-required errors.
8. Do not allow blind retries for permission, policy, or confirmation failures.
9. Sanitize error output; never expose secrets or stack traces.
10. Use correlation IDs for debugging.
11. If using structuredContent with outputSchema, make sure the schema allows the error shape.
12. Clients should pass tool execution errors back to the model so it can self-correct.
13. Clients should validate tool results, enforce timeouts, log usage, and require confirmation for sensitive operations.

Scenario-based practice questions

Question 1

An MCP client calls a valid tool, but the tool rejects start_date: "next Friday" because it requires YYYY-MM-DD.

What should the server return?

A. JSON-RPC protocol error
B. isError: false with text saying “Invalid date”
C. isError: true with a structured validation error, expected format, and example corrected argument
D. No response

Answer

C. This is a tool execution error, not a protocol error. Claude can recover if the result explains the expected format.


Question 2

Claude calls tools/call with a tool name that does not exist.

Best response?

A. isError: true with TOOL_FAILED
B. JSON-RPC error such as Unknown tool
C. Successful result with empty content
D. Ask the user what to do

Answer

B. Unknown tool name is a protocol-level issue according to the MCP error model.


Question 3

A refund tool receives valid arguments, but confirmedByUser is false.

Best response?

A. Execute the refund because the model intended it.
B. Return isError: true with MISSING_USER_CONFIRMATION, requires_user_action: true, and no automatic retry.
C. Return isError: false and say “not refunded.”
D. Throw a stack trace.

Answer

B. The operation has a financial side effect. The response should guide Claude to ask for confirmation, not retry automatically.


Question 4

A tool returns:

{
  "isError": false,
  "content": [
    {
      "type": "text",
      "text": "ERROR: Permission denied"
    }
  ]
}

What is wrong?

A. Nothing; the text contains the word ERROR.
B. Permission errors should not be returned to Claude.
C. isError should be true, and the error should be structured with no automatic retry.
D. The tool should retry indefinitely.

Answer

C. Do not encode failures as successful tool results. The agent and client need a reliable error signal.


Question 5

A tool has this outputSchema:

{
  "type": "object",
  "properties": {
    "ticket_id": { "type": "string" },
    "status": { "type": "string" }
  },
  "required": ["ticket_id", "status"]
}

The tool returns structuredContent:

{
  "ok": false,
  "error": {
    "code": "TICKET_NOT_FOUND"
  }
}

What is the issue?

A. Error results can ignore schemas.
B. structuredContent may violate the declared outputSchema.
C. The error code is too long.
D. isError must always be omitted.

Answer

B. If the tool declares an output schema, structured results must conform to it. Use a success/error envelope schema or return the error in text content only.


Question 6

A tool catches an upstream API exception. The exception contains request headers and an access token.

What should the tool return?

A. The full exception so Claude can debug.
B. A sanitized structured error with category upstream or internal, a safe message, retry guidance, and correlation ID.
C. Nothing.
D. The access token, but only in structuredContent.

Answer

B. Structured errors should support recovery without leaking secrets.


Question 7

A tool is rate-limited by an upstream API.

Best response?

A. isError: true, category rate_limit, include retry_after_seconds if known.
B. JSON-RPC parse error.
C. Infinite automatic retry.
D. Return success with zero results.

Answer

A. Rate limiting is a tool execution failure that may be recoverable with delay or narrower query.


Question 8

A search tool returns no matches. Should this always be isError: true?

A. Yes, no results are always an error.
B. No. It depends on the tool contract and user task. A normal empty search may be a successful result with result_count: 0; a required lookup by exact ID may be isError: true.
C. It should be a protocol error.
D. It should crash.

Answer

B. The exam often tests judgment. Empty results are not automatically tool errors; define behavior consistently.


Question 9

A tool returns:

{
  "ok": false,
  "error": {
    "code": "BAD_INPUT",
    "message": "Invalid."
  }
}

What is missing?

A. Nothing.
B. Field-level details, expected format, recoverability, retry guidance, and suggested next action.
C. A stack trace.
D. The user’s password.

Answer

B. A structured error must be actionable, not merely machine-readable.


Question 10

A business rule prevents booking travel for a past date. Claude passes departure_date: "2026-01-01" when today is 2026-06-25.

Best error?

A. “Failed.”
B. “Invalid departure date: must be today or later. Current date is 2026-06-25. Retry with an ISO date such as 2026-07-01.”
C. Protocol error -32700.
D. Return success and silently change the date.

Answer

B. This gives Claude the reason, current date, format, and a valid example.


D2.2 mental model

For this module, think like this:

MCP protocol errors help clients debug broken calls.
MCP tool execution errors help Claude recover from failed operations.

The best exam answers make errors:

Structured
Actionable
Recoverable where appropriate
Safe to expose to the model
Consistent across tools
Clear about whether to retry, ask the user, try another tool, or stop