How to Handle MCP Tool Errors So Models Can Recover
When a tool fails, return a normal tool result with isError set to true. Put in the text what went wrong and what to try next, such as the format a valid id takes or the tool to call next. Use a JSON-RPC protocol error only when the request itself is wrong, such as a tool name that doesn't exist. In Python, raise ToolError(...); in TypeScript, throw new Error(...). Both SDKs send that message back with isError: true.
What you'll have at the end
An orders server with three tools. When the model asks for an order that doesn't exist, the result looks like this. This is real output from the Python server, run through the MCP Inspector:
{
...
"content": [
{
"type": "text",
"text": "Error executing tool get_order: No order with id ORD-9999. Call list_orders to see the ids that exist."
}
],
"isError": true
}
The model reads the message and knows the next step: call list_orders. The TypeScript server sends the same message without the Error executing tool get_order: prefix.
Before you start
- Python: Python 3.10 or later and uv.
- TypeScript: Node.js 20 or later and npm.
- For testing, either language: Node.js 22.19 or later, which MCP Inspector needs.
- A server you can change. If you haven't built one yet, start with Build an MCP server.
Two kinds of errors
The spec gives a tool two ways to report a failure. It describes them as two different jobs: a protocol error reports "issues with the request structure itself that models are less likely to be able to fix", and a tool execution error "contains actionable feedback that language models can use to self-correct and retry with adjusted parameters".
| Tool execution error | Protocol error | |
|---|---|---|
| Where it appears | In a normal result, with isError: true | As a JSON-RPC error object, with no result |
| Typical causes | The order doesn't exist, a date is in the past, an upstream API returned an error | An unknown tool name, a malformed request |
| Can the model fix it? | Yes, by retrying with different arguments | Usually not |
| Does the model see it? | Clients should pass it to the model | Clients may pass it on, but the model is less likely to recover |
Argument problems count as tool execution errors too. The spec lists "input validation errors (e.g., date in wrong format, value out of range)" under tool execution errors, and SEP-1303, which is marked Final, moved them there so the model can read the message and correct its call. The test in this guide shows both SDKs doing that for a missing argument.
So the question to ask is simple: can the model fix this by changing its call? If yes, return a tool execution error. If the request is wrong at the protocol level, such as a tool name the server doesn't have, use a protocol error.
Write messages the model can act on
A message that says only "Not found." leaves the model guessing. A good message does three things: it names the problem, shows what a valid value looks like, and names the next step.
The most common mistake is returning the error as text. The handler below looks fine, but the SDK sees a normal return value, so isError is false and the model treats "Not found." as the answer:
@mcp.tool()
def get_order(order_id: str) -> str:
order = ORDERS.get(order_id)
if order is None:
return "Not found."
return f"{order_id}: {order['item']} ({order['status']})"
Raise an error instead. In Python that's raise ToolError(...), and in TypeScript it's throw new Error(...). Both SDKs turn it into a result with isError: true.
Build the example server
The example has three tools: list_orders, get_order and cancel_order. Orders live in memory, so the server needs no network access or accounts.
- Python
- TypeScript
import logging
import re
from typing import Annotated
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import Field
# Logs go to stderr. Anything written to stdout corrupts the stdio transport.
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("orders")
ORDER_ID = re.compile(r"^ORD-\d{4}$")
# In-memory data keeps the example self-contained.
ORDERS = {
"ORD-1001": {"item": "Desk lamp", "status": "shipped"},
"ORD-1002": {"item": "Notebook", "status": "processing"},
"ORD-1003": {"item": "Mug", "status": "cancelled"},
}
mcp = MCPServer("orders")
def find_order(order_id: str) -> dict:
# Tell the model what a valid id looks like, so it can fix the call and retry.
if not ORDER_ID.match(order_id):
raise ToolError(
f"'{order_id}' is not a valid order id. Order ids look like ORD-1001: "
"the letters ORD, a dash, then four digits."
)
order = ORDERS.get(order_id)
if order is None:
raise ToolError(
f"No order with id {order_id}. Call list_orders to see the ids that exist."
)
return order
@mcp.tool()
def list_orders() -> str:
"""List every order id with its status."""
return "\n".join(f"{oid}: {order['status']}" for oid, order in ORDERS.items())
@mcp.tool()
def get_order(
order_id: Annotated[str, Field(description="Order id such as ORD-1001")],
) -> str:
"""Get the item and status of one order."""
order = find_order(order_id)
return f"{order_id}: {order['item']} ({order['status']})"
@mcp.tool()
def cancel_order(
order_id: Annotated[str, Field(description="Order id such as ORD-1002")],
) -> str:
"""Cancel an order that has not shipped yet."""
order = find_order(order_id)
if order["status"] == "shipped":
raise ToolError(
f"{order_id} has already shipped, so it can't be cancelled. "
"Tell the customer to ask for a return instead."
)
if order["status"] == "cancelled":
return f"{order_id} was already cancelled."
order["status"] = "cancelled"
logger.info("Cancelled %s", order_id)
return f"Cancelled {order_id}."
if __name__ == "__main__":
mcp.run(transport="stdio")
find_order is where the errors come from. Its messages say what a valid id looks like and which tool to call next. ToolError makes the SDK return them as a result with isError: true, and adds the Error executing tool get_order: prefix you saw at the start of this guide. cancel_order uses the same helper, then refuses to cancel an order that has already shipped.
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
type Status = "processing" | "shipped" | "cancelled";
interface Order {
item: string;
status: Status;
}
const ORDER_ID = /^ORD-\d{4}$/;
// In-memory data keeps the example self-contained.
const ORDERS = new Map<string, Order>([
["ORD-1001", { item: "Desk lamp", status: "shipped" }],
["ORD-1002", { item: "Notebook", status: "processing" }],
["ORD-1003", { item: "Mug", status: "cancelled" }],
]);
// Throwing from a tool handler makes the SDK send the message back with isError: true.
// Write messages the model can act on, because it reads them and retries.
function findOrder(orderId: string): Order {
if (!ORDER_ID.test(orderId)) {
throw new Error(
`'${orderId}' is not a valid order id. Order ids look like ORD-1001: ` +
"the letters ORD, a dash, then four digits.",
);
}
const order = ORDERS.get(orderId);
if (!order) {
throw new Error(
`No order with id ${orderId}. Call list_orders to see the ids that exist.`,
);
}
return order;
}
function text(value: string) {
return { content: [{ type: "text" as const, text: value }] };
}
// serveStdio calls this whenever it needs a server instance,
// so it must return a new McpServer every time.
function createServer() {
const server = new McpServer({ name: "orders", version: "1.0.0" });
server.registerTool(
"list_orders",
{
description: "List every order id with its status.",
},
async () =>
text(
[...ORDERS.entries()]
.map(([id, order]) => `${id}: ${order.status}`)
.join("\n"),
),
);
server.registerTool(
"get_order",
{
description: "Get the item and status of one order.",
inputSchema: z.object({
order_id: z.string().describe("Order id such as ORD-1001"),
}),
},
async ({ order_id }) => {
const order = findOrder(order_id);
return text(`${order_id}: ${order.item} (${order.status})`);
},
);
server.registerTool(
"cancel_order",
{
description: "Cancel an order that has not shipped yet.",
inputSchema: z.object({
order_id: z.string().describe("Order id such as ORD-1002"),
}),
},
async ({ order_id }) => {
const order = findOrder(order_id);
if (order.status === "shipped") {
throw new Error(
`${order_id} has already shipped, so it can't be cancelled. ` +
"Tell the customer to ask for a return instead.",
);
}
if (order.status === "cancelled") {
return text(`${order_id} was already cancelled.`);
}
order.status = "cancelled";
console.error(`Cancelled ${order_id}`);
return text(`Cancelled ${order_id}.`);
},
);
return server;
}
// Logs go to stderr. Anything written to stdout corrupts the stdio transport.
serveStdio(createServer, {
onerror: (error) => console.error("Orders server error:", error),
});
console.error("Orders MCP server running on stdio");
findOrder throws an Error. The SDK catches whatever a tool handler throws and returns it as a result with isError: true, so throw is the TypeScript equivalent of raise ToolError. The SDK adds no prefix, so the message reaches the model exactly as you wrote it.
Both servers check the id format in the handler rather than in the input schema. A schema failure gets the SDK's own wording, which tells the model less than a message you write yourself.
Test the errors
The test script starts each server, lists its tools in both protocol eras, then calls each tool with a good input and with four inputs that should fail. Run it from the example folder:
bash test.sh
You'll see one line per check, ending with All checks passed. To run a single call yourself, use the Python server from its folder:
npx @modelcontextprotocol/inspector --cli uv run server.py \
--protocol-era modern --method tools/call \
--tool-name cancel_order --tool-arg order_id=ORD-1001
The result is an error because ORD-1001 has shipped. The Inspector prints the result, then exits with code 5 whenever a tool returns isError: true. That exit code is expected, and test.sh checks the output instead of the exit code.
The test uses the Inspector's --protocol-era flag twice. modern checks the 2026-07-28 revision, and legacy checks the older 2025-era handshake, so test.sh runs every check in both.
Where the SDKs differ
The spec describes the behavior, but the two SDKs don't handle every case the same way. These results come from running the example servers and sending the failing requests over stdio.
| Case | Python SDK 2.3.0 | TypeScript SDK 2.3.1 |
|---|---|---|
| Error you raise on purpose | ToolError becomes isError: true, with your message prefixed by Error executing tool <name>: | throw new Error becomes isError: true, with your message unchanged |
Unexpected crash, such as a KeyError | isError: true, but the model only sees Error executing tool <name>. The traceback goes to the server log | isError: true, and the model sees whatever the error says |
| Missing argument | isError: true with a Pydantic validation message | isError: true with Input validation error: ... |
| Unknown tool name | isError: true with Unknown tool: <name> | JSON-RPC error -32602 with Tool <name> not found |
Two rows need attention.
Unknown tool names. The spec and the TypeScript SDK send a JSON-RPC error, -32602 ("Invalid params" in the JSON-RPC spec). The Python SDK 2.3.0 sends a tool result with isError: true instead. Your client should handle both forms, and you shouldn't write server code that depends on either.
Crashes. An unexpected exception reaches the model verbatim in TypeScript. When we threw db password=hunter2 table=orders from a test handler, the model received that exact text. The Python SDK hides it, which is safer by default. Either way, don't put secrets, connection strings or stack traces into messages a tool might throw. Raise a ToolError with a short message the model can use, and log the details on the server.
Common problems
Error executing tool get_order with no detail (Python). The handler raised an exception that isn't a ToolError, so the SDK hid the details from the model and logged the traceback on the server. Raise ToolError with a message the model can use, and read the server log for the rest.
The model keeps retrying the same call. The message doesn't say what to change. Name the valid format, or the tool to call next, the way find_order does.
A failed call looks like a success. The handler returned the error text instead of raising it, so isError is false. See the example above that returns "Not found.".
The Inspector exits with code 5 after tools/call. The tool returned isError: true. That's the expected result for a failed call, not a crash.
FAQ
Should a tool throw an exception or return an error result?
Throw, and let the SDK build the result. In Python, raise ToolError with the message you want the model to read. In TypeScript, throw an Error. Both give a result with isError set to true and your message in content. In Python, other exceptions also set isError, but the model sees only the tool name.
Will the model see a protocol error?
Not reliably. The spec says clients may pass protocol errors to the model, but the model is less likely to recover from them. Save protocol errors for requests that are malformed, and return tool execution errors for failures the model can fix.
Should I put the stack trace or the database error in the message?
No. In TypeScript the message reaches the model exactly as written, so a stack trace or a connection string can leak internals or secrets. Log the details on the server and send a short message that says what to do next.
Do I still need checks in my handler if the input schema validates arguments?
Yes, for rules a schema can't express, such as an id format or a date in the past. Schema failures are reported as errors too, but the message comes from the SDK. Your own checks can say what a valid value looks like.
- Build an MCP serverWrite a working server in Python or TypeScript and connect it to an AI app.
- Write tools the model picks correctlyNames and descriptions that lead to the right tool call.
- Tool input schemasParameters, enums, defaults and validation, and how the SDKs generate them.
- Tool outputsText, structured content and large results.
- Test with MCP InspectorDebug tools with the Inspector web interface and CLI.