Model Context ProtocolMCP Python SDK 2.2 · LangChain 1.4 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
27 small wins to finish your pathNext lesson →

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

python
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 request
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
shop.py
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.

Example
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())

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.

python
@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.

Example
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())

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_error first before reading structured_content is the habit every client needs.
Watch out. A tool that returns the string "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.

Example
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())

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 readsSets is_errorUse it when
ToolErrorYour messageYesThe model could fix its input
A bare exceptionOnly that it failedYesAn unexpected crash, hidden by the SDK
MCPErrorNothingNo, it fails the requestThe whole request cannot proceed

When to raise which

  • Raise ToolError for 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 MCPError only when the request itself cannot go on.
Try it yourself
  • Raise ToolError from search_help when nothing is found, instead of returning "No articles found.".
  • Replace ToolError with ValueError and 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.