03 · Error Handling & Resilience Patterns¶
Up to now every tool call in this course has succeeded. In a multi-server setup running for real, that stops being true: a server times out, a network blip drops a remote connection, a tool call fails because the underlying GitHub API rate-limited you. This lesson covers how MCP represents failure on the wire, and how to build a client that survives it.
The JSON-RPC error object¶
Every JSON-RPC error response has the same three fields, and MCP inherits them unchanged:
{
"jsonrpc": "2.0",
"id": 42,
"error": {
"code": -32601,
"message": "Method not found",
"data": {"method": "tools/cal"}
}
}
code is a signed integer, message is a short human-readable string, and
data is optional, free-form extra detail. JSON-RPC 2.0 reserves a block of
standard codes:
| Code | Meaning |
|---|---|
-32700 |
Parse error — invalid JSON was sent |
-32600 |
Invalid Request — well-formed JSON, but not a valid JSON-RPC request |
-32601 |
Method not found |
-32602 |
Invalid params |
-32603 |
Internal error |
MCP uses these for protocol-level failures (you called a method that
doesn't exist, or sent malformed params) and layers its own, tool-specific
failure signal on top for a different case: a tool that ran but the
operation itself failed (e.g. "that file doesn't exist"). That case isn't
a JSON-RPC error at all — it's a normal, successful tools/call response
with isError: true on the result content:
{
"jsonrpc": "2.0",
"id": 43,
"result": {
"content": [{"type": "text", "text": "File not found: notes/missing.md"}],
"isError": true
}
}
That distinction matters for how you write client code: a JSON-RPC-level
error means something is wrong with the protocol exchange itself
(connection, method, parameters) and is usually worth retrying or
surfacing as a system error. An isError: true tool result means the tool
ran correctly and is reporting that the requested operation failed — the
model should see that message and decide what to do next, not have your
client swallow it as a crash.
async def call_tool_distinguishing_errors(session, tool_name, arguments):
try:
result = await session.call_tool(tool_name, arguments)
except Exception as e:
# protocol-level failure: connection dropped, method not found, etc.
return {"kind": "protocol_error", "detail": str(e)}
if result.isError:
# the tool ran, but the operation it represents failed
return {"kind": "tool_error", "detail": result.content}
return {"kind": "success", "detail": result.content}
How It Actually Works: what's safely retryable¶
Not every failure deserves the same response, and blindly retrying everything is its own bug (retrying a non-idempotent "create issue" call on a timeout can create the issue twice). A practical classification:
- Transport-level connection failures (the subprocess died, the HTTP connection reset) are usually safe to retry the connection, not necessarily the in-flight call — you often can't tell if the server received and processed the request before the connection dropped.
-32601Method not found /-32602Invalid params are never worth retrying as-is — the request itself is wrong, and retrying it unchanged will fail identically every time. Fix the request or don't send it.-32603Internal error might be transient (the server hit a bug under specific, maybe-momentary conditions) — a single retry with a short backoff is reasonable, but don't retry indefinitely.isError: truetool results are not retryable by your client code at all in the generic case — "file not found" won't become "file found" on a second attempt. These should flow back to the model, which can decide to try a different approach.- Idempotency is the real gate on whether to retry anything. A
read-only tool (
list_issues) is safe to retry freely. A write tool (create_issue) is only safe to retry if the server supports an idempotency key or you can detect duplicates after the fact.
Timeouts and backoff¶
import asyncio
async def call_with_retry(session, tool_name, arguments, max_attempts=3, timeout=10.0):
last_error = None
for attempt in range(max_attempts):
try:
return await asyncio.wait_for(
session.call_tool(tool_name, arguments), timeout=timeout
)
except asyncio.TimeoutError as e:
last_error = e
except Exception as e:
# don't retry protocol errors that won't change on retry
if "Method not found" in str(e) or "Invalid params" in str(e):
raise
last_error = e
await asyncio.sleep(2 ** attempt) # 1s, 2s, 4s backoff
raise last_error
This only retries timeouts and unclassified exceptions — a method-not-found or invalid-params error is re-raised immediately rather than wasting three attempts on a request that can't succeed.
Graceful degradation when a server disappears¶
In a multi-server client, one dead server shouldn't take down the others. Wrap per-server operations so a failure in one is contained:
async def list_all_tools_resilient(client) -> dict:
catalog = {}
for server_name, session in list(client.sessions.items()):
try:
result = await asyncio.wait_for(session.list_tools(), timeout=5.0)
catalog[server_name] = result.tools
except Exception as e:
print(f"Server {server_name} unavailable, skipping: {e}")
catalog[server_name] = [] # degrade, don't crash the whole catalog build
return catalog
A user losing access to the GitHub server mid-session because of a network blip should mean "GitHub tools are temporarily unavailable," not "the entire assistant stopped working."
Exercise¶
Add a call_with_retry-style wrapper to the MultiServerClient from
lesson 1 that: retries transport timeouts up to 3 times with exponential
backoff, never retries -32601/-32602 errors, and returns a tagged
{"kind": ..., "detail": ...} dict like the one above for every outcome
(success, tool_error, protocol_error) so calling code never has to guard
against an unhandled exception leaking out of a tool call.