MCP Error Handling: Tool Errors vs Protocol Errors

MCP has two ways to report a failure: a tool result with isError set, which the model reads, and a JSON-RPC error, which the model usually never sees. Which to use when, the error codes in the current specification, how the TypeScript and Python SDKs behave, and how to handle timeouts, cancellation and retries.

8 min read

Error handling in MCP comes down to one question: who needs to read the failure? If the model could fix it, by passing a different argument, calling another tool first or waiting, return a normal tool result with isError: true and a sentence saying what went wrong and what to do next. If the request itself is broken, such as an unknown method, missing protocol metadata or an unsupported protocol version, answer with a JSON-RPC error object and a numeric code, which is addressed to the client software rather than the model. Put timeouts on every request you send, cancel what you stop waiting for, and retry only what is safe to repeat.

This is about the mechanics. How to word the message itself is in writing MCP tool descriptions, and finding out why an assistant mishandled an error is in how to debug MCP tools.

Two channels, two audiences

The tools page of the MCP specification (opens in a new tab) names both mechanisms. Protocol errors are for problems with the request structure that models are less likely to be able to fix: an unknown tool, a request that fails the tools/call schema, a server error. Tool execution errors carry feedback a model can use to correct itself: API failures, input validation errors such as a date in the wrong format, and business logic errors. Clients may pass protocol errors to the model; they should pass tool execution errors to it.

A tool execution error: a result, read by the model
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "resultType": "complete",
    "isError": true,
    "content": [
      { "type": "text", "text": "No task T-9. Refs look like T-1. Call tasks_search to find a task by its title." }
    ]
  }
}
A protocol error: an error object, handled by the client
{
  "jsonrpc": "2.0",
  "id": 8,
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version",
    "data": { "supported": ["2026-07-28"], "requested": "1900-01-01" }
  }
}

The first is a successful JSON-RPC response that happens to describe a failure. The second has no result at all. Both carry the id of the request they answer; an error response may leave it out only when the request was too malformed for the id to be read.

The error codes in the current specification

The base protocol page (opens in a new tab) of the 2026-07-28 revision keeps the standard JSON-RPC codes and now divides the range JSON-RPC sets aside for implementations:

  • -32700 Parse error, -32600 Invalid Request, -32601 Method not found, -32602 Invalid params, -32603 Internal error: the standard JSON-RPC set. A request missing its required _meta fields gets -32602, as does a read of a resource that does not exist.
  • -32020 HeaderMismatch: on Streamable HTTP, the mirrored headers are missing or disagree with the body.
  • -32021 MissingRequiredClientCapability: the request needs a capability the client did not declare; data.requiredCapabilities lists it.
  • -32022 UnsupportedProtocolVersion: data.supported lists the versions the server will accept, so the client can retry with one.
  • -32000 to -32019 are legacy. New implementations should not use them, and receivers must not assume a meaning for any of them except -32002.
  • Retired: -32002 for a missing resource (2025-11-25 and earlier, now -32602) and -32042 for URL elicitation (2025-11-25 only). Implementations of 2026-07-28 must not send either, though clients should still accept -32002 from older servers.
  • Your own codes go outside the reserved block, -32768 to -32000.

Errors that never crossed the wire, such as a timeout raised inside an SDK, have no code in the specification. It asks implementations to make sure such a local error cannot be mistaken for one the other side sent.

What a model can recover from

The test the Python SDK’s guide to handling errors (opens in a new tab) offers is a good one: could a smarter model have avoided this? If yes, it is a tool error. By that test:

  • A record that does not exist, an argument in the wrong format, a value out of range, a business rule, a permission the caller lacks: tool errors. The model can change the call, pick another tool or explain the refusal to its person.
  • An upstream service that is down or slow: a tool error that says whether trying again later could help.
  • An unsupported protocol version, a missing client capability, a header mismatch, an unknown method: protocol errors. No change of arguments fixes them; the client has to.
  • An unknown tool: the specification lists it as a protocol error, and the TypeScript SDK sends -32602. The Python SDK, in our test of version 2.2.0, returned it as an isError result reading “Unknown tool: no_such_tool”. Handle both on the client side.

In the TypeScript SDK

In the TypeScript SDK’s version 2 (@modelcontextprotocol/server), a tool handler returns isError: true itself, or throws. Its errors guide (opens in a new tab) says every exception a tool handler throws, even a ProtocolError, becomes an isError result with the exception’s message as the text. ProtocolError, which replaces version 1’s McpError, is for resource, prompt and completion handlers, which have no isError channel.

TypeScript: return the error the model should read
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const tasks = new Map([['T-1', 'Draft the release notes']]);
const server = new McpServer({ name: 'tasks', version: '1.0.0' });

server.registerTool(
  'tasks_get',
  {
    description: 'Read one task by its reference, such as T-1.',
    inputSchema: z.object({ ref: z.string() }),
  },
  async ({ ref }) => {
    const task = tasks.get(ref);
    if (!task) {
      return {
        isError: true,
        content: [{ type: 'text', text: `No task ${ref}. Refs look like T-1. Call tasks_search to find a task by its title.` }],
      };
    }
    return { content: [{ type: 'text', text: task }] };
  },
);

The trap is the throw. A handler that throws connect ECONNREFUSED 10.0.0.5:5432 sends exactly that text to the model: an internal address, and nothing it can act on. Catch what you expect and return your own sentence; let only the truly unexpected escape.

In the Python SDK

Python version 2 (MCPServer) splits the three cases by exception type. ToolError becomes an isError result carrying your message, prefixed with “Error executing tool tasks_get:”, and one INFO line in the log. MCPError escapes the tool wrapper and fails the whole tools/call with a JSON-RPC error. Any other exception is a crash: the model gets only “Error executing tool tasks_get”, and the traceback goes to the server log at ERROR. Arguments that fail the input schema never reach your function; they come back as an isError result as well.

Python: raise ToolError for what the model can fix
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

mcp = MCPServer("tasks")
TASKS = {"T-1": "Draft the release notes"}


@mcp.tool()
def tasks_get(ref: str) -> str:
    """Read one task by its reference, such as T-1."""
    if ref not in TASKS:
        raise ToolError(f"No task {ref}. Refs look like T-1. Call tasks_search to find a task by its title.")
    return TASKS[ref]

Never return an error message as an ordinary string. It arrives with isError: false, so the model and every client screen treat it as the answer.

Timeouts and cancellation

The specification’s cancellation rules (opens in a new tab) say implementations should put a timeout on every request they send, and cancel it when the time runs out. On Streamable HTTP, closing the response stream is the cancellation, and the server must treat a disconnect that way. On stdio the client sends a notification:

Cancelling request 7 on stdio
{
  "jsonrpc": "2.0",
  "method": "notifications/cancelled",
  "params": { "requestId": 7, "reason": "Timed out after 60 seconds" }
}
  • A server that receives it should stop the work, free what it holds and send no response. A cancellation for an unknown or finished request is simply ignored, because the two can cross in flight.
  • A client may reset its timer when a progress notification arrives, but should still enforce a maximum. The TypeScript client exposes this as resetTimeoutOnProgress and maxTotalTimeout.
  • Inside a TypeScript handler, ctx.mcpReq.signal aborts on cancellation or disconnect. Pass it to fetch and your own I/O stops with the request.
  • Give your own upstream calls a shorter deadline than the client’s, and report a miss as a tool error. “The export did not answer within 10 seconds. Nothing was imported. Try again in a minute.” tells the model three things a client timeout cannot.

Retries

  • Tool errors are retried by the model, which reads the message and decides. Say in the message whether a retry can help and when.
  • Transport failures are retried by the client. Since 2026-07-28 a broken Streamable HTTP stream loses the request in flight, and the client must send it again as a new request with a new id. A stdio server that dies is restarted, and its lost requests retried.
  • -32022 is retried once, with a version from data.supported. Other protocol errors are not retried: the same request will fail the same way.
  • Retrying a write can do it twice. A tool annotated idempotentHint: true says a repeat with the same arguments has no further effect; the default is false, so treat unannotated writes as unsafe to repeat blindly, and give a create tool a key it can deduplicate on.
  • For a rate limit, say how long to wait. A bare refusal invites an immediate retry.

What to put in the message

Whichever channel it travels on, a failure should carry: what failed, the value that was wrong, what would be accepted, the next step or tool, whether retrying helps, and whether anything was changed before it failed. Leave out stack traces, internal hostnames, SQL and tokens. The model cannot use them, and a tool result is copied into a transcript that people will read later.

How fenbs reports errors

fenbs, a task board where AI assistants are members with roles, runs its MCP server at https://fenbs.ai/api/mcp and sends almost every failure back as a tool result. A permission refusal comes back as a tool result with isError: true whose text is JSON naming the missing permission and the role the caller holds, so the assistant can tell its person why instead of hunting for a way round. A task that does not exist and a value that fails validation come back the same way, as does the rate limit, whose message says it allows 120 tool calls a minute and to wait and try again. A missing, revoked or expired credential is the one refusal that is not a tool result: it is HTTP 401 with a JSON-RPC error body and a WWW-Authenticate header, because that status is what makes a client sign in again or use its refresh token. The tools and their setup are in the MCP docs.

Related

Wording the message: writing MCP tool descriptions. Why an assistant ignored an error: how to debug MCP tools. What the messages look like on the wire: why MCP uses JSON-RPC. Recording failures on the server side: logging for MCP servers.

Questions people ask.

What is isError in MCP?

A field on a tools/call result. When it is true, the call reached the tool and the tool failed, and the content explains why. The JSON-RPC response itself is a success, so the client passes the text to the model, which can correct its arguments or try something else.

Should an MCP tool throw an exception or return isError?

Return isError, or raise the SDK exception meant for it, for anything the model could fix. In the TypeScript SDK any exception thrown in a tool handler becomes an isError result carrying the exception message, so catch expected failures and write your own sentence. In the Python SDK, ToolError passes your message to the model and other exceptions reach it only as a generic line.

Which JSON-RPC error codes does MCP use?

The standard -32700, -32600, -32601, -32602 and -32603, plus three defined in the 2026-07-28 revision: -32020 for a header mismatch, -32021 for a missing client capability and -32022 for an unsupported protocol version. Codes -32000 to -32019 are legacy, and application codes belong outside -32768 to -32000.

How do you cancel an MCP request?

On stdio, send a notifications/cancelled notification with the request id. On Streamable HTTP, close the response stream for that request. The server should stop the work and send no response, and a cancellation that arrives too late is ignored.

Start with one thing.

There is nothing to set up first. Write one line and you’ve started.