examples/stories/error_handling/README.md
Tool execution failures travel as a successful CallToolResult with
is_error=True so the LLM can read the message and self-correct.
Protocol failures travel as a JSON-RPC error that the client catches as
MCPError. This story shows how to produce each from a tool body — raise ToolError(...) vs raise MCPError(...) on MCPServer; an explicit
is_error=True return vs raise MCPError on lowlevel.Server — and how a
client tells them apart.
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.error_handling.client
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
uv run python -m stories.error_handling.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.error_handling.client --http --server server_lowlevel
client.py main — opens with async with Client(target, mode=mode) as client:. Inside it, await returns for is_error results and
except MCPError catches protocol errors; the client never auto-raises on
is_error.server.py — raise ToolError(...) vs raise MCPError(...): same raise
keyword, opposite wire channel. The tool wrapper re-raises MCPError
verbatim and wraps everything else as an is_error result.server_lowlevel.py — no wrapper: you build CallToolResult(is_error=True)
yourself, and MCPError is the only way to pick a JSON-RPC error code.is_error result" contract on MCPServer and the
"uncaught exception → code=0" behaviour on lowlevel.Server are not
shown — the contract is under design and the legacy code is a known spec
divergence. This story will grow those cases once the contract lands.MCPServer prefixes the execution-error message with
"Error executing tool {name}: "; build a CallToolResult directly from a
lowlevel handler if you need verbatim control.tools/ (the happy path), streaming/ (cancellation as a third error-adjacent
surface).