On this page
Your agent keeps calling a failing tool because the model only sees the error text, and that text doesn't say the failure is permanent. The MCP spec sends tool errors back to the model so it can fix and retry. Fix it on the server by saying whether a retry can help, and on the client by keeping approval on.
This guide comes from an r/mcp post with 20+ comments, "My agent kept calling an MCP tool after it said the API key is invalid or revoked" (the thread that prompted this).
The two kinds of MCP error
The Tools section of the MCP spec (version 2026-07-28, the current one) defines two ways a tool can fail:
| Kind | How it's sent | Spec examples |
|---|---|---|
| Protocol error | A JSON-RPC error object, for example code -32602 |
Unknown tools, malformed requests that fail the CallToolRequest schema, server errors |
| Tool execution error | A normal result with isError: true and text content |
API failures, input validation errors, business logic errors |
Note when this changed. In 2025-06-18, "invalid arguments" was listed as a
protocol error. The 2025-11-25 spec moved "input validation errors (e.g., date
in wrong format, value out of range)" into tool execution errors, and 2026-07-28
keeps them there. One visible difference in 2026-07-28: every result
now carries "resultType": "complete" alongside isError
(changelog).
The reason matters for this problem. The spec says: "Tool Execution Errors contain actionable feedback that language models can use to self-correct and retry with adjusted parameters." Clients "SHOULD provide tool execution errors to language models to enable self-correction." Protocol errors, by contrast, are "less likely to result in successful recovery."
So the protocol is built to show the model execution errors and invite a retry. That's right for a bad date. It's wrong for a revoked API key, unless the error says so.
Why the agent keeps retrying
A revoked upstream key usually comes back as an API failure: isError: true
with text like "Request failed" or "Error: 401". The model reads that, sees the
spec-shaped invitation to self-correct, and tries again, maybe with different
arguments. Nothing in the result says "this won't change until a human acts."
MCP has no field for "permanent" or "retryable." The only signal is the text you write. If it's vague, a retry looks reasonable every time.
Server-side fixes (for server authors)
1. Return isError with text that says whether to retry
The TypeScript SDK server docs
say: "To indicate that a tool call failed, set isError: true in the result."
Here is a minimal tool that sorts failures into permanent and temporary:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const apiKey = process.env.FOO_API_KEY;
if (!apiKey) {
console.error("FOO_API_KEY is not set. Add it to this server's env and restart.");
process.exit(1);
}
const fail = (text: string) => ({
content: [{ type: "text" as const, text }],
isError: true,
});
const server = new McpServer({ name: "foo", version: "1.0.0" });
server.registerTool(
"search_foo",
{ description: "Search Foo records", inputSchema: { query: z.string() } },
async ({ query }) => {
let res: Response;
try {
res = await fetch(
`https://api.foo.example/search?q=${encodeURIComponent(query)}`,
{ headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(15000) },
);
} catch {
return fail("Foo didn't respond in time. This may be temporary. Try once more, then stop.");
}
if (res.status === 401 || res.status === 403) {
return fail(
`The API key was rejected (${res.status}). Retrying won't help. ` +
"Ask the user to update FOO_API_KEY, then reconnect.",
);
}
if (res.status === 429) {
const wait = res.headers.get("retry-after") ?? "60 seconds";
return fail(`Foo's rate limit was hit (429). This is temporary. Wait ${wait} before one retry.`);
}
if (!res.ok) {
return fail(`Foo returned ${res.status}. Try once more later; if it fails again, tell the user.`);
}
return { content: [{ type: "text", text: await res.text() }] };
},
);
await server.connect(new StdioServerTransport());
What makes it work:
- Permanent errors say so. Auth (401), permission (403), "account suspended", "resource deleted": say "Retrying won't help" and name the human action.
- Temporary errors give a limit. Rate limits pass on the
Retry-Aftervalue; timeouts suggest one more try, not an open-ended loop. - Validation errors say what to change, like the spec's example: "Invalid departure date: must be in the future."
2. Don't leak secrets or stack traces
Everything in an error result goes into the model's context and often into logs. The spec says servers MUST "sanitize tool outputs." Don't echo the key, the full upstream response, or a stack trace. "The API key was rejected" is enough. See MCP security best practices.
3. Fail at startup when the key is missing
The example exits before connecting if FOO_API_KEY is unset. In Claude Code,
a stdio server that fails to start is marked as failed in /mcp, so you see the
problem once at startup instead of the model finding it one call at a time.
Claude Code also warns in /mcp and claude mcp list when a ${VAR} in your
config isn't set, naming the variable
(Claude Code MCP docs). Setup is covered
in MCP API keys and config.
4. Remote OAuth servers: use HTTP 401, not a tool result
This applies to the token the client sends to your MCP server, not to an
upstream API key. The authorization spec
says "Invalid or expired tokens MUST receive a HTTP 401 response", and the
server must point to its metadata via a WWW-Authenticate header or a
well-known URI. Clients "MUST be able to parse WWW-Authenticate headers
and respond appropriately." Insufficient scope gets a 403 with
error="insufficient_scope", and clients "SHOULD implement retry limits."
The spec also says stdio servers should not use this flow and should "retrieve
credentials from the environment."
Claude Code documents what it does with these: on a 401 from a server you
signed in to, it refreshes the token, reconnects and retries once, then flags
the server as needing authentication in /mcp if that fails. A 401 hidden
inside a tool result skips all of that. More in
MCP authentication explained.
Client and user-side fixes
- Give the agent a retry rule. This is our advice, not a documented client feature. Add a line like this to your CLAUDE.md or system prompt: "If an MCP tool fails twice with the same error, or says retrying won't help, stop and tell me."
- Keep approval on for paid or write tools. Claude Code rules are checked
deny, then ask, then allow, and an ask rule like
mcp__foo__*prompts on every call to that server, even when a narrower allow rule also matches (permissions docs). A loop can't run up costs if each call needs a yes. - Revoke and reconnect. Rotate the key at the provider, update your config,
then use Reconnect in
/mcp. For OAuth servers,/mcpoffers Re-authenticate and Clear authentication. - Check
/mcp. It shows each server's status, failed connections and servers that need authentication. Toggling a server off there stops Claude Code connecting to it without deleting its config.
Claude Code's MCP docs don't describe a setting that limits how many times the
model calls a tool that returned isError. The retries they document are for
dropped connections, first connections and auth refreshes. For slow tools, see
MCP tool call timeouts.
The trust angle
A server that turns every failure into "Error" costs you twice: wasted calls on paid APIs, and time spent working out that a key was revoked. A server that wraps auth errors as success, or returns an empty result, is worse, because the agent may act on bad data.
Our trust grade doesn't test error
text directly. Its authentication check does reward scoped, revocable
credentials, which is what lets you cut off a looping agent quickly. Accurate
tool annotations help too: Claude Code only
repeats a call after a dropped connection for tools marked idempotentHint or
readOnlyHint.
Frequently asked questions
Should I return isError or throw an exception?
The spec puts API failures and validation errors in results with
isError: true, so the model can see them. Use a protocol error for things the
spec lists there: unknown tools, malformed requests, server errors. The TS SDK
docs show catching the error and returning isError: true. Check your SDK's
docs for what an uncaught exception becomes.
Should my tool return an error or an empty result?
If the call failed, return an error. An empty result tells the model "nothing matched," which is a different, wrong fact. Return empty only when the search really ran and found nothing.
Can I make Claude Code stop retrying?
There's no documented retry-limit setting for tool calls. Use an ask rule so
each call needs your approval, deny or toggle off the server in /mcp, and put
a retry rule in your instructions.
Is a 401 from the upstream API the same as MCP's 401?
No. MCP's HTTP 401 is about the token for the MCP server itself. A 401 from the
API your tool calls is a tool execution error: return it with isError: true
and say retrying won't help.