Skip to content

Error Handling

read as .md

ggui surfaces failures at three layers. Handle each at its layer:

  1. HTTP — the MCP transport returns 401 / 403 / 429 / 5xx before the JSON-RPC body is even parsed.
  2. JSON-RPC — a tools/call reaches the server but the server returns a -32xxx error code (or a tool-level failure with isError: true).
  3. Live channel — a failure after a successful render. Action-validation rejections ride the live-channel WebSocket as typed error frames carrying code: 'CONTRACT_VIOLATION' (numeric -32020) and surface in the renderer, not the agent; nothing lands on the consume buffer.

Layer 1 — HTTP errors from the MCP transport

Section titled “Layer 1 — HTTP errors from the MCP transport”

These come back as IsHttpError / response status from whichever HTTP client your MCP SDK uses. Treat them as transport failures — the server hasn’t even looked at your JSON-RPC payload yet.

StatusMeaningRetry?
401Bad / expired API keyNo — fix config
403Key valid but app not authorizedNo — fix config
429Rate-limited (Retry-After header)Yes, after Retry-After seconds
5xxTransient server failureYes, with exponential backoff
network errorDNS / connection / TLS failureYes, with exponential backoff

The Retry-After header (when present) is authoritative — honor it verbatim. See /api/rate-limits/ for the 429 response shape (body + header) and a raw-HTTP backoff recipe.

Layer 2 — JSON-RPC errors from the server

Section titled “Layer 2 — JSON-RPC errors from the server”

A 200 HTTP response can still carry a JSON-RPC error. The MCP SDKs surface these as thrown errors with a numeric code; raw HTTP callers see { "error": { "code": -32xxx, "message": "..." } } in the response body.

CodeNameWhenRetry?
-32700Parse ErrorInvalid JSON in requestNo — fix the call
-32600Invalid RequestNot a valid JSON-RPC objectNo — fix the call
-32601Method Not FoundUnknown tool nameNo — fix the call
-32602Invalid ParamsMissing / invalid tool argumentsNo — fix the call
-32603Internal ErrorServer-side failureYes, with backoff
-32001UnauthorizedInvalid token or app IDNo — fix config
-32002Session Not FoundSession expired or reapedRe-handshake + render
-32003App Not FoundApp ID does not existNo — fix config
-32004Production FailedUI generation failedYes — try simpler intent
-32005Capability DeniedRequested capability not allowedNo — fix config
-32013Rate Limit ExceededPlatform rate limit hitYes, after backoff

Platform deployments also reserve the -32010 range (-32010 generation quota, -32011 app limit, -32012 concurrent-session limit, -32020 contract violation). Full table with descriptions: /api/mcp-protocol/#error-codes.

Tool-level failures (the tool ran but returned an error result) come back as a successful JSON-RPC response with isError: true on the tools/call result content. Inspect result.content for the failure detail. On the OSS server the ggui_* tools surface their domain failures this way — isError results whose typed error classes carry string codes like handshake_not_found and session_not_found — while the numeric -32xxx table above is the protocol-level canonical set.

Retry with exponential backoff (raw @modelcontextprotocol/sdk)

Section titled “Retry with exponential backoff (raw @modelcontextprotocol/sdk)”

The SDK throws on HTTP failures and on JSON-RPC errors alike; you classify by error.code (JSON-RPC) or by reading the HTTP status off the underlying response. The pattern below works for both layers.

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const client = new Client({ name: "my-agent", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL("http://127.0.0.1:6781/mcp"), {
requestInit: { headers: { Authorization: "Bearer dev" } },
});
await client.connect(transport);
async function callWithRetry<T>(
name: string,
args: Record<string, unknown>,
maxRetries = 3
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await client.callTool({ name, arguments: args });
if (result.isError) {
// Tool-level failure — payload is in result.content.
throw new Error(`Tool ${name} returned isError: ${JSON.stringify(result.content)}`);
}
return result.structuredContent as T;
} catch (error) {
if (attempt === maxRetries) throw error;
// JSON-RPC error: error.code is a -32xxx number.
const code = (error as { code?: number }).code;
// Permanent — surface immediately.
if (code === -32001 || code === -32003) throw error; // auth / app config
if (code === -32600 || code === -32601 || code === -32602) throw error; // bad request
// Render expired — caller replays at the render layer (see below).
if (code === -32002) throw error;
// Rate-limited (HTTP 429) — honor Retry-After if the SDK surfaces it.
const retryAfter = (error as { retryAfter?: number }).retryAfter;
if (retryAfter != null) {
await sleep(retryAfter * 1000);
continue;
}
// Transient (5xx, network, -32603, -32004) — exponential backoff.
await sleep(Math.min(1000 * 2 ** attempt, 10_000));
}
}
throw new Error("unreachable");
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

-32002 Session Not Found is the protocol-level canonical code for “the session is gone”. On the OSS server, though, expiry never surfaces as a numeric JSON-RPC error from the ggui_* tools — the call succeeds at the JSON-RPC layer and returns isError: true with the failure detail in the result content:

  • handshake_not_found (from ggui_render) — handshake records are single-use and TTL’d (10 minutes); the supplied handshakeId was unknown, already consumed, or expired. (A render consumes its handshake — handshakes aren’t bound to renders.)
  • session_not_found (from ggui_consume / ggui_update / ggui_emit / ggui_get_session) — the sessionId was never minted, expired via TTL, or belongs to another app.

Recovery is the same in every case: re-run ggui_handshakeggui_render, which mints a fresh sessionId. (Other isError results from ggui_render — contract violations, schema mismatches — leave the handshake alive: fix the arguments and retry on the same handshakeId.)

function errorText(result: { isError?: boolean; content?: unknown }): string {
const blocks = (result.content ?? []) as Array<{ type: string; text?: string }>;
return blocks
.filter((b) => b.type === "text")
.map((b) => b.text)
.join(" ");
}
async function resilientRender(intent: string, contract: object, props: Record<string, unknown>) {
const mintHandshake = async () => {
const hs = await client.callTool({
name: "ggui_handshake",
arguments: { intent, blueprintDraft: { contract } },
});
return (hs.structuredContent as { handshakeId: string }).handshakeId;
};
// Negotiate, then render. `ggui_render` takes { handshakeId, props } —
// accept the suggestion as-is (no `override`).
let result = await client.callTool({
name: "ggui_render",
arguments: { handshakeId: await mintHandshake(), props },
});
// Expired / already-consumed handshake → mint a fresh
// handshake → render pair (a fresh sessionId comes with it).
if (result.isError && /handshakeId .* not found/i.test(errorText(result))) {
result = await client.callTool({
name: "ggui_render",
arguments: { handshakeId: await mintHandshake(), props },
});
}
return result;
}

Layer 3 — typed failures on the live channel

Section titled “Layer 3 — typed failures on the live channel”

The transport- and JSON-RPC-level errors above fire when the request fails. A separate layer surfaces after a successful render: when the renderer dispatches an action that fails validation (undeclared action name, payload rejected by the declared actionSpec[name].schema), the server answers with a typed error frame carrying code: 'CONTRACT_VIOLATION' — and nothing lands on the consume buffer.

These ride the live-channel WebSocket alongside the renderer — not the agent-side MCP poll — so there is no JSON-RPC error to catch on the agent. The renderer observes them and surfaces an error activity row.

Earlier protocol drafts reserved a _ggui:contract-error channel carrying a ContractErrorPayload envelope, but it never gained a first-party emitter and was removed in draft-2026-06-11 — the channel, payload shape, validator, and code union are all deleted. Contract failures now surface on the call that caused them: inbound action violations answer with a CONTRACT_VIOLATION (-32020) error frame on the live channel, and nothing reaches the consume buffer; ggui_render / ggui_emit validation failures reject the agent’s own tool call; push-time schema mismatches reject with SCHEMA_MISMATCH_ERROR. The reserved _ggui: namespace itself survives — the only first-party reserved channels today are _ggui:lifecycle and _ggui:preview — and your streamSpec MUST NOT declare reserved names.

Keep user-visible copy at one layer; never leak stack traces or JSON-RPC codes to end users.

function getUserMessage(error: unknown): string {
const code = (error as { code?: number }).code;
const status = (error as { status?: number }).status;
if (status === 401 || code === -32001) return "Authentication failed — contact support.";
if (status === 429) return "Too many requests — please slow down.";
if (code === -32002) return "Your session expired.";
if (code === -32004) return "We couldn't generate that UI — try a simpler request.";
if (status && status >= 500) return "The server is temporarily unavailable.";
return "Something went wrong.";
}

Renderer-side: faults stay inside the iframe

Section titled “Renderer-side: faults stay inside the iframe”

The canonical web host mounts each render in a sandboxed iframe via <AppRenderer> (from @mcp-ui/client, driven by useMcpAppsChat — see React SDK). That sandbox is the fault boundary: an error thrown by LLM-generated component code is contained to its own iframe and cannot crash your host React tree. You don’t wrap renders in a host-side error boundary — the origin isolation does that structurally.

Two host-observable failure surfaces matter.

Transport faults — <AppRenderer onError>

Section titled “Transport faults — <AppRenderer onError>”

<AppRenderer>’s own onError fires for iframe/transport-level failures (the sandbox bundle failed to load, the runtime failed to boot). It receives a plain Error. Log it or show a placeholder in the frame:

<AppRenderer
toolName="ggui_render"
sandbox={sandbox}
html={inlinedHtml}
onReadResource={onReadResource}
onCallTool={onCallTool}
onMessage={handleAppMessage}
onError={(err) => console.warn("[render] AppRenderer error", err)}
/>

(This is exactly what the ggui-basic-web sample does.)

Structured failures — the ggui:observe channel

Section titled “Structured failures — the ggui:observe channel”

After a successful mount, ggui’s iframe-runtime emits a typed ObservabilityEvent to the parent on a dedicated postMessage channel — { type: "ggui:observe", event }. This is where runtime health signals surface, so a host can show an activity row without parsing wire frames. The union is extensibly-closed — match the kinds you know, treat the unknown tail as generic:

event.kindWhen
subscribe-failedA non-fatal subscribe failure the reconnect ladder is handling.
schema-version-mismatchThe protocol-version handshake rejected the connection.
auth-requiredA tool the agent calls needs OAuth consent — carries provider + authUrl. See Auth-Gated UI.

The ObservabilityEvent union + its member types are re-exported from @ggui-ai/react (import type { ObservabilityEvent } from "@ggui-ai/react"). The { type: "ggui:observe", event } envelope is specified in Bootstrap handshake.