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.

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
-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. 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 (-32004 is retired-reserved and never fires). Match on structuredContent.error.code. 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; the other three codes need a config fix first). Other ggui_* domain failures surface the same way with string codes like handshake_not_found; 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 === -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) — 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 (status && status >= 500) return "The server is temporarily unavailable.";
return "Something went wrong.";
}
// Generation failure is NOT a thrown error — it arrives as an
// isError tool RESULT. Read the canonical code off structuredContent:
function getRenderFailureMessage(result: {
isError?: boolean;
structuredContent?: { error?: { code: string; message: string } };
}): string | null {
if (result.isError !== true) return null;
switch (result.structuredContent?.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.";
}
}

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.