# MCP tool errors, and tools that models use well

October 15, 2026 · Part 9 of 35

![MCP tool errors, and tools that models use well](https://pournasserian.com/_astro/lead.CaEw6yYX_ZKVYB5.webp)

The tool bug I see most often is an error the model can't read: a tool fails on bad input, and the model learns nothing it can act on. What surprised me in Python is what the official software development kit (SDK) does with an exception you didn't plan for: the model gets an error result, but not your exception's text, so it still can't tell what to fix.

This is part 9 of my series on what a Model Context Protocol (MCP) server does under the 2026-07-28 specification, after [the tool definition and its result in part 8](https://pournasserian.com/writing/mcp-2026-tool-definitions). The facts are as I read them in October 2026.

## In brief

1. **A tool reports a failure in one of two ways.** A protocol error is for a request that is itself invalid, such as a call to an unknown tool; a result with `isError: true` is for everything else, such as bad input or a broken business rule.
2. **Clients SHOULD pass the second kind to the model, and only MAY pass the first.**
3. **In the Python SDK, raise `ToolError` with a message that says what a valid value looks like.** An unexpected exception also becomes an `isError` result, but the model sees only the tool's name.
4. **Servers MUST validate inputs, control access, rate-limit calls and sanitize outputs.**
5. **My advice: build few, sharp, well-described tools,** with small outputs, idempotent writes, and reads kept apart from writes.

## Two kinds of errors

The [tools page of the specification](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) gives a tool two ways to report a failure, chosen by whether the request itself is invalid:

```mermaid
flowchart TB
    start(["Something went wrong"])
    q1{"Is the request itself invalid?<br/>unknown tool, malformed request,<br/>unexpected server failure"}
    proto["Protocol error<br/>JSON-RPC error object<br/>e.g. -32602 Unknown tool"]
    exec["Tool execution error<br/>result with isError true<br/>+ actionable message<br/>bad input, business rule, API failure"]
    start --> q1
    q1 -- yes --> proto
    q1 -- no --> exec
```

A protocol error is a JSON-RPC 2.0 error object in place of a result. A malformed request is one that doesn't fit the shape of a `tools/call` at all; arguments that fail the tool's input schema, a broken business rule or a failed call to an upstream application programming interface (API) are tool execution errors, as my [host rulebook](https://pournasserian.com/writing/what-ai-hosts-require-of-an-mcp-server) also records. It comes back as an ordinary result:

```json
{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "resultType": "complete",
    "content": [{ "type": "text", "text": "Invalid departure date: must be in the future. Current date is 2026-10-08." }],
    "isError": true
  }
}
```

This matters because clients SHOULD pass tool execution errors to the model so it can correct itself, and MAY pass protocol errors, which are less likely to lead to a successful recovery. An expired or unknown handle is a tool execution error too; [part 5](https://pournasserian.com/writing/mcp-2026-stateless-requests) shows one.

The spec already makes validating every tool input a server MUST (see the security duties below). My advice is about what the model sees when a check fails: return `isError: true` with a message that says what was wrong and what a valid value looks like.

### In Python: `ToolError` and `MCPError`

With the official Python SDK, `mcp` 2.3.0, each mechanism has its exception. The [SDK's page on handling errors](https://py.sdk.modelcontextprotocol.io/servers/handling-errors/) draws the same line: raise `ToolError` when what the tool tried didn't work, an upstream API timeout included, and `MCPError` when the request itself should be rejected. A bad date is the model's to fix; a server without its key can't serve anyone:

```python
import os
from datetime import date

from mcp import MCPError
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import INTERNAL_ERROR

mcp = MCPServer("flights")

@mcp.tool()
def search_flights(departure_date: str) -> str:
    """Search flights for one day. departure_date is a future date as YYYY-MM-DD."""
    if "FARES_API_KEY" not in os.environ:
        # No model can fix this: the server itself isn't set up.
        raise MCPError(
            code=INTERNAL_ERROR,
            message="Flight search is not configured on this server.",
        )
    try:
        day = date.fromisoformat(departure_date)
    except ValueError:
        day = None
    if day is None or day <= date.today():
        raise ToolError(
            f"Invalid departure date {departure_date!r}: "
            f"use a future date as YYYY-MM-DD. Today is {date.today()}."
        )
    return f"No flights found on {day}."
```

With `FARES_API_KEY` set, the SDK's in-memory `Client` (which speaks 2026-07-28) got "next Friday" back as a result with `is_error` set. Its text is my message, after a prefix the SDK adds:

```text
Error executing tool search_flights: Invalid departure date 'next Friday': use a future date as YYYY-MM-DD. Today is 2026-10-09.
```

Without `FARES_API_KEY`, the client got no result: it raised the protocol error as an `MCPError` with code `-32603`. Here is what `MCPServer` returned for each failure I tried:

| In the tool or the call | What the client gets |
|---|---|
| `raise ToolError(...)` | An `isError` result: `Error executing tool <name>: ` and your message |
| An unexpected exception, such as a `ValueError` | An `isError` result with only `Error executing tool <name>`; the traceback goes to the server's log |
| `raise MCPError(...)` | A JSON-RPC error with your code and message |
| Arguments that fail the input schema | An `isError` result with Pydantic's validation message; your function never runs |
| A tool name the server doesn't have | An `isError` result: `Unknown tool: <name>` |

The second row is the trap: the model learns that the call failed, not why. The Claude connector directory's review criteria, as I recorded them in [what AI hosts require of an MCP server](https://pournasserian.com/writing/what-ai-hosts-require-of-an-mcp-server), rule out generic errors. The last row differs from the spec, which answers an unknown tool with a protocol error, for example `-32602`. The SDK's [low-level `Server`](https://py.sdk.modelcontextprotocol.io/advanced/low-level-server/) differs again: any exception other than `MCPError` in a handler reaches the client as a `-32603` protocol error, `Internal server error`.

## Security duties on both sides

The tools page closes with duties for both sides of a call. Servers MUST:

- validate all tool inputs;
- implement proper access controls;
- rate-limit tool invocations;
- sanitize tool outputs.

Clients SHOULD, among other things:

- prompt for confirmation on sensitive operations;
- show tool inputs to the user before calling the server, to prevent data exfiltration;
- validate results before passing them to the model;
- enforce timeouts;
- log tool usage for audit.

## Designing tools that models use well

The specification stops at the wire; these lessons are mine:

- **Fewer, sharper tools.** Ten well-described tools beat fifty thin wrappers around a web API's endpoints. Every tool definition costs context, even with tool search.
- **Descriptions are prompts.** Say when to use the tool, when not to, and what comes back, with the most important sentence first, because some hosts cut long descriptions ([part 4](https://pournasserian.com/writing/mcp-2026-server-instructions) gives Claude Code's limit). My post on [what AI hosts require of an MCP server](https://pournasserian.com/writing/what-ai-hosts-require-of-an-mcp-server) has more on how descriptions are judged.
- **Control output size.** As of October 2026, [Claude Code](https://code.claude.com/docs/en/mcp) warns when a tool's output exceeds 10,000 tokens and limits it to 25,000 tokens by default. It also saves a successful text result over 50,000 characters to a file, whatever its token count, unless the tool raises that threshold with `anthropic/maxResultSizeChars`. Return summaries plus `resource_link`s rather than whole documents.
- **Make writes idempotent.** Accept a client-supplied idempotency key on tools that create things: streams drop and requests get re-issued, as [part 7](https://pournasserian.com/writing/mcp-2026-transports) explains.
- **Separate reads from writes.** Read-only tools are easier for hosts to auto-approve, and a tool that mixes a read and a write forces a confirmation prompt every time. As of October 2026, the Claude connector directory asks for reads and writes in separate tools; [part 8](https://pournasserian.com/writing/mcp-2026-tool-definitions) covers the annotations that mark which is which.
- **Use vendor `_meta` keys carefully.** Claude Code honours keys such as `anthropic/maxResultSizeChars` (raises the 50,000-character threshold for one tool), `anthropic/alwaysLoad` (loads the tool up front, not through tool search) and `anthropic/requiresUserInteraction` (asks for approval on every call). Other hosts ignore them; a later part covers vendor keys.

## When a call can't finish in one go

A `tools/call` doesn't always return a final result straight away. Each of these gets a part of its own later in this series:

| The tool needs… | What the server uses |
|---|---|
| A confirmation or a missing detail before it can run | `resultType: "input_required"`, a Multi Round-Trip Request (MRTR) |
| Minutes or hours of work | `resultType: "task"`, from the Tasks extension, only if the request opted in |
| To report progress while it runs | `notifications/progress` on the response stream |
| To render an interactive user interface (UI) | `_meta.ui.resourceUri` on the tool definition, from MCP Apps |

## Method and caveats

- Built from the second half of my guide's tools chapter, written against the 2026-07-28 specification.
- Claude Code's limits and `_meta` keys are as its MCP documentation gave them on 9 October 2026.
- The Python sample ran against `mcp` 2.3.0 on 9 October 2026. The table and the low-level `Server` result come from the same run, which added a tool without its own check, an unknown tool name and a low-level `Server`.
- Where the Python SDK differs from my notes (what an exception in a tool becomes), this article describes `mcp` 2.3.0.

I'm [Amir Pournasserian](https://pournasserian.com/about). I build AI and platform systems for a living, maintain [FluentCMS](https://pournasserian.com/open-source/fluentcms) and [YeSvelte](https://pournasserian.com/open-source/yesvelte), and write here about what I find along the way.
