MCPMCP Python SDK 2.2 · Python 3.10+
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
24 small wins to finish your pathNext lesson

Tool errors: what the model sees when a call fails

A model will ask for orders that do not exist. What it reads back decides whether it can recover, and that depends on how your tool fails.

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

ORDERS["Z9"] raised a KeyError inside the tool. 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.

An error the model can act on

Exampleshop.py, lookup_order now
@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])

ToolError comes from mcp.server.mcpserver.exceptions. Raise it with a message written for the model: what went wrong and what a valid value looks like.

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

Z9 fails with your message, and A17 still works. Checking is_error before reading structured_content is the habit every client needs. The call itself succeeded and returned an error result, which a model reads like any other tool answer, then asks the customer for the right order id or tries again.

Raise, never return an error
A tool that returns the string "Order not found" sends is_error=False. To the model and to every client, the tool worked and that sentence was the answer. The flag is the signal, and only raising sets it.

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. The SDK docs give one question for choosing: could a smarter model have avoided this? A wrong order id, yes, so ToolError. A server that is 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.

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.

Little by little, you're building something great.