h3-mcp logoh3-mcp

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 (-32601404, -32603500, 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

CodeMeaning
-32700Parse error — body was not valid JSON
-32600Invalid request — e.g. a request method sent without an id
-32601Method not found (HTTP 404 on the modern era)
-32602Invalid params
-32603Internal error
-32020HeaderMismatch — headers disagree with the body
-32021MissingRequiredClientCapability
-32022UnsupportedProtocolVersion
-32000-32019Legacy 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 in Header validation (-32020).

#HTTP-Level Rejections

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

SituationStatus
Untrusted Origin403
Missing or invalid credentials401 + www-authenticate
Body over limits.maxBodySize413
Missing / malformed / unaccepted Content-Type400 / 422 / 415
Method the endpoint does not serve405 + allow
Unknown session id (legacy)404
Missing session id (legacy)400
maxSessions exceeded (legacy)503
Read more in Security.
h3-mcp logo

h3-mcp  MCP servers, built on H3.