Skip to main content

How to Handle MCP Tool Errors So Models Can Recover

Short answer

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.

Tested withMCP spec 2026-07-28Python SDK (mcp) 2.3.0TypeScript SDK (@modelcontextprotocol/server) 2.3.1MCP Inspector 2.11.0Node.js 24.19 (the Inspector needs 22.19 or later)Checked 20 Oct 2026

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 errorProtocol error
Where it appearsIn a normal result, with isError: trueAs a JSON-RPC error object, with no result
Typical causesThe order doesn't exist, a date is in the past, an upstream API returned an errorAn unknown tool name, a malformed request
Can the model fix it?Yes, by retrying with different argumentsUsually not
Does the model see it?Clients should pass it to the modelClients 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.

server.py
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.

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.

note

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.

CasePython SDK 2.3.0TypeScript SDK 2.3.1
Error you raise on purposeToolError 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 KeyErrorisError: true, but the model only sees Error executing tool <name>. The traceback goes to the server logisError: true, and the model sees whatever the error says
Missing argumentisError: true with a Pydantic validation messageisError: true with Input validation error: ...
Unknown tool nameisError: 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.