MCP Tool Call Error Handling: When an Agent Should Retry
Good MCP tool call error handling gives the model an error it can act on, then tells it which action fits which error: fix the call, wait and retry, stop, or ask the user. Perceptlayer's call_endpoint returns every failure as a tool result marked isError, with text that starts Error <code>:, so the model reads the code directly. Only two codes are worth retrying unchanged, and no failed call costs credits.
Why should tool errors reach the model at all?
Because a model can only correct what it can see. The MCP schema says errors that originate from a tool should be reported inside the tool result with isError set to true, since otherwise the model wouldn't know an error occurred and couldn't self-correct (Model Context Protocol schema, 2025). The tools specification draws the line the same way: unknown tools, invalid arguments and server errors are JSON-RPC protocol errors, while API failures and business-logic errors are tool execution errors reported with isError: true (MCP tools specification, 2025).
Perceptlayer's MCP server follows that split. An endpoint's own parameters travel inside call_endpoint's params object, so a bad value there is a failure of the endpoint call, not of the tool's arguments. A bad endpoint ID, bad parameters, an empty result or a busy source all come back as normal tool results with isError: true, so the model reads them like any other output and decides what to do next. That puts the decision with the model, which is useful for invalid_parameters, where it can repair the call itself, and risky for codes it can't fix, where an unguided model may try again anyway. The rest of this article is about guiding that decision.
What does the model actually see when a call fails?
One line of text: Error, the code, a colon, a message, and the request ID in brackets. When the source asks you to wait, the line also ends with Retry after N seconds., taken from the Retry-After header. These are the messages the server sends today, from its source code, with the request ID shortened:
| Code | HTTP status behind it | What the model reads |
|---|---|---|
invalid_parameters |
422 | Error invalid_parameters: Invalid parameters: 'safe' must be one of: active, off. (request_id: req_…) |
unknown_endpoint |
none (MCP-level) | Error unknown_endpoint: No endpoint 'google.search'. Use search_endpoints to find endpoint IDs. |
not_found |
404 | Error not_found: No data was found for these parameters. You were not charged. (request_id: req_…) |
rate_limited |
429 | Error rate_limited: Too many requests. Retry after the number of seconds in the Retry-After header. Retry after N seconds. (request_id: req_…) |
temporarily_unavailable |
503 | Error temporarily_unavailable: The data source is busy. … You were not charged. Retry after N seconds. (request_id: req_…) |
insufficient_credits |
402 | Error insufficient_credits: Your credit balance is used up. Buy more credits to continue. (request_id: req_…) |
restricted_endpoint |
403 | Error restricted_endpoint: This endpoint requires accepting the restricted-endpoint terms in your dashboard. (request_id: req_…) |
invalid_api_key |
401 | Error invalid_api_key: Missing, unknown or revoked API key. (request_id: req_…) |
Two more codes can appear: source_error (502, "The data source returned an error") and timeout (504, "did not answer within 60 seconds"), both ending "You were not charged." When the source itself rejects parameters that passed validation, invalid_parameters carries "The data source rejected these parameters. Check them against the API reference." The errors docs list every code with its HTTP status.
Which action fits each error code?
Sort the codes by who can fix the problem. If the model can fix it, let it fix the call and try once more. If time fixes it, wait the stated seconds and retry. If nothing in this conversation will change the answer, stop. If only the user can fix it, say so and end the task. Every one of these failures costs 0 credits, so credit cost never decides the action; wasted turns do.
| Code | Who fixes it | Agent action | Retry? |
|---|---|---|---|
invalid_parameters |
The model | Read the message, call get_endpoint if needed, correct the parameter |
Once, with the fix |
unknown_endpoint |
The model | Call search_endpoints, use a listed ID |
Once, with the new ID |
rate_limited |
Time | Wait the Retry after N seconds, then repeat the same call |
Yes, after the wait |
temporarily_unavailable |
Time | Same as rate_limited |
Yes, after the wait, at most twice |
source_error, timeout |
Maybe time | Retry once later; if it fails again, report it | Once |
not_found |
Nobody | Accept that there's no data for these parameters; change the question or report it | No, not unchanged |
insufficient_credits |
The user | Stop and tell the user the balance is used up | No |
restricted_endpoint |
The user | Stop and tell the user to accept the restricted-endpoint terms | No |
invalid_api_key |
The user | Stop and tell the user to fix the server's API key | No |
not_found deserves care. It means the source had nothing for those exact parameters, which is an answer, not a fault. Retrying the same call wastes a turn. A different query, a broader location or another source may succeed, and that's a new call, not a retry.
What does a recovered call look like?
Here's the most common recovery: a parameter outside its allowed values, fixed on the next turn. The model asks for a Google search with safe set to a value the endpoint doesn't accept:
{ "endpoint": "google-search.search", "params": { "query": "best restaurants in Berlin", "safe": "maybe" } }
The tool result comes back with isError: true and this text:
Error invalid_parameters: Invalid parameters: 'safe' must be one of: active, off. (request_id: req_…)
The message names the parameter and its allowed values, so the model needs no extra lookup. It sends the call again with "safe": "off", and this time the result is the search JSON followed by a second text block, Credits used: 1. Credits remaining: … and the request ID. The failed attempt cost nothing; the whole recovery cost 1 credit. The Google Search API page lists that endpoint's other parameters.
A rate limit plays out differently. With the free account's 1 request a second, an agent that fires several calls at once gets rate_limited on the extras, each ending Retry after N seconds. The right move is to wait that long and repeat the same calls, not to change them. The rate limits docs explain the per-account limits, and the article on MCP server rate limiting covers pacing calls so the limit rarely trips.
What should the system prompt say?
State the mapping in a few plain rules, keyed on the code at the start of the error text, rather than general advice to "handle errors gracefully" that leaves the model to guess what each code means. This block fits any agent that uses the server:
When a call_endpoint result starts with "Error <code>:", act on the code:
- invalid_parameters or unknown_endpoint: fix the call using the message
(call get_endpoint or search_endpoints if you need to), then try once more.
- rate_limited or temporarily_unavailable: wait the seconds given after
"Retry after", then repeat the same call. Give up after two waits.
- source_error or timeout: try once more; if it fails again, say the source
is not answering right now.
- not_found: there is no data for those parameters. Don't repeat the call.
Change the query if that still answers the user's question; otherwise say so.
- insufficient_credits, restricted_endpoint, invalid_api_key: stop. Tell the
user exactly what the message says they need to do. Don't call other tools
to work around it.
Never repeat an identical failed call more than once.
The last line matters most. It stops the loop where a model answers each failure by resending the same request, which costs no credits but burns turns and fills the context with error lines.
How do you enforce the rules in code?
Count failures per call in the client and refuse to forward a repeat past the limit. A prompt shapes what the model tries; your code decides what actually reaches the server. If you run the agent loop yourself, wrap the tool call: parse the code from the result text, track identical calls, and sleep for Retry after before passing a repeat through. The parsing is two regular expressions:
import json
import re
import time
ERROR = re.compile(r"^Error (\w+):")
RETRY = re.compile(r"Retry after (\d+) seconds")
failures = {}
def guarded_call(call_tool, endpoint, params, max_repeats=1):
key = endpoint + json.dumps(params, sort_keys=True)
if failures.get(key, 0) > max_repeats:
return "Error retry_limit: this exact call already failed. Change it or stop."
text = call_tool("call_endpoint", {"endpoint": endpoint, "params": params})
m = ERROR.match(text)
if not m:
failures.pop(key, None)
return text
failures[key] = failures.get(key, 0) + 1
wait = RETRY.search(text)
if m.group(1) in ("rate_limited", "temporarily_unavailable") and wait:
time.sleep(int(wait.group(1)))
return text
The error texts above come from the server's source code; we haven't run this guard against a live agent for this article. call_tool is whatever function your MCP client uses to call a tool and return its text. The guard sleeps through rate limits so the model's next attempt lands after the window, and it turns a third identical failure into a message the model can't misread. For a hard cap on credits per run as well, the guide to AI agent cost control adds a budget check to the same wrapper.
To see the error texts for yourself, add the server to your client using the AI agents (MCP) docs and send a call with a deliberately wrong parameter.
APIs in this post
Frequently asked questions
Do failed MCP tool calls cost credits?
No. Every error response from the API carries X-Credits-Used: 0, and nothing is debited from the balance. Only a successful call_endpoint spends the endpoint's credits, 1 for most endpoints. Retries after an error are free in credits, though each one still costs a model turn and context.
Should an agent retry not_found?
Not with the same parameters. not_found means the source had no data for exactly what was asked, so the identical call will return the same answer. The agent should change the query, such as a broader location or different wording, if that still serves the user, or report that nothing was found.
How long should the agent wait after rate_limited?
The number of seconds in the error text after "Retry after", which comes from the response's Retry-After header. Waiting less just produces another rate_limited. Free accounts get 1 request a second and paying accounts at least 5, counted per minute across all of an account's keys.
Why do some errors have no request ID?
unknown_endpoint and bad params shapes are caught by the MCP server before any request reaches the API, so there's no request to identify. Errors from the API itself, such as invalid_parameters from validation or rate_limited, include a request_id you can quote to support.
Can the agent fix insufficient_credits itself?
No, and it shouldn't try. The balance is prepaid and there's no automatic top-up, so the call fails until someone buys credits. The agent should stop and tell the user. Calling other endpoints won't help, because every paid call fails the same way until the balance is topped up on the pricing page.