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:
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:
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.
#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 |