Live Webinar
The Festive Growth Sessions: How Top Brands Drive More Leads· 23 Sept, 3:30 PM ISTSave Your Seat
Wati

What Should an MCP Tool Return When It Fails?

Rohan Chaturvedi
6 mins read
Fact-checked by: Namitha Sudhakar
|According to: Editorial Policies
What Should an MCP Tool Return When It Fails?
CategoriesAI Agent

Too Long? Read This First

  • MCP separates request-level failures from execution-level failures: use a JSON-RPC error when the request itself is invalid, and a normal result with isError: true when a valid tool call simply didn't succeed.
  • A tool execution failure should still return content the model can read; a JSON-RPC error carries no content field at all.
  • Good error text names the operation, the specific argument or state handle, the concrete reason, and the next action; "request failed" gives the model nothing to act on.
  • Never put secrets, stack traces, or raw SQL in model-facing error content; log that detail server-side instead.
  • The Python SDK makes the split explicit: raise ToolError for a recoverable execution failure, raise MCPError for a protocol-level one.

A surprising number of MCP servers get this backwards: they throw a JSON-RPC error for a routine business failure, like an invalid date or a missing record, and the model never sees why the call didn't work. It just gets a broken turn with nothing to reason about.

The Model Context Protocol actually defines two distinct failure channels, and they exist for different reasons. Knowing which one to use, and what to put inside it, is the difference between an agent that can retry intelligently and one that just gives up or hallucinates an explanation.

Two Failure Channels, Not One

The first channel is a JSON-RPC error, used when the request itself can't be processed as a valid MCP operation: an unknown tool name, a malformed request, or a server-level protocol problem. The response has an error object instead of a result:

"jsonrpc": "2.0", "id": 3, "error": { "code": -32602, "message": "Unknown tool: invalid_tool_name" }

That error must contain an integer code and a message, and may carry an optional data field with more detail.

The second channel is a normal tool result with isError set to true, used when the tool was found and the call was valid, but execution failed for a business reason: an upstream API failure, an invalid value that made it past the schema, a missing record, or an expired state handle. For example:

"result": { "content": [ { "type": "text", "text": "Invalid departure date: must be in the future. Current date is 08/08/2025." } ], "isError": true }

The distinction matters because of what the model actually receives. A JSON-RPC error produces no tool result and no content for the model to read; that's appropriate when the request itself was broken, but it's the wrong channel for an ordinary, recoverable failure, because the model gets nothing to correct course with.

Why the Model Needs the Result, Not the Exception

The model chose the tool and supplied the arguments, so it needs to see the consequence of that choice to adjust.

An isError: true result preserves the conversation turn and hands back something the model can act on: that a date has to be in the future, that a value is out of range, that a handle expired and needs to be recreated.

Throw a protocol-level error instead, and that entire feedback loop disappears; the model has no content to reason from and typically just stalls or guesses.

What a Recoverable Error Message Actually Needs

Vague text like "failed" or "invalid input" forces the model to guess.

A useful message includes five things: what operation failed, the specific argument, resource, or state handle involved, the concrete reason, the applicable constraint or current state, and the next action the model should take, whether that's a corrected format, a valid range, a retry delay, or a fresh handle.

Message

Why it works or fails

"Request failed."

No operation named, no reason, no next step. The model can only retry blindly.

"Rate limit exceeded for the weather API. Retry after 60 seconds."

Names the constraint and gives an exact, actionable next step.

"Account lookup failed: account_id 'a-17' was not found. Verify the ID or create the account first."

Names the operation, the specific value, the reason, and two concrete recovery paths.

Keep secrets, credentials, stack traces, and raw SQL out of this channel entirely; that information belongs in server-side logs, not in text the model (and potentially the end user, through the model's response) can see.

Structured Error Content, Used Correctly

A tool result can carry both unstructured content and optional structured content. content is what the model actually reads; structuredContent is JSON meant for the calling application, and must match the tool's declared output schema if one exists. For an error, the actionable explanation belongs in content, with isError: true set, and a structured payload can add machine-readable detail alongside it:

"structuredContent": { "error": { "category": "not_found", "account_id": "a-17", "retryable": false, "suggested_action": "verify_account_id_or_create_account" } }

Don't rely on the structured payload alone. The model reads the content channel, and a failed call should never leave isError false with a structured object standing in for a real answer; that shape reads as a successful call and hides the failure entirely.

Standard Error Codes Worth Knowing

MCP reuses JSON-RPC 2.0's standard codes for protocol-level failures, and reserves a further range for its own use.

Code

Meaning

-32700

Parse error: the received JSON was invalid

-32600

Invalid Request: not a valid JSON-RPC message

-32601

Method Not Found: the requested method doesn't exist

-32602

Invalid Params: malformed parameters, including an unknown tool name or invalid tool arguments

-32603

Internal Error: an internal server or protocol failure

JSON-RPC additionally reserves -32000 through -32099 for server-defined codes. Within the current MCP specification, -32000 through -32019 are legacy implementation-assigned codes, and -32020 through -32099 are reserved for MCP-defined codes, including -32020 (HeaderMismatch), -32021 (MissingRequiredClientCapability), and -32022 (UnsupportedProtocolVersion).

New application-specific codes should sit outside the JSON-RPC reserved range unless MCP itself defines them, and none of these codes should ever be used to report an ordinary, model-recoverable tool failure.

How the Official SDKs Enforce the Split

The Python SDK makes the two channels concrete in code: raising ToolError inside a tool handler produces a result with is_error=True and the message in content, while raising MCPError produces a genuine JSON-RPC error.

Its own documentation specifically warns against returning an error string as if it were a successful value, since that leaves is_error false and makes a failure look like a working answer to anything downstream.

The TypeScript SDK shows the same pattern: a handler returns { content: [...], isError: true }, and its client-side tests confirm that a failed call resolves as an ordinary result the caller can inspect, rather than throwing.

Applying This to a WhatsApp MCP Server

WhatsApp tools fail for reasons that map cleanly onto this split. An unknown tool name, or a call missing a required argument, is a protocol-level problem: a genuine JSON-RPC error is correct there.

A send attempt that fails because a contact's 24-hour session window has closed, or because a template hasn't been approved yet, is a business failure the model needs to see and can act on, so it belongs in an isError: true result with a message naming the constraint and the fix, not a thrown exception.

Wati's MCP security writeup touches this same spot from a different angle: an error message is also a place where secrets or internal state can leak if a server isn't careful about what it puts in model-facing content.

The Wati MCP server is built around named actions with meaningful failure states rather than a single generic call, which makes room for exactly this kind of specific, actionable error text.

Reviewing whether an agent actually recovered from a failed send or a bad argument is a job for auditing a WhatsApp AI agent in Claude, since the session transcript shows whether it read the error text and corrected course or just gave up.

The ten-minute WhatsApp AI agent build guide is a reasonable place to see the tool set these errors would attach to.

Frequently asked questions

Should every tool failure be a JSON-RPC error to be safe?

No. That approach hides the failure from the model entirely, since a JSON-RPC error carries no content field. Reserve JSON-RPC errors for genuinely invalid requests, like an unknown tool name, and use isError: true results for anything a valid call failed to do.

What's the difference between content and structuredContent in an error result?

Content is what the model reads and should always carry the actionable explanation. structuredContent is JSON for the calling application and, if the tool declares an output schema, must conform to it; treat it as a supplement to the message, not a replacement for it.

Is it safe to include a stack trace in an error message for debugging?

No. Model-facing content can end up surfaced to an end user through the agent's response, and it may be read by a model that shouldn't see internal implementation detail. Log the trace server-side and send the model only what it needs to retry correctly.

Does MCP define its own error codes beyond standard JSON-RPC?

Yes, a small set: -32020 (HeaderMismatch), -32021 (MissingRequiredClientCapability), and -32022 (UnsupportedProtocolVersion), reserved within the -32000 to -32099 server-defined range. These cover protocol-level conditions, not ordinary tool execution failures.

Related posts