Error Handling
read as.md ggui surfaces failures at three layers. Handle each at its layer:
- HTTP — the MCP transport returns
401/403/429/5xxbefore the JSON-RPC body is even parsed. - JSON-RPC — a
tools/callreaches the server but the server returns a-32xxxerror code (or a tool-level failure withisError: true). - Live channel — a failure after a successful render. Action-validation rejections ride the live-channel WebSocket as typed
errorframes carryingcode: '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.
| Status | Meaning | Retry? |
|---|---|---|
401 | Bad / expired API key | No — fix config |
403 | Key valid but app not authorized | No — fix config |
429 | Rate-limited (Retry-After header) | Yes, after Retry-After seconds |
5xx | Transient server failure | Yes, with exponential backoff |
| network error | DNS / connection / TLS failure | Yes, 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.
| Code | Name | When | Retry? |
|---|---|---|---|
-32700 | Parse Error | Invalid JSON in request | No — fix the call |
-32600 | Invalid Request | Not a valid JSON-RPC object | No — fix the call |
-32601 | Method Not Found | Unknown tool name | No — fix the call |
-32602 | Invalid Params | Missing / invalid tool arguments | No — fix the call |
-32603 | Internal Error | Server-side failure | Yes, with backoff |
-32001 | Unauthorized | Invalid token or app ID | No — fix config |
-32002 | Session Not Found | Session expired or reaped | Re-handshake + render |
-32003 | App Not Found | App ID does not exist | No — fix config |
-32004 | Production Failed | UI generation failed | Yes — try simpler intent |
-32005 | Capability Denied | Requested capability not allowed | No — fix config |
-32013 | Rate Limit Exceeded | Platform rate limit hit | Yes, 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));Recover from an expired render (-32002)
Section titled “Recover from an expired render (-32002)”-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(fromggui_render) — handshake records are single-use and TTL’d (10 minutes); the suppliedhandshakeIdwas unknown, already consumed, or expired. (A render consumes its handshake — handshakes aren’t bound to renders.)session_not_found(fromggui_consume/ggui_update/ggui_emit/ggui_get_session) — thesessionIdwas never minted, expired via TTL, or belongs to another app.
Recovery is the same in every case: re-run ggui_handshake → ggui_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.
Translate errors to user-facing messages
Section titled “Translate errors to user-facing messages”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.kind | When |
|---|---|
subscribe-failed | A non-fatal subscribe failure the reconnect ladder is handling. |
schema-version-mismatch | The protocol-version handshake rejected the connection. |
auth-required | A 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.
See also
Section titled “See also”/api/mcp-protocol/#error-codes— full JSON-RPC error-code table, plusCONTRACT_VIOLATION(-32020)/api/rate-limits/— HTTP429response shape,Retry-Aftersemantics, raw-HTTP backoff recipe@ggui-ai/reactSDK reference —<AppRenderer>+useMcpAppsChat, the web render host- Bootstrap handshake — the
ggui:observechannel +ObservabilityEventcatalog - Troubleshooting — common errors and their root causes