How should a tool report errors — protocol-level errors vs. execution errors?

5 minintermediatemcptoolserror-handling

Quick Answer

MCP distinguishes two error categories. A protocol-level error (a normal JSON-RPC error object, e.g. "method not found" or "invalid params") means something is wrong with the request itself — an unknown tool name, malformed JSON-RPC. A tool execution error (the tool ran, but the underlying operation failed — a network timeout, an invalid city name, an API rejecting a request) should be returned as a successful JSON-RPC result with isError: true inside the CallToolResult, and the failure reason described in the content array. This split matters because execution errors are meant to be shown to the model as part of the conversation (so it can retry, adjust arguments, or explain the failure to the user), whereas a JSON-RPC error indicates the client/host did something structurally wrong that the model usually can't fix by itself.

Detailed Answer

Mixing these two error channels up is one of the most common mistakes in a first MCP server implementation.

Two different failure shapes

// Protocol-level error: the REQUEST was invalid (unknown tool, bad JSON-RPC)
{"jsonrpc": "2.0", "id": 5, "error": {
  "code": -32602, "message": "Unknown tool: send_emial"
}}
// Execution error: the request was valid, the TOOL failed while running
{"jsonrpc": "2.0", "id": 5, "result": {
  "isError": true,
  "content": [{"type": "text", "text": "SMTP server rejected the message: mailbox not found"}]
}}

Why the split matters

  • A JSON-RPC error signals a bug in how the client is talking to the server — wrong method, malformed arguments per the schema, tool doesn't exist. It's a plumbing failure. The model generally can't meaningfully react to it.
  • isError: true inside a normal result means the request was well-formed, but something in the real world went wrong. This content still flows back into the model's context like any other tool result. The model can read "mailbox not found," recognize the problem, and retry with corrected arguments, try a different tool, or tell the user what happened.

A concrete implementation pattern

@mcp.tool()
def send_email(to: str, subject: str, body: str) -> str:
    try:
        smtp_client.send(to, subject, body)
        return f"Email sent to {to}"
    except SMTPRecipientRefused:
        # Wrapped as isError: true, not a JSON-RPC error — the model sees it
        # in context and can retry, adjust arguments, or explain it to the user,
        # instead of the interaction breaking on an opaque protocol failure.
        raise ToolExecutionError(f"Recipient {to} was refused by the mail server")

Related Resources