Building MCP Servers
Error Handling in MCP
An MCP server has an unusual audience for its errors: a language model. It cannot read your logs, it will not open a stack trace, and it will happily invent a plausible answer if your failure message tells it nothing. Error handling here is less about crashing safely and more about writing failures the model can act on.
MCP Error Types
MCP uses the standard JSON-RPC 2.0 codes for general protocol failures:
| Code | Name | Description |
|---|---|---|
| -32700 | Parse Error | Invalid JSON received |
| -32600 | Invalid Request | Malformed request |
| -32601 | Method Not Found | Unknown method called |
| -32602 | Invalid Params | Wrong parameters — including a resource URI that doesn't resolve |
| -32603 | Internal Error | Server-side error |
On top of those, the specification defines its own codes and partitions the implementation-defined range:
| Code | Name | When |
|---|---|---|
| -32020 | HeaderMismatch | Envelope metadata disagrees with the request body |
| -32021 | MissingRequiredClientCapability | The request needs a capability the client didn't declare; data.requiredCapabilities lists them |
| -32022 | UnsupportedProtocolVersion | The request's protocol version isn't one you speak |
Two rules about the ranges matter when you invent your own codes:
-32000to-32019is closed. These were handed out before the current policy and new implementations should not use them at all. The notable retiree is-32002(resource not found), now replaced by-32602— accept-32002from older servers, but never emit it.-32020to-32099belongs to the spec. Don't allocate in it. Application-specific codes go outside the JSON-RPC reserved range (-32768to-32000) entirely.
Raising Errors in Tools
When a tool encounters an error, raise it with a descriptive message:
from mcp.types import McpError
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "get_user":
user_id = arguments.get("user_id")
if not user_id:
raise McpError(
code=-32602,
message="user_id is required"
)
user = await db.get_user(user_id)
if not user:
raise McpError(
code=-32603,
message=f"User {user_id} not found"
)
return [TextContent(type="text", text=user.to_json())]
Error Categories
Every exception your code can raise needs to land on exactly one of these paths. Decide the mapping once, in one place, and the handler below writes itself:
Routing an exception to the right error code
The right-hand column is the part that matters. A model that retries a genuinely-missing record burns turns for nothing, and a model that gives up on a transient timeout loses work it could have recovered. Your message is what decides which happens.
Handle errors at the appropriate level:
@server.call_tool()
async def call_tool(name: str, arguments: dict):
try:
# Validation errors
validate_arguments(name, arguments)
# Business logic errors
result = await execute_tool(name, arguments)
return result
except ValidationError as e:
# Client's fault - bad parameters
raise McpError(code=-32602, message=str(e))
except NotFoundError as e:
# Resource not found
raise McpError(code=-32603, message=str(e))
except ExternalAPIError as e:
# External service failure
raise McpError(
code=-32603,
message=f"External service unavailable: {e}"
)
except Exception as e:
# Unexpected error - log it
logger.exception("Unexpected error in tool call")
raise McpError(
code=-32603,
message="An unexpected error occurred"
)
User-Friendly Error Messages
The model reads your error message and nothing else. Write it for a reader who has no access to your infrastructure and has to decide, right now, whether to try again:
Same failure, two messages
python1raise McpError(2 code=-32603,3 message="ERR_DB_CONN_FAIL"4)56# The model sees an opaque token.7# Common outcomes: it retries the identical call8# in a loop, or it apologises to the user and9# invents an answer from memory.
1raise McpError(2 code=-32603,3 message=(4 "Could not reach the knowledge base (connection "5 "timed out after 5s). This is usually temporary — "6 "retrying once is reasonable. If it fails again, "7 "tell the user the knowledge base is unavailable "8 "rather than answering from memory."9 )10)1112# States what broke, whether it is transient,13# and what to do if the retry also fails.
That last clause — rather than answering from memory — is doing real work. Without an instruction to fall back on, a model that has exhausted its retries tends to fill the gap itself, and a confidently wrong answer is worse than a visible error.
Logging for Debugging
Always log errors with context:
import logging
logger = logging.getLogger("mcp-server")
@server.call_tool()
async def call_tool(name: str, arguments: dict):
logger.info(f"Tool called: {name}", extra={"arguments": arguments})
try:
result = await execute_tool(name, arguments)
logger.info(f"Tool succeeded: {name}")
return result
except Exception as e:
logger.error(
f"Tool failed: {name}",
extra={"arguments": arguments, "error": str(e)},
exc_info=True
)
raise
Next, we'll build a complete server lab exercise. :::
Sign in to rate