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
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:
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user