Error Handling
read as.mdggui 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'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 /hosted/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 |
-32007 |
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 |
Platform deployments also reserve the -32010 range: -32014 (generation overloaded — HTTP 503 with Retry-After) is the one live code; -32010 to -32013 and -32020 are retired-reserved and never emitted. 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. Generation failure is the flagship case: a failed ggui_render returns isError: true with schema-conformant structuredContent carrying error: { code, message } — the closed canonical enum PRODUCTION_FAILED | VALIDATION_ERROR | NO_PLATFORM_KEY | NO_CREDENTIALS | GENERATION_QUEUE_OVERLOADED (-32004 is retired-reserved and never fires). Branch on structuredContent.outcome before you read any code. Two different outcomes arrive as isError: true, and they carry their classification on different fields: a failed render carries error.code, while a refused one carries refusal.code and no error at all. Matching on structuredContent.error.code alone silently misses every refusal and throws away the fix and retry that make it actionable.
A failed render has no resourceUri and no _meta — do not mount it; the handshake is consumed, so recovery is a fresh ggui_handshake (for PRODUCTION_FAILED, retrying with a simpler intent is reasonable; GENERATION_QUEUE_OVERLOADED means generation never ran at all, so it’s safe to retry immediately; the remaining three codes need a config fix first).
A refused render is the deployment declining before it did any work. Its structuredContent is exactly { outcome: "refused", refusal } — no sessionId, no cache, no error. Nothing was committed and the handshake is intact, so the same handshakeId still works once the fix lands. refusal.fix names the one recovery step and refusal.retry is one of after-fix | next-period | later | never. Only retry automatically when the registry row for refusal.code names fixBy: "caller" — otherwise the fix belongs to the app’s owner or the operator, and retrying does not perform it. Surface refusal.fix instead. See Refused (pre-generation).
Other ggui_* domain failures surface the same way — an isError result whose content[0].text leads with the registered code, <code>: <detail> (handshake_not_found: handshakeId "…" not found. …, session_not_found: GguiSession "…" not found. …), with no structuredContent and no _meta. Branch on text.startsWith(code + ": "), or parse it with parseDomainErrorText(text) from @ggui-ai/protocol. The numeric -32xxx table above covers only pre-execution protocol errors.
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 === -32007 || 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) — 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, and the result’s text leads with the code:handshake_not_found: handshakeId "<id>" not found. …. (A render consumes its handshake — handshakes aren’t bound to renders.) A missinghandshakeIdnever gets this far: it is refused at input validation with-32602, and the message sayshandshakeId is REQUIRED.session_not_found(fromggui_consume/ggui_update/ggui_amend/ggui_emit/ggui_get_session) — thesessionIdwas never minted, expired via TTL, was closed, or belongs to another app; the result’s text leads with the code:session_not_found: GguiSession "<id>" not found. ….
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 && errorText(result).startsWith("handshake_not_found: ")) { 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 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 === -32007) return "Authentication failed — contact support."; if (status === 429) return "Too many requests — please slow down."; if (code === -32002) return "Your session expired."; if (status && status >= 500) return "The server is temporarily unavailable."; return "Something went wrong.";}
// Neither a generation failure nor a refusal is a thrown error — both// arrive as an isError tool RESULT, and they are DIFFERENT outcomes.// Branch on `outcome` first: `failed` classifies on `error.code`,// `refused` on `refusal.code` and carries its own recovery step.import { isFailedRenderOutput, isRefusedRenderOutput, type GguiRenderOutput,} from "@ggui-ai/protocol";
function getRenderProblemMessage(result: { isError?: boolean; structuredContent?: GguiRenderOutput;}): string | null { if (result.isError !== true) return null; const sc = result.structuredContent; if (sc === undefined) return "Something went wrong generating the UI.";
// Declined BEFORE any work: nothing was committed and the handshake // is still valid. `fix` is the one recovery step — show it rather // than a generic message, and only retry the same handshakeId when // the registry says the caller is the party who can act. if (isRefusedRenderOutput(sc)) { return sc.refusal.retry === "never" ? sc.refusal.message : `${sc.refusal.message} ${sc.refusal.fix}`; }
// Generation RAN and produced nothing. The handshake is consumed, so // recovery starts at a fresh ggui_handshake. if (isFailedRenderOutput(sc)) { switch (sc.error.code) { case "PRODUCTION_FAILED": return "We couldn't generate that UI — try a simpler request."; case "NO_CREDENTIALS": case "NO_PLATFORM_KEY": return "No LLM credentials are configured for this app."; case "VALIDATION_ERROR": return "This app's key configuration needs attention."; default: return "Something went wrong generating the UI."; } }
return "Something went wrong generating the UI.";}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. |
channel-transport-* |
A streamSpec channel picked, fell back to, or resubscribed on a transport. |
The ObservabilityEvent union + its member types are re-exported from @ggui-ai/mcp-apps-react (import type { ObservabilityEvent } from "@ggui-ai/mcp-apps-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; the live channel’sCONTRACT_VIOLATIONframe code is a string, not a number/hosted/rate-limits/— HTTP429response shape,Retry-Aftersemantics, raw-HTTP backoff recipe@ggui-ai/mcp-apps-reactSDK reference —<AppRenderer>+useMcpAppsChat, the web render host- Bootstrap handshake — the
ggui:observechannel +ObservabilityEventcatalog - Troubleshooting — common errors and their root causes