
# Errors

> Failed tool calls, protocol errors, and what reaches the client.

There are two distinct kinds of failure in MCP, and picking the right one matters:

- **A failed operation** — the query returned nothing, the file was missing, the input made no sense. This is a _successful_ JSON-RPC response carrying `isError: true`. The model sees it and can react.
- **A protocol error** — the method does not exist, the parameters are malformed, the server broke. This is a JSON-RPC error object. The client sees it; the model usually does not.

## Failed Operations

Return `isError: true` from a tool handler with an explanation the model can act on:

```ts
defineMcpTool({
  name: "read-file",
  inputSchema: z.object({ path: z.string() }),
  handler: async ({ path }) => {
    const file = await tryRead(path);
    if (!file) {
      return {
        isError: true,
        content: [{ type: "text", text: `No file at ${path}. Try \`list-files\` first.` }],
      };
    }
    return { content: [{ type: "text", text: file }] };
  },
});
```

This is almost always what you want from a tool. Write the message for the model: say what went wrong _and_ what to do next.

## Protocol Errors

Everything the library validates for you already produces the right error — unknown tool, missing required argument, bad cursor, malformed URI. You only need to raise one yourself when your handler decides the _request_ was invalid rather than the operation:

```ts
import { McpJsonRpcError } from "h3-mcp";

throw new McpJsonRpcError(-32602, "Widget id must be a UUID", { status: 400 });
```

`McpJsonRpcError(code, message, { status?, data? })` is the escape hatch that lets you choose the exact code and HTTP status. When `status` is omitted it defaults sensibly per code (`-32601` → `404`, `-32603` → `500`, otherwise `400`).

### Messages Are Not Forwarded

On the **modern** era, an error thrown from your handler is reported as `-32603 "Internal error"` and **its message is dropped**. This is deliberate: handler errors routinely carry filesystem paths, SQL fragments, or upstream API detail, and the client is not a trusted audience.

Two things keep their message on the wire:

- `McpJsonRpcError` — you chose the code and the wording on purpose.
- the library's own validation errors.

So to say something specific to the client, either return `isError: true` (model-visible) or throw `McpJsonRpcError` (client-visible). An `HTTPError` from `h3` will be collapsed.

On the **legacy** era, h3's JSON-RPC layer maps a thrown `HTTPError`'s status to a JSON-RPC code with HTTP status `200`, so `404` becomes `-32004`.

## Codes

| Code                | Meaning                                                      |
| ------------------- | ------------------------------------------------------------ |
| `-32700`            | Parse error — body was not valid JSON                        |
| `-32600`            | Invalid request — e.g. a request method sent without an `id` |
| `-32601`            | Method not found (HTTP `404` on the modern era)              |
| `-32602`            | Invalid params                                               |
| `-32603`            | Internal error                                               |
| `-32020`            | `HeaderMismatch` — headers disagree with the body            |
| `-32021`            | `MissingRequiredClientCapability`                            |
| `-32022`            | `UnsupportedProtocolVersion`                                 |
| `-32000` … `-32019` | Legacy sub-range; never emitted on the modern era            |

On the modern era, statuses map as: `400`/`404`/`409`/`422` → `-32602` with HTTP `400`; everything else → `-32603` with HTTP `500`.

:read-more{to="/guide/modern/headers" title="Header validation (-32020)"}

## HTTP-Level Rejections

Some requests are refused before any JSON-RPC is parsed, and answer with a plain HTTP status:

| Situation                                       | Status                     |
| ----------------------------------------------- | -------------------------- |
| Untrusted `Origin`                              | `403`                      |
| Missing or invalid credentials                  | `401` + `www-authenticate` |
| Body over `limits.maxBodySize`                  | `413`                      |
| Missing / malformed / unaccepted `Content-Type` | `400` / `422` / `415`      |
| Method the endpoint does not serve              | `405` + `allow`            |
| Unknown session id (legacy)                     | `404`                      |
| Missing session id (legacy)                     | `400`                      |
| `maxSessions` exceeded (legacy)                 | `503`                      |

:read-more{to="/security" title="Security"}
