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.
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
@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.
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.
"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.
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.
- 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.
Little by little, you're building something great.