On this page
MCP Tool Errors: Why Your Agent Keeps Calling a Failing Tool (Nov 2026)

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-After value; 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, /mcp offers 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.

More: MCP server security risk ยท Browse the directory