chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
# error-handling
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.
## Run it
```bash
# 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
```
## What to look at
- `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.
## Caveats
- The "any other exception → `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.
## Spec
[Tools — error handling](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#error-handling)
## See also
`tools/` (the happy path), `streaming/` (cancellation as a third error-adjacent
surface).
+38
View File
@@ -0,0 +1,38 @@
"""Prove the two error channels: is_error results return; MCPError raises."""
from mcp_types import INVALID_PARAMS, TextContent
from mcp import MCPError
from mcp.client import Client
from stories._harness import Target, run_client
async def main(target: Target, *, mode: str = "auto") -> None:
async with Client(target, mode=mode) as client:
# Success: is_error defaults to False.
ok = await client.call_tool("divide", {"a": 6, "b": 2})
assert ok.is_error is False, ok
assert isinstance(ok.content[0], TextContent)
assert ok.content[0].text == "3.0"
# Execution error: arrives as a *result* — await returns, no exception.
failed = await client.call_tool("divide", {"a": 1, "b": 0})
assert failed.is_error is True, "execution errors ride CallToolResult, not an exception"
assert isinstance(failed.content[0], TextContent)
# MCPServer prefixes "Error executing tool divide: ..."; lowlevel returns
# the message verbatim. Assert the substring both produce.
assert "cannot divide by zero" in failed.content[0].text
# Protocol error: arrives as a raised MCPError.
try:
await client.call_tool("restricted", {})
except MCPError as e:
assert e.code == INVALID_PARAMS
assert e.message == "this tool is gated"
assert e.data == {"reason": "demo"}
else:
raise AssertionError("expected MCPError for a protocol-level rejection")
if __name__ == "__main__":
run_client(main)
+35
View File
@@ -0,0 +1,35 @@
"""Two error channels: ToolError -> is_error result; MCPError -> JSON-RPC protocol error."""
from mcp_types import INVALID_PARAMS
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.shared.exceptions import MCPError
from stories._hosting import run_server_from_args
def build_server() -> MCPServer:
mcp = MCPServer("error-handling-example")
@mcp.tool()
def divide(a: float, b: float) -> float:
"""Divide a by b. Division by zero is an execution error the LLM should see."""
if b == 0:
# ToolError is caught by the tool wrapper and returned as
# CallToolResult(is_error=True) — the LLM reads the message and can
# self-correct.
raise ToolError("cannot divide by zero")
return a / b
@mcp.tool()
def restricted() -> str:
"""A tool that always rejects the caller at the protocol level."""
# MCPError escapes the tool wrapper and becomes a JSON-RPC error
# response — the *host* sees code/message/data, not the LLM.
raise MCPError(code=INVALID_PARAMS, message="this tool is gated", data={"reason": "demo"})
return mcp
if __name__ == "__main__":
run_server_from_args(build_server)
@@ -0,0 +1,45 @@
"""Two error channels on lowlevel.Server: return is_error=True yourself, or raise MCPError."""
from typing import Any
import mcp_types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.shared.exceptions import MCPError
from stories._hosting import run_server_from_args
_TOOLS = [
types.Tool(name="divide", description="Divide a by b.", input_schema={"type": "object"}),
types.Tool(name="restricted", description="Always rejects.", input_schema={"type": "object"}),
]
def build_server() -> Server[Any]:
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=_TOOLS)
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
args = params.arguments or {}
if params.name == "divide":
a, b = float(args["a"]), float(args["b"])
if b == 0:
# Execution error: build the is_error result yourself.
return types.CallToolResult(
content=[types.TextContent(text="cannot divide by zero")],
is_error=True,
)
return types.CallToolResult(content=[types.TextContent(text=str(a / b))])
if params.name == "restricted":
# Protocol error: raise MCPError; the dispatcher serialises it as a
# JSON-RPC error response with this code/message/data.
raise MCPError(code=types.INVALID_PARAMS, message="this tool is gated", data={"reason": "demo"})
raise MCPError(code=types.INVALID_PARAMS, message=f"Unknown tool: {params.name}")
return Server("error-handling-example", on_list_tools=list_tools, on_call_tool=call_tool)
if __name__ == "__main__":
run_server_from_args(build_server)