Troubleshooting
read as.mdA symptom-first index. Skim until you find the error string you’re seeing, then jump to the linked deep-dive. For typed handling of every protocol error class in code, the Error Handling cookbook is the canonical reference — this page is the lookup table. The authoritative numeric error table lives in MCP Protocol → Error Codes.
Is the MCP server reachable?
Section titled “Is the MCP server reachable?”GGUI speaks plain MCP. Connect with any MCP-compliant client. The minimum smoke-test using the official TypeScript SDK:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const transport = new StreamableHTTPClientTransport(new URL("http://127.0.0.1:6781/mcp"), { requestInit: { headers: { Authorization: "Bearer dev" } },});
const client = new Client({ name: "ggui-smoke", version: "0.0.0" });await client.connect(transport);const { tools } = await client.listTools();console.log( "ok", tools.map((t) => t.name));If connect() rejects, the transport never produced a successful HTTP response — diagnose with the HTTP status table below. If connect() succeeds but listTools() rejects, you have a JSON-RPC error — see JSON-RPC error codes.
The snippet assumes a local ggui serve --dev-allow-all, where any non-empty bearer (e.g. Bearer dev) works; the strict default requires a pair-minted bearer. Hosted cloud: use https://mcp.ggui.ai/apps/<appId> — no /mcp suffix — with a ggui_user_* connector key.
Authentication
Section titled “Authentication”401 Unauthorized from the MCP transport
Section titled “401 Unauthorized from the MCP transport”- Connector keys start with
ggui_user_— verify the prefix and that no characters were lost to a copy-paste. - Hosted GGUI keys (cloud) are environment-scoped. A
sandboxkey will not work against the production project and vice versa. - On the hosted cloud, rotate the key in console.ggui.ai (apps → keys) if you suspect leakage.
- Self-hosted
ggui servedefaults to strict pairing-based auth — a401means your bearer wasn’t pair-minted; use the pairing flow or--dev-allow-allon localhost. If you wrapped it behind your own auth, double-check the header your reverse proxy expects.
→ Recovery patterns: Error Handling cookbook.
403 Forbidden (capability denied)
Section titled “403 Forbidden (capability denied)”The key authenticated but is not authorised for this app or method. It surfaces as a bare 403 whose JSON-RPC body carries -32007 — see the HTTP table; -32005 never had a first-party emitter and is retired-reserved (GGUI#910).
Connection
Section titled “Connection”WebSocket connection stuck on 'reconnecting'
Section titled “WebSocket connection stuck on 'reconnecting'”The render runs in a sandboxed iframe (<AppRenderer>); the iframe-runtime owns the live-channel WebSocket and reconnects with exponential backoff (1 s → 30 s). After the retry budget exhausts it latches at 'disconnected'. To resume:
- Remount the
<AppRenderer>— a fresh boot re-runs the bootstrap and opens a new socket. - Inspect your network path. Many corporate proxies strip the
Upgrade: websocketheader silently; the symptom is upgrade requests that never return101 Switching Protocolsin the network tab.
The wsUrl the iframe connects to is server-stamped on the render’s ai.ggui/render slice (ws:// on the local ggui serve default; wss:// once TLS fronts it) — you don’t configure it host-side. Wire format: WebSocket Protocol.
Actions feel “dropped” during a flaky connection
Section titled “Actions feel “dropped” during a flaky connection”The iframe-runtime buffers outbound actions while the socket is reconnecting and flushes them on resume — actions aren’t lost across a transient drop. A generated component can disable destructive submits while offline, but that’s component behavior inside the iframe, not host wiring.
→ The host surfaces connection trouble via the ggui:observe channel (subscribe-failed). See Error Handling → renderer-side faults.
CONTRACT_VIOLATION error frame on the live channel
Section titled “CONTRACT_VIOLATION error frame on the live channel”An inbound action whose name is undeclared, or whose payload fails the contract’s actionSpec[name].schema, is rejected with a typed CONTRACT_VIOLATION error frame on the live channel — nothing reaches the consume buffer. ggui_render / ggui_emit validation failures instead reject the agent’s own tool call. (The earlier _ggui:contract-error channel + ContractErrorPayload shape were removed in draft-2026-06-11.) See MCP Protocol → Error Codes.
Renders
Section titled “Renders”Session not found (-32002)
Section titled “Session not found (-32002)”A render has been reaped (TTL elapsed, or server restart). Re-run the in-flight intent through ggui_handshake + ggui_render; ggui_consume returns whatever events were collected before the render expired (status: 'expired').
→ Lifecycle: ggui_handshake / ggui_render. Recovery pattern: Error Handling → Recover from an expired render.
Cross-environment render mismatch
Section titled “Cross-environment render mismatch”Sandbox and production renders live on separate appIds. If you’re moving between environments, regenerate the appId + key pair — renders never migrate.
Component rendering
Section titled “Component rendering”Module does not export a default function component
Section titled “Module does not export a default function component”The generated bundle is malformed. Causes, in order of likelihood:
- A partial or failed generation was served (the server’s generation pipeline normally repairs these before delivery).
- A custom
gadgetreturned a renderer that does not exportdefault. - Generation logs (visible in the Console or your operator dashboard) show an esbuild error.
Component shows the fallback border
Section titled “Component shows the fallback border”A fault in generated component code is isolated to its sandboxed iframe — it can’t crash your host tree. The host’s <AppRenderer onError> fires for iframe/transport faults; structured failures (contract errors, subscribe failures) arrive on the ggui:observe channel.
<AppRenderer toolName="ggui_render" sandbox={sandbox} html={html} onError={(err) => console.warn("render error", err)}/>Regeneration of broken component code happens server-side in the generation pipeline; the iframe re-mounts with corrected HTML on the next resources/read. See Error Handling → renderer-side faults.
Renderer still shows old code after I redeployed
Section titled “Renderer still shows old code after I redeployed”The browser-side module cache keys on contractHash. A redeploy that does not bump the hash will not invalidate the cached factory. Either:
- Trigger a new generation by changing the agent prompt or the
actionSpec, which advances the hash; or - Hard-reload the iframe (
<AppRenderer>re-mount).
Gadgets and tools
Section titled “Gadgets and tools”[gadget] export "X" from package "Y" is not loaded
Section titled “[gadget] export "X" from package "Y" is not loaded”The generated component called a gadget export that the operator never registered on App.gadgets. Add the package (or the specific export) to ggui.json#app.gadgets — see SDK → Gadgets → Operator registration.
gadget_not_registered / gadget_package_mismatch / gadget_public_env_missing
Section titled “gadget_not_registered / gadget_package_mismatch / gadget_public_env_missing”Render-time validation rejections, not runtime errors: gadget_not_registered means the contract references an export the operator hasn’t registered; gadget_package_mismatch means the referenced export belongs to a different registered package than the one keyed; gadget_public_env_missing means a wrapper’s requires aren’t satisfied by App.publicEnv. All three name the offending key so the operator fix is unambiguous — see SDK → Gadgets.
HTTP status codes
Section titled “HTTP status codes”The MCP transport surfaces transport-level failures as HTTP status codes on the /mcp endpoint. Read the status first. A refusal may also carry a JSON-RPC error body whose code comes from the table below — every 401 carries -32007, and on hosted GGUI a 503 for a saturated generation queue carries -32014 and a 403 for a deprovisioned app carries -32003 with a data.refusal you can act on — so either signal identifies it. An unmapped server failure is a 500 whose body carries -32603.
| Status | Meaning |
|---|---|
401 |
Missing or invalid Authorization header — see Authentication |
403 |
Not authorised for this appId or method. A bare 403 carries -32007 and says no more by design; a deprovisioned app answers 403 with -32003 and data.refusal — see JSON-RPC error codes |
404 |
Unknown route, or appId does not exist on this environment |
429 |
Rate limited at the ingress — hosted GGUI’s per-IP flood backstop, plain HTTP with no JSON-RPC body; honor Retry-After (seconds). A render-rate cap never arrives this way: it is an in-band refusal — see Rate limits |
5xx |
Server-side failure. A 503 with Retry-After (body -32014) is hosted GGUI’s saturated generation queue — wait that long; otherwise retry with jitter, then file a support ticket with the response x-request-id |
A render-rate cap never surfaces as HTTP at all. On tools/call a limited ggui_render resolves as a refusal — isError: true, structuredContent.outcome: 'refused', refusal.code app_rate_limited (or issuer_rate_limited for the issuing identity’s cap), retry: 'later', the wait in message and fix, handshake intact — so wait and retry the same call with the same handshakeId; see Rate limits. The only 429 hosted GGUI sends is the ingress’s per-IP backstop, plain HTTP with Retry-After and no JSON-RPC body; no first-party server emits -32013. Honor Retry-After wherever the server sends one — hosted GGUI’s 503 for a saturated generation queue always does, and its body carries -32014 GENERATION_OVERLOADED.
JSON-RPC error codes
Section titled “JSON-RPC error codes”Successful HTTP responses (200) may still carry a JSON-RPC error object with a numeric code. A server may add a data field with a requestId you can quote when filing support. Authoritative reference: MCP Protocol → Error Codes.
| Code | Name | Meaning |
|---|---|---|
-32700 |
Parse error | Malformed JSON in the request body |
-32600 |
Invalid request | Required JSON-RPC fields missing (jsonrpc, method) |
-32601 |
Method not found | Unknown method or tool name |
-32602 |
Invalid params | Wrong types or missing arguments |
-32603 |
Internal error | Server-side failure |
-32007 |
Unauthorized | Missing, invalid or expired bearer, or a handler that needs a signed-in user — rides HTTP 401; the same code is in the response’s JSON-RPC body |
-32002 |
Session not found | Session expired or never existed |
-32003 |
App not found | The endpoint no longer serves this app; for a deprovisioned app the body carries data.refusal |
-32006 |
Mount unavailable | A resources/read found the render locator but can return no mount for it |
-32014 |
Generation overloaded | Platform extension: the generation queue is saturated — HTTP 503 with Retry-After governs the retry; the server, not the caller, is the limit |
-32004 is retired-reserved. Generation failure never surfaces as a
JSON-RPC error: ggui_render returns a tools/call result with
isError: true and structuredContent.error: { code, message } — codes
PRODUCTION_FAILED | VALIDATION_ERROR | NO_PLATFORM_KEY | NO_CREDENTIALS | GENERATION_QUEUE_OVERLOADED. Match on error.code, not on a numeric JSON-RPC code.
A failed render carries no resourceUri and no _meta — do not attempt
to mount it.
-32006 answers a resources/read of a render locator that cannot come
back as a mount. Match on error.data.code for the reason —
NOT_FOUND (nothing resolves it, or you may not read it),
BLUEPRINT_UNRESOLVABLE (the component behind it is gone),
NOT_SUPPORTED (this deployment keeps no durable record, so an evicted
render can never be restored here) or NOT_MOUNTABLE (it resolved, but
nothing can deliver it). NOT_FOUND arrives on -32002 rather than
-32006. None of the four is worth retrying — ask the agent for a fresh
render instead.
-32003 on a 403 is the endpoint-level refusal: the app the per-app endpoint serves has been deprovisioned, and every JSON-RPC request from a caller bound to that app — initialize included — is refused before any tool runs. The body carries data.refusal { code, message, fix, retry, appId }: code is app_deprovisioned, retry is never, fix is the tenant-side repair (re-provision the app), and appId is the app the refused endpoint serves — the same id as the endpoint path — so key your repair loop on it instead of parsing the message. Retry per data.refusal.retry — today never: nothing on the caller’s side changes the answer. It is the app’s state, not yours: a revoked subject on a deprovisioned app gets this answer too, before its own deny status is read, and learns only an id it already holds. An anonymous request never sees it — it is refused 401 by the auth adapter first — and a caller bound to a different app gets the bare 403 before any lookup. A 403 with -32007 and no data is a different thing — an authorization failure the server declines to explain (no issuer, a foreign issuer, a denied subject on a live app, a key bound elsewhere or not the owner’s); check the credential and the appId in the URL.
Reading errors from Claude Agent SDK
Section titled “Reading errors from Claude Agent SDK”When you drive GGUI from the Claude Agent SDK, JSON-RPC errors arrive inside the SDKMessage stream rather than as thrown exceptions — tool results land inside user messages as tool_result content blocks. Walk the stream and match on is_error:
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({ prompt, options })) { if (message.type === "user") { for (const block of message.message.content) { if (block.type === "tool_result" && block.is_error) { // block.content holds the JSON-RPC error.message string console.error("tool failed:", block.tool_use_id, block.content); } } }}Host SDKs (@anthropic-ai/claude-agent-sdk, @modelcontextprotocol/sdk, openai) each define their own error types — consult their docs for the thrown shape. GGUI guarantees the wire error (HTTP status + JSON-RPC code), not the host-SDK’s exception class.
Generation timeout
Section titled “Generation timeout”Bump your transport’s timeout or shorten the agent prompt. Most timeouts are constraint-misalignment in the prompt, not raw slowness. With the @modelcontextprotocol/sdk client:
await client.callTool({ name: "ggui_handshake", arguments }, undefined, { timeout: 60_000, // 60 s});If you control the operator side, check Blueprint-First Architecture — a matched blueprint short-circuits generation entirely.
First render feels slow (no error, just wait)
Section titled “First render feels slow (no error, just wait)”This is the blueprint-cache miss path, not a bug. The two-stage flow is: a cheap LLM match against cached blueprints → full generation if no match (cold renders typically land in 10–20 s). Symptoms:
- A cache hit surfaces as
suggestion.origin === 'cache'on the handshake; the pairedggui_renderthen returnscache: {hit: true, llmCallsAvoided, ...}. A miss showsorigin === 'agent'/'synth'andcache.hit: false. - Subsequent calls with the same
actionSpecshould hit cache (cache.hit: true) and avoid regeneration cost.
If repeated identical prompts never warm the cache, the prompt or actionSpec is varying between calls in a way that advances contractHash. Stabilise the inputs or pre-register a blueprint against the operator endpoint. See Blueprint-First Architecture.
TypeScript build
Section titled “TypeScript build”tsc under moduleResolution: NodeNext reports errors inside node_modules/@modelcontextprotocol/ext-apps
Section titled “tsc under moduleResolution: NodeNext reports errors inside node_modules/@modelcontextprotocol/ext-apps”Your own code is fine; the errors sit in a dependency’s declarations. With moduleResolution: "NodeNext" (or Node16), tsc reports TS2834 and TS2339 inside node_modules/@modelcontextprotocol/ext-apps/dist/src/app.d.ts, and TS2305 on @ggui-ai/protocol/dist/types/host-context.d.ts for McpUiHostContext, McpUiHostCapabilities and McpUiDisplayMode.
Cause. @modelcontextprotocol/ext-apps 1.7.5 ships extensionless relative specifiers (./types, ./events, ./standard-schema) in its own .d.ts files — a defect in the published package, tracked upstream at modelcontextprotocol/ext-apps#704. @ggui-ai/protocol re-exports the MCP Apps spec types from that package rather than copying them (the spec is a frozen external surface, and a copy would drift the moment it moves), so the three host-context names cascade from the same defect.
Fix. Until ext-apps ships the upstream fix, set the TypeScript switch for third-party declaration defects in the consumer’s tsconfig.json:
{ "compilerOptions": { "skipLibCheck": true }}skipLibCheck skips type-checking of .d.ts files only — your own sources still typecheck fully. The @ggui-ai/protocol dependency bump follows the day the upstream fix is published; nothing in your code changes then except removing the switch if you want it gone.
Debugging tips
Section titled “Debugging tips”- Network tab — inspect the WebSocket frames at
/ws(self-hosted:ws://127.0.0.1:6781/ws; hosted cloud:wss://mcp.ggui.ai/ws) and the JSON-RPC bodies on/mcp. - MCP Apps host postMessage — for non-WebSocket hosts (Claude Desktop, generic MCP clients), props updates arrive as a
ui/notifications/tool-resultpostMessage carrying the sameai.ggui/renderslice (propsJson) the WS path projects — one projection, two envelopes. - Console app —
@ggui-ai/consolegives you a live session inspector with raw envelopes, generation logs, and a contract diff viewer. - Conformance kit — if you’re building your own client or server, run
ggui conformanceagainst your endpoint; protocol-level mismatches show up as named violations, not stack traces. See Conformance.