Tool errors
A tool error is a failed call the model can read and recover from, and how you fail decides whether it can.
Last updated: 29 Sep, 2026 · MCP 2.2
The lookup tool from the last lesson assumes the order exists. A model will ask for orders that do not. What it reads back depends on how your tool fails.
ToolError and MCPError
from mcp.server.mcpserver.exceptions import ToolError
from mcp import MCPError
# raise ToolError(msg) -> an error RESULT the model reads and can retry
# raise MCPError(...) -> a protocol error that fails the whole requestView the code here
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("Shop support")
ORDERS = {
"A17": {"item": "blue mug", "status": "shipped", "total": 12.50},
"B42": {"item": "desk lamp", "status": "processing", "total": 48.00},
}
ARTICLES = {
"Where is my order?": "orders",
"Changing an order": "orders",
"How refunds work": "refunds",
"Refunds for damaged items": "refunds",
"Resetting your password": "account",
}
class Order(BaseModel):
id: str
item: str
status: Literal["processing", "shipped", "delivered"]
total: float
@mcp.tool()
def lookup_order(order_id: str) -> Order:
"""Look up an order by its id."""
if order_id not in ORDERS:
raise ToolError(f"No order with id {order_id!r}. Order ids look like A17.")
return Order(id=order_id, **ORDERS[order_id])
@mcp.tool()
def search_help(
query: Annotated[str, Field(description="Words to look for in the help articles.")],
topic: Literal["orders", "refunds", "account"] | None = None,
limit: Annotated[int, Field(ge=1, le=5)] = 3,
) -> str:
"""Search the help centre and return matching article titles."""
found = [title for title, t in ARTICLES.items() if query.lower() in title.lower() and topic in (None, t)]
return "; ".join(found[:limit]) or "No articles found."
An exception you did not plan for
With no error handling, reading a missing key raises a KeyError inside the tool.
import asyncio
from mcp import Client
from shop import mcp
async def main():
async with Client(mcp) as client:
result = await client.call_tool("lookup_order", {"order_id": "Z9"})
print(result.is_error)
print(result.content[0].text)
asyncio.run(main())True Error executing tool lookup_order
The SDK treats an exception you did not plan for as a crash: the model learns only that the call failed, not why, because the text of an unexpected error could reveal the server's internals. The full traceback goes to the server's log instead.
Raising an error the model can act on
Add from mcp.server.mcpserver.exceptions import ToolError to the top of shop.py. Without it the tool fails with a NameError that the client only sees as Error executing tool lookup_order. Then check for the missing order and raise ToolError with a message written for the model: what went wrong and what a valid value looks like.
@mcp.tool()
def lookup_order(order_id: str) -> Order:
"""Look up an order by its id."""
if order_id not in ORDERS:
raise ToolError(f"No order with id {order_id!r}. Order ids look like A17.")
return Order(id=order_id, **ORDERS[order_id])A bad id and a good id, side by side
Z9 fails with your message; A17 still works.
import asyncio
from mcp import Client
from shop import mcp
async def main():
async with Client(mcp) as client:
for order_id in ("Z9", "A17"):
result = await client.call_tool("lookup_order", {"order_id": order_id})
if result.is_error:
print("error:", result.content[0].text)
else:
print("found:", result.structured_content["item"])
asyncio.run(main())error: Error executing tool lookup_order: No order with id 'Z9'. Order ids look like A17. found: blue mug
Why the model can recover
- The call succeeded and returned an error result, which a model reads like any other tool answer.
- The message is yours, so the model can ask the customer for the right id or try again.
- Checking
is_errorfirst before readingstructured_contentis the habit every client needs.
"Order not found" sends is_error=False. To the model and every client, the tool worked and that sentence was the answer. Only raising sets the flag, so raise, never return, an error.Errors that stop the request
MCPError, from from mcp import MCPError, is different: it fails the whole request with a protocol error, and the model sees nothing. One question decides which to use: could a smarter model have avoided this? A wrong order id, yes, so ToolError. A server not configured to take refunds at all, no, so MCPError.
import asyncio
from mcp import Client
from shop import mcp
async def main():
async with Client(mcp) as client:
result = await client.call_tool("cancel_order", {"order_id": "A17"})
print(result.is_error)
print(result.content[0].text)
asyncio.run(main())True Unknown tool: cancel_order
A tool that does not exist is also an error result, not an exception, so a model that guesses a tool name finds out and can pick another.
ToolError vs MCPError vs a bare exception
| The model reads | Sets is_error | Use it when | |
|---|---|---|---|
ToolError | Your message | Yes | The model could fix its input |
| A bare exception | Only that it failed | Yes | An unexpected crash, hidden by the SDK |
MCPError | Nothing | No, it fails the request | The whole request cannot proceed |
When to raise which
- Raise
ToolErrorfor a bad argument, a missing record, or a rule the caller broke. - Let an unexpected bug raise its own exception; the SDK hides the details and logs the trace.
- Raise
MCPErroronly when the request itself cannot go on.
Related
- Previous: Structured output
- Next: Tool annotations
- Reference: MCP tools specification
- Raise
ToolErrorfromsearch_helpwhen nothing is found, instead of returning"No articles found.". - Replace
ToolErrorwithValueErrorand compare the text the model gets. - Write the message for a model that sent
"a17"in lower case.
Slow is fine. Stopping is the only problem.