Return anything the model could plausibly fix as a normal tool result with isError set to true, and reserve JSON-RPC errors for requests the model has no way to repair. The 2025-11-25 revision of the MCP specification states this directly: tool execution errors "contain actionable feedback that language models can use to self-correct and retry with adjusted parameters", while protocol errors "indicate issues with the request structure itself that models are less likely to be able to fix", and clients SHOULD pass the first kind to the model (MCP spec, Tools, Error Handling). The failure I see most in production servers is the opposite: every unhappy path throws, the agent receives a stack trace or a generic transport error, it retries the identical call, and three turns later the run is dead with nothing useful in the transcript.
Key takeaways
- Route by repairability. If a different argument or a preceding tool call would fix it, it is a tool result with
isError: true. If the request itself is malformed or the tool does not exist, it is a JSON-RPC error. - Error text is prompt text. Every error should name the failure, the specific fix, and whether retrying the same call can ever work.
- Never let a raw traceback or an upstream HTML body reach the model. Log it server side with a correlation id and return a bounded string.
- Models have no clock and no patience. "Retry later" produces an immediate retry, so absorb short backoff inside the tool call and return a terminal error after that.
- Missing or expired credentials on an HTTP transport belong in a 401 with
WWW-Authenticate, not in a tool result the model will try to work around. - Test error paths with fault injection and measure recovery rate, not only happy-path pass rate.
Which layer should carry each failure
Decide by asking whether the model could produce a different call that succeeds. Schema-level problems are handled before your code runs: the official SDKs validate arguments against inputSchema and reject a mismatch as a JSON-RPC error (typescript-sdk, python-sdk), using the standard codes from JSON-RPC 2.0. Everything your handler discovers after that point is business logic, and business logic failures are results, not errors.
| Failure | Layer | Retryable by the model |
|---|---|---|
| Unknown tool name | JSON-RPC error | No |
Arguments violate inputSchema | JSON-RPC error (SDK, before your handler) | Sometimes, host dependent |
| Argument valid but semantically wrong (past date, id from another tenant) | Tool result, isError | Yes, with a named correction |
| Record not found | Tool result, isError | Yes, after a lookup call |
| Upstream 429 or 5xx | Tool result, isError, after server-side backoff | Once, then terminal |
| Caller lacks permission for this object | Tool result, isError | No |
| Missing or expired token (HTTP transport) | HTTP 401 with WWW-Authenticate | No, the client re-authorizes |
| Bug in your server | Tool result, isError, correlation id only | No |
The spec's own example of a tool execution error is an input validation failure that reads like an instruction: Invalid departure date: must be in the future. Current date is 08/08/2025. That is the shape to copy. It tells the model what was wrong and gives it the fact it was missing.
What makes an error message recoverable
A recoverable error answers four questions in the fewest tokens that will do it: a stable code, what failed, the next action, and whether a retry of the same call can succeed. The code matters because it lets your host loop and your traces count identical failures without string matching. The next action matters because a model that does not know which tool to call next will guess, and guessing is where runs go sideways. Anthropic's guidance on writing tools for agents makes the same point from the model's side: an error is another chance to steer behaviour, so it should describe the correct usage rather than the exception class (Writing effective tools for agents).
{
"jsonrpc": "2.0",
"id": 12,
"result": {
"content": [{
"type": "text",
"text": "error_code: AMOUNT_ABOVE_REFUNDABLE\nwhat_failed: Requested 4500 cents, refundable balance on order A-8812 is 3200 cents.\nnext_action: Retry refund_order with amount_cents at or below 3200, or call get_order to confirm the balance.\nretryable: true"
}],
"isError": true
}
}
One schema detail bites people here. If a tool declares an outputSchema, the spec says servers MUST return structuredContent conforming to it, and clients SHOULD validate. An error envelope is a different shape from your success payload, so either model the error branch into the output schema explicitly or, simpler, omit structuredContent entirely on error results and return text only. Sending a non-conforming structured error is how you turn a handled failure into a client-side validation error the model never sees. If your agent is ignoring the tool even on the happy path, the problem is upstream of errors and lives in the name and description instead (why your agent never calls your MCP tool).
How to emit these errors in the official SDKs
In the TypeScript SDK, return an error result rather than throwing, so you control the exact bytes the model reads.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const fail = (e) => ({
isError: true,
content: [{
type: "text",
text: [
`error_code: ${e.code}`,
`what_failed: ${e.message}`,
`next_action: ${e.fix}`,
`retryable: ${e.retryable}`
].join("\n")
}]
});
server.registerTool(
"refund_order",
{ inputSchema: { order_id: z.string(), amount_cents: z.number().int().positive() } },
async ({ order_id, amount_cents }) => {
const order = await orders.get(order_id);
if (!order) {
return fail({
code: "ORDER_NOT_FOUND",
message: `No order with id ${order_id}.`,
fix: "Call search_orders with the customer email to get a valid order_id.",
retryable: false
});
}
if (amount_cents > order.refundable_cents) {
return fail({
code: "AMOUNT_ABOVE_REFUNDABLE",
message: `Requested ${amount_cents}, refundable balance is ${order.refundable_cents}.`,
fix: `Retry with amount_cents at or below ${order.refundable_cents}.`,
retryable: true
});
}
return { content: [{ type: "text", text: await refund(order, amount_cents) }] };
}
);
In the Python SDK, FastMCP converts an exception raised inside a tool into a tool result with isError set, using the exception message as the text. That default is fine as a backstop and poor as a strategy, because the message you get is whatever the library that failed happened to write. Raise a typed error whose message is your envelope.
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError
mcp = FastMCP("billing")
def fail(code: str, message: str, fix: str, retryable: bool) -> ToolError:
return ToolError(
f"error_code: {code}\nwhat_failed: {message}\n"
f"next_action: {fix}\nretryable: {retryable}"
)
@mcp.tool()
async def refund_order(order_id: str, amount_cents: int) -> str:
order = await orders.get(order_id)
if order is None:
raise fail(
"ORDER_NOT_FOUND",
f"No order with id {order_id}.",
"Call search_orders with the customer email first.",
False,
)
try:
return await refund(order, amount_cents)
except UpstreamError as exc:
incident = log_with_trace(exc) # full traceback stays server side
raise fail(
"UPSTREAM_UNAVAILABLE",
f"Payment provider rejected the call (incident {incident}).",
"Do not retry. Report the incident id to the user.",
False,
)
Wrap every handler in a catch-all that maps unknown exceptions to a single INTERNAL code with a correlation id. An uncaught exception on a stdio server can take the process down, which the client reports as a transport failure with no context at all, and the model then has no idea whether the refund happened.
How to keep error text out of your context budget
Cap error text at a few hundred characters and never pass through an upstream body verbatim. A Python traceback or an HTML error page from a load balancer can run to thousands of tokens, and unlike a log line it stays in the conversation transcript for every subsequent turn of the run. Three failed calls against a chatty upstream can cost more context than the actual data the agent was sent to fetch, which is the same accounting problem as oversized success payloads (stop MCP tool results eating your context).
Two rules that pay for themselves. First, truncate: keep the code, the first line of the upstream message, and the correlation id, and drop the rest. Second, collapse repeats: if the same tool returns the same error code twice in a row, the second result should be shorter than the first and say plainly that the state has not changed.
How to signal a retry without creating a retry loop
Do the waiting yourself, because the model cannot. A model that reads "rate limited, retry in 30 seconds" will retry on the next token it emits, at which point you are rate limited again and one more turn is gone. Absorb bounded backoff inside the tool call while it stays under the client timeout, and if the call is long enough that the timeout is at risk, send progress notifications, which clients may use to reset the timeout (streaming progress from a long-running MCP tool). When your budget is spent, return a terminal error that says explicitly not to retry.
Retries also need the write path to be safe. If refund_order can run twice because the first response was lost, the agent's recovery attempt becomes a duplicate refund, so accept a client-supplied idempotency key and return the original outcome on replay (making MCP tools idempotent for retrying agents). Stable error codes make the host-side loop breaker trivial: count identical (tool, error_code) pairs and stop the agent after two, rather than trying to parse intent out of free text.
Errors that should stop the agent, not steer it
Some failures must not read as an invitation to try something else. Authorization on an HTTP transport is the clearest case: a server that lacks a valid token returns HTTP 401 with a WWW-Authenticate header pointing at its protected resource metadata, and the client runs the OAuth flow (MCP authorization spec). Returning "authentication failed" as a tool result instead sends the model looking for a token-shaped argument to pass, which is how credentials end up pasted into a transcript. The mechanics of the handshake are covered in adding OAuth to a remote MCP server.
Permission denials are the second case. If a caller may not touch an object, say so and mark it not retryable, and do not disclose whether the object exists. The third is untrusted text: an upstream error body is attacker-influenced input in exactly the way a fetched web page is, so an error path that echoes it verbatim gives an injected instruction a direct route into the model's context (defending tool-using agents against prompt injection). Map upstream failures onto your own enumerated codes and let the raw text stay in your logs.
How to test error paths before an agent finds them
Fault injection first, evaluation second. Put a switch in front of the upstream client that can force each failure class on demand, then drive the server through MCP Inspector to confirm each one comes back as the layer and shape you intended, with isError set where you expect and no traceback in the payload.
# pytest: every declared failure class returns a recoverable envelope
import pytest
CASES = [
("not_found", "ORDER_NOT_FOUND", "retryable: False"),
("rate_limit", "UPSTREAM_RATE_LIMITED", "retryable: True"),
("upstream_5xx","UPSTREAM_UNAVAILABLE", "retryable: False"),
("boom", "INTERNAL", "retryable: False"),
]
@pytest.mark.parametrize("fault, code, retry_line", CASES)
async def test_error_envelope(client, fault, code, retry_line):
result = await client.call_tool("refund_order",
{"order_id": f"fault:{fault}", "amount_cents": 100})
text = result.content[0].text
assert result.isError is True
assert code in text and retry_line in text
assert "next_action:" in text
assert "Traceback" not in text
assert len(text) < 600
Then run the agent against the same faults and score recovery, meaning the fraction of injected failures where the agent reached the correct end state without human input. That number moves when you rewrite error text, and it is the only measurement that tells you whether the text worked. Turn count per recovered failure is the companion metric: an error that gets fixed after four exploratory calls is not a well written error.
Trade-offs I would make
Prose over strict JSON in the text block. Models parse both, humans reading a trace parse prose faster, and a rigid JSON error tempts you into schema games with outputSchema that buy nothing. Keep the codes stable and machine-greppable, keep the rest readable.
Server-side retries over model-side retries for anything transient, up to about a second or two of total backoff. Below that threshold the model never needs to know the upstream flickered. Above it, hand back a terminal error and let the host decide, because a model spending turns on backoff is the most expensive sleep function ever built.
Be conservative with isError itself. An empty result set is a success, not an error, and marking it otherwise both trains the agent to retry a query that worked and lights up error UI in hosts that render failed calls prominently. Reserve the flag for calls that did not do what they were asked to do.
Where I would spend the extra effort: the next_action line. It is the one field that changes agent behaviour, and it is the one most teams leave as a generic apology. Naming the exact tool and argument to try next converts a dead run into a recovered one more often than any other change to a server I have shipped.
Sources
- Model Context Protocol specification, Tools (2025-11-25), Error Handling
- Model Context Protocol specification, Authorization
- JSON-RPC 2.0 specification, error object and reserved codes
- modelcontextprotocol/typescript-sdk
- modelcontextprotocol/python-sdk
- Anthropic, Writing effective tools for agents
- Anthropic docs, tool use and the is_error field on tool results