Errors & error classes#
The qaqnuz API returns a fixed-shape result envelope for every call_tool. Failures never throw across the boundary and never leak internals — the only error text you receive is a sanitized message safe to log and display.
The error contract#
On the MCP wire, a failed call looks like:
{
"result": {
"content": [{ "type": "text", "text": "<sanitized_error>" }],
"isError": true
}
}The underlying result envelope has two error-relevant fields:
| Field | Meaning |
|---|---|
error_class | One of five categories (below). Tells you how to react. |
sanitized_error | A human-readable message guaranteed free of secrets, PII, raw provider errors, stack traces, and internal IDs. The only error text that crosses the boundary. |
A third field, retry_hint, is present only for the retryable categories (see below).
The five error classes#
error_class | Meaning | What to do |
|---|---|---|
validation | Your arguments failed the tool's input schema. | Fix the input. Do not retry the same call — it will fail again. |
permission | Policy/scope/entitlement denied the call, the tool is unreachable for your key, or the action needs approval. | Do not retry blindly. Check entitlement, scopes, and whether the tool requires approval. |
retryable | A transient condition (including rate-limit rejections). The same call may succeed later. | Back off and retry, honoring retry_hint when present. |
dependency | An upstream/provider the tool depends on failed. | Retry with backoff; if it persists, treat as an upstream outage. |
terminal | A permanent failure for this input; retrying the same call will not help. | Do not retry. Surface the failure. |
Auth failures look generic. Authentication problems (unknown / revoked / expired key, malformed header) all surface as the sameUnauthorized-style rejection with no distinguishing detail — deliberate enumeration-resistance. Likewise, an unreachable tool returns a genericpermissionmessage that does not reveal whether the tool exists, is disabled, is sensitive, or is merely out of scope.
retry_hint#
When error_class is retryable or dependency, the envelope may carry a retry_hint:
{
"retry_after_ms": 1000,
"max_attempts": 1,
"backoff": "fixed",
"jitter": 0.2
}retry_after_ms— recommended wait before the next attempt.max_attempts— a soft cap on attempts for this fault.backoff—fixedorexponential.jitter— optional jitter fraction[0,1)to apply to the wait (defaults to
0.2 when omitted).
retry_hint is advisory. For validation, permission, and terminal errors it is always absent — those are not retry situations.
Handling pattern#
Because the wire response gives you isError + a text message, a robust client classifies the outcome and reacts accordingly. The snippets below show a defensive pattern keyed on the message and isError.
TypeScript#
async function callWithRetry(
client: import("@modelcontextprotocol/sdk/client/index.js").Client,
name: string,
args: Record<string, unknown>,
maxAttempts = 3,
) {
let lastText = "";
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await client.callTool({ name, arguments: args });
if (!res.isError) return res;
lastText = res.content[0]?.text ?? "";
// Rate-limit / transient → back off and retry.
const isRateLimited = /rate limit/i.test(lastText);
if (isRateLimited && attempt < maxAttempts) {
const waitMs = 1000 * attempt; // honor retry_hint when you have it
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
// Approval / permission / validation → do not retry.
break;
}
throw new Error(`call_tool ${name} failed: ${lastText}`);
}Python#
import asyncio
async def call_with_retry(session, name, args, max_attempts=3):
last_text = ""
for attempt in range(1, max_attempts + 1):
result = await session.call_tool(name, arguments=args)
if not result.isError:
return result
last_text = result.content[0].text if result.content else ""
# Rate-limit / transient -> back off and retry.
if "rate limit" in last_text.lower() and attempt < max_attempts:
await asyncio.sleep(1.0 * attempt) # honor retry_hint when you have it
continue
# Approval / permission / validation -> do not retry.
break
raise RuntimeError(f"call_tool {name} failed: {last_text}")Do / don't#
- Do treat
sanitized_errortext as safe to log and show to your users. - Do branch retry behavior on the category: retry
retryable/dependency,
never retry validation / terminal, and re-check setup for permission.
- Don't parse
sanitized_errorfor internal detail — there is none by
design, and the wording may change.
- Don't assume an empty
list_toolsor a genericpermissionerror reveals
why — the surface is intentionally opaque about that.