# MCP tool errors agents can actually recover from

- Canonical: https://ansezz.com/blog/mcp-structured-tool-errors-laravel/
- Markdown: https://ansezz.com/blog/mcp-structured-tool-errors-laravel/index.md
- Published: 2026-09-19
- Description: Throwing exceptions hides failures from the model. MCP wants isError tool results with actionable text, mapped from Laravel domain failures and audited like auth denials.
- Category: [laravel](https://ansezz.com/blog/category/laravel/)
- Tags: [laravel](https://ansezz.com/blog/tag/laravel/), [mcp](https://ansezz.com/blog/tag/mcp/), [agentic-ai](https://ansezz.com/blog/tag/agentic-ai/), [security](https://ansezz.com/blog/tag/security/), [api-design](https://ansezz.com/blog/tag/api-design/), [multi-tenancy](https://ansezz.com/blog/tag/multi-tenancy/)

When a tool fails, the model needs a sentence it can act on, not a crashed server.

**TL;DR**

- Business failures go back as a normal tool result with `isError: true`. JSON-RPC errors are for protocol problems only.
- Catch once at the MCP adapter and map domain exceptions to a small, stable set of codes.
- Each error needs a hint that names the next tool or field, and the same code goes into your audit log.

[Auth and audit logging](/blog/mcp-auth-audit-logging-laravel/) keep a tool attributable. [Idempotency keys](/blog/mcp-idempotency-laravel-mutations/) keep a retry from charging twice. Neither helps if a failed tool call looks like a crashed server to the model.

I keep seeing the same bug in Laravel MCP servers. A domain failure throws, the transport turns it into a JSON-RPC error (or an empty 500), and Claude never gets a sentence it can use to self-correct.

The agent retries the same bad invoice ID, invents a different tool, or apologizes to the user. The invoice was simply not in that tenant.

This post is the third gate I ship with every mutating MCP surface: **tool execution errors that stay inside the tool result**, carry a stable code, and tell the model what to try next, without leaking stack traces or other tenants' IDs.

## The failure mode

A support agent asks Claude to fetch invoice `inv_9f2`. Your tool does this:

```php
public function handle(string $invoiceId, McpActor $actor): array
{
    $invoice = Invoice::query()
        ->where('tenant_id', $actor->tenantId)
        ->where('id', $invoiceId)
        ->firstOrFail(); // ModelNotFoundException → 500 / JSON-RPC error

    return $this->present($invoice);
}
```

`firstOrFail()` is correct for an HTTP controller that should return 404. Over MCP it is wrong.

### Two channels for errors

The Model Context Protocol draws a hard line:

1. **Protocol / request errors:** unknown tool name, malformed arguments at the RPC layer. JSON-RPC `error` object. The client may surface these; models rarely recover well.
2. **Tool execution errors:** business failure, missing row, permission denial you already decided to explain, upstream timeout. Reported as a **successful** `tools/call` result with `isError: true` and actionable text in `content`.

That second path is how the [MCP tools specification](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) expects language models to self-correct. Hide the failure in a protocol error and you train the agent to flail.

![Comic pop-art panel: two MCP error channels, JSON-RPC protocol errors on one side and tool results with isError true on the other](/blog/mcp-structured-tool-errors-laravel/error-channels-c8925e3a.webp)

*Protocol errors stop the loop. Tool results with isError let the model fix itself.*

## Return isError. Do not throw across the wire.

Every tool handler ends in one of three shapes:

```php
final class McpToolResult
{
    public static function ok(array $payload): array
    {
        $json = json_encode($payload, JSON_THROW_ON_ERROR);

        return [
            'content' => [['type' => 'text', 'text' => $json]],
            'structuredContent' => $payload, // optional; keep in sync with outputSchema if you declare one
            'isError' => false,
        ];
    }

    public static function fail(
        string $code,
        string $message,
        bool $retryable = false,
        ?string $hint = null,
    ): array {
        $body = array_filter([
            'error' => [
                'code' => $code,
                'message' => $message,
                'retryable' => $retryable,
                'hint' => $hint,
            ],
        ]);

        $text = $hint
            ? "{$message} Hint: {$hint}"
            : $message;

        return [
            'content' => [['type' => 'text', 'text' => $text]],
            // Prefer omitting structuredContent on errors unless your clients
            // are known to skip outputSchema validation when isError is true.
            'isError' => true,
        ];
    }
}
```

Notes that bite in production:

- **`isError: true` is still a successful RPC result.** The call happened; the business outcome failed. That is the whole point.
- **Put the recovery text in `content`.** Models read the text blocks. A code alone is not enough.
- **Be careful with `structuredContent` on errors** if the tool declares `outputSchema`. Some clients still validate error payloads against the success schema. The safe default today: actionable text in `content`, no structured error object, unless you have verified your client stack.
- **Never put secrets, stack traces, SQL, or other tenants' identifiers in the text the model sees.** Audit can keep a redacted internal reason; the agent gets a public message.

**Try this**

Search your MCP tools for `firstOrFail()` and uncaught exceptions. Each one is a place where the model gets a crash instead of a hint.

## Map Laravel domain failures to a small code set

Do not invent a new error dialect per tool. Use one mapper, shared with the [auth gate](/blog/mcp-auth-audit-logging-laravel/) denial reasons and the [idempotency](/blog/mcp-idempotency-laravel-mutations/) conflict codes:

| Code | When | Retry? | Hint |
| --- | --- | --- | --- |
| `not_found` | Missing in this tenant | no | List first; check the ID |
| `forbidden` | Actor lacks scope | no | Elevate or pick another resource |
| `validation_failed` | Args fail domain rules | no | Name the bad field and expected shape |
| `conflict` | Same key, different args | no | New key for a new intent |
| `in_flight` | Idempotency claim held | yes | Retry the **same** key shortly |
| `rate_limited` | Tool budget exhausted | yes | Back off; include Retry-After |
| `upstream_unavailable` | Provider blip | yes | Retry once; no second refund |
| `internal` | Truly unexpected | no | Stop; send the user to support |

```php
final class McpErrorMapper
{
    public function fromThrowable(\Throwable $e, McpActor $actor): array
    {
        return match (true) {
            $e instanceof ModelNotFoundException => McpToolResult::fail(
                code: 'not_found',
                message: 'Resource not found in your workspace.',
                hint: 'List resources first, then retry with an ID from that list.',
            ),
            $e instanceof AuthorizationException => McpToolResult::fail(
                code: 'forbidden',
                message: 'You are not allowed to run this tool on that resource.',
                hint: 'Ask the account owner for the required permission.',
            ),
            $e instanceof ValidationException => McpToolResult::fail(
                code: 'validation_failed',
                message: $e->validator->errors()->first() ?: 'Invalid arguments.',
                hint: 'Fix the field named in the message and call again.',
            ),
            $e instanceof IdempotencyConflict => McpToolResult::fail(
                code: 'conflict',
                message: 'Idempotency key was reused with different arguments.',
                hint: 'Generate a new idempotency_key for a new intent.',
            ),
            $e instanceof IdempotencyInFlight => McpToolResult::fail(
                code: 'in_flight',
                message: 'A request with this idempotency key is still running.',
                retryable: true,
                hint: 'Wait a few seconds and retry with the same idempotency_key.',
            ),
            default => McpToolResult::fail(
                code: 'internal',
                message: 'The tool failed unexpectedly. Do not retry with new side effects.',
                hint: 'Tell the user support needs the session id '.$actor->sessionId.'.',
            ),
        };
    }
}
```

### Catch once, at the adapter

Catch at the MCP adapter boundary, once, not inside every tool with a different string. Tools throw domain exceptions. The adapter decides what the model is allowed to see.

```php
public function invoke(string $tool, array $args, McpActor $actor): array
{
    try {
        $this->auth->assertAllowed($actor, $tool, $args);
        $result = $this->tools->run($tool, $args, $actor);
        $this->audit->allowed($tool, $args, $result);

        return McpToolResult::ok($result);
    } catch (\Throwable $e) {
        $error = $this->errors->fromThrowable($e, $actor);
        $this->audit->denied($tool, $args, $this->errors->codeOf($e));

        return $error;
    }
}
```

`not_found` must mean "not in **this** tenant," the same rule as your global scopes from [Laravel multi-tenancy](/blog/laravel-multi-tenancy/). A row that exists for another customer is still `not_found` to this actor.

Never return `forbidden` with the other tenant's ID in the message. That is an enumeration leak dressed as honesty.

## Hints beat apologies

**Bad tool error:** Something went wrong.

**Better:** Invoice not found in your workspace. Hint: call `list_invoices` for this customer, then retry `get_invoice` with an ID from that list.

The first one is useless. The model will guess.

The hint should name **another tool the agent already has**, or a field to fix, not a URL to your internal runbook.

> You are writing for an LLM tool loop, not a human SRE.

For `retryable: true`, say what to reuse. Idempotency taught us that "retry" without "same key" creates double refunds. Encode that in the hint every time:

```text
A refund with this idempotency key is still in flight.
Hint: wait ~30s and call refund_order again with the SAME idempotency_key and arguments.
```

**Try this**

Rewrite your three most common error messages so each one ends with a hint that names a tool the agent already has.

## Audit the code the model saw

Your audit stream already records `allowed | denied | replayed`. Store the public error `code` on denials.

When an agent loops on `not_found` fifty times, you want a query, not a vibes-based log scroll.

![Comic pop-art panel: error envelope fields flowing into the MCP audit table as denied reasons](/blog/mcp-structured-tool-errors-laravel/error-envelope-e4a7a7d5.webp)

*The same error code the model saw lands in the audit table.*

```php
McpAuditEvent::query()->create([
    'tenant_id' => $actor->tenantId,
    'user_id' => $actor->userId,
    'session_id' => $actor->sessionId,
    'tool' => $tool,
    'decision' => 'denied',
    'reason' => $code, // not_found | forbidden | …
    'args_hash' => hash('sha256', json_encode($redactedArgs)),
    'args' => $redactedArgs,
    'result_summary' => ['isError' => true, 'code' => $code],
    'idempotency_key' => $args['idempotency_key'] ?? null,
    'duration_ms' => $ms,
]);
```

Ops dashboard: top denied codes per tool, per tenant, last 24h. Product signal: a spike in `validation_failed` usually means your tool schema description is lying to the model.

## What I refuse to ship

- `firstOrFail()` / uncaught exceptions escaping the MCP adapter
- JSON-RPC errors for "invoice missing" or "seat already reset"
- Stack traces, SQLSTATE, or filesystem paths in `content` text
- Different error shapes per tool (`err` vs `error` vs `message` only)
- `forbidden` messages that confirm another tenant's resource exists
- Swallowing idempotency `conflict` / `in_flight` into a generic `internal`
- Marking money-moving failures `retryable: true` without a key

If a human API would return 404/409/422 with a problem+json body, the agent path needs `isError` with a code and a hint, not a thrown Exception.

## Wiring it into the series

This sits on top of [MCP auth and audit logging](/blog/mcp-auth-audit-logging-laravel/) and [idempotency keys for mutations](/blog/mcp-idempotency-laravel-mutations/). Same `McpActor`, same audit table, same claim codes, now visible to the model instead of only to your pager.

Tenancy rules stay the [Laravel multi-tenancy](/blog/laravel-multi-tenancy/) ones; do not invent a parallel "agent ACL."

For wallet-level token budgets at the LLM edge, that is a different layer. See [rate limiting your AI wallet](/blog/rate-limiting-ai-wallet/). Tool errors are what happen **after** the model is already allowed to call you.

If your MCP tools still throw through to the client and you want the error surface reviewed before agents hold live keys, [start with an AI Integration Sprint](/contact/?package=ai). We will inventory which failures are recoverable, which must stop the loop, and how the audit trail proves it after a bad night.

**Key takeaways**

- **Use MCP tool-result errors (`isError: true`) for business failures.** Reserve JSON-RPC errors for protocol problems.
- **Catch once at the adapter.** Map domain exceptions to a small, stable code set.
- **Write for the model:** message + hint that names the next tool or field; set `retryable` honestly.
- **Omit or carefully handle `structuredContent` on errors** if `outputSchema` is in play.
- **Audit the same codes** the agent saw. Spikes are product bugs, not just noise.
- **Never leak cross-tenant existence** through clever error text.

When your agent hits a missing invoice today, does it get a hint it can act on, or a crash it can only apologize for?
