qaqnuz MCP public API reference

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:

json
{
  "result": {
    "content": [{ "type": "text", "text": "<sanitized_error>" }],
    "isError": true
  }
}

The underlying result envelope has two error-relevant fields:

FieldMeaning
error_classOne of five categories (below). Tells you how to react.
sanitized_errorA 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_classMeaningWhat to do
validationYour arguments failed the tool's input schema.Fix the input. Do not retry the same call — it will fail again.
permissionPolicy/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.
retryableA transient condition (including rate-limit rejections). The same call may succeed later.Back off and retry, honoring retry_hint when present.
dependencyAn upstream/provider the tool depends on failed.Retry with backoff; if it persists, treat as an upstream outage.
terminalA 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 same Unauthorized-style rejection with no distinguishing detail — deliberate enumeration-resistance. Likewise, an unreachable tool returns a generic permission message 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:

json
{
  "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.
  • backofffixed or exponential.
  • 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#

ts
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#

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_error text 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_error for internal detail — there is none by

design, and the wording may change.

  • Don't assume an empty list_tools or a generic permission error reveals

why — the surface is intentionally opaque about that.