# MCP Apps Iframe Sandbox & CSP Research Report **Date:** 2026-02-03 **Context:** MCP App UI (single-file HTML with inlined React) renders visually in Goose but interactive JS functionality fails silently. --- ## Executive Verdict **Iframe sandboxing is NOT the likely cause of our interactivity loss.** The MCP Apps spec and all hosts (including Goose) use `sandbox="allow-scripts"` at minimum, which permits JavaScript execution including React event handlers. The root cause is more likely in the **postMessage communication layer** or **App SDK initialization**, not in sandbox/CSP restrictions. --- ## Question 1: How do MCP hosts sandbox the iframe? The MCP Apps specification mandates a **double-iframe architecture**: ``` Host (parent window) ↔ Sandbox (outer iframe) ↔ View (inner iframe) ``` ### Reference Host (basic-host) sandbox attributes: **Outer iframe** (set by host in `implementation.ts`): ```typescript iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); ``` **Inner iframe** (set by sandbox proxy in `sandbox.ts`): ```typescript inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); ``` ### Goose (MCP-UI era, pre-MCP Apps): - **Raw HTML (srcdoc):** `sandbox="allow-scripts"` only - **External URL:** `sandbox="allow-scripts allow-same-origin"` - This came from `@mcp-ui/client` library defaults ### Goose (MCP Apps era): Goose is transitioning to MCP Apps spec support (PR #6039). The new implementation follows the double-iframe proxy pattern from the spec. ### Spec Requirements (SEP-1865, §Sandbox proxy): > "The Sandbox MUST have the following permissions: `allow-scripts`, `allow-same-origin`." --- ## Question 2: Could missing `allow-scripts` prevent JS execution? **No — this is NOT our issue.** Both the spec and all implementations include `allow-scripts`. Without it: - No JavaScript would execute at all - The page would be completely static — no React rendering, no DOM manipulation - We would see **nothing** interactive, not even initial rendering **Since our React components DO render** (visual output appears), `allow-scripts` IS present. The JS engine is running — the problem is elsewhere. ### What `sandbox="allow-scripts"` permits: - ✅ Script execution (inline and external) - ✅ DOM manipulation - ✅ Event listeners (click, input, submit, etc.) - ✅ React synthetic event system - ✅ `setTimeout`, `setInterval`, `requestAnimationFrame` - ✅ `fetch` / XMLHttpRequest (subject to CSP, not sandbox) - ✅ `postMessage` communication ### What `sandbox` restricts even WITH `allow-scripts`: - ❌ Form submission to URLs (without `allow-forms`) - ❌ Opening popups (without `allow-popups`) - ❌ Top-level navigation (without `allow-top-navigation`) - ❌ Same-origin access to parent (without `allow-same-origin`) --- ## Question 3: Reference Host iframe attributes From `/tmp/mcp-ext-apps-r3/examples/basic-host/`: ### Host → Outer Iframe (`implementation.ts:loadSandboxProxy`): ```typescript iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); const allowAttribute = buildAllowAttribute(permissions); // camera, mic, etc. if (allowAttribute) iframe.setAttribute("allow", allowAttribute); ``` ### Sandbox Proxy → Inner Iframe (`sandbox.ts`): ```typescript const inner = document.createElement("iframe"); inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); // Later, on receiving sandbox-resource-ready: if (typeof sandbox === "string") { inner.setAttribute("sandbox", sandbox); // Host can override } ``` ### HTML Loading Method: ```typescript // Uses document.write(), NOT srcdoc const doc = inner.contentDocument || inner.contentWindow?.document; doc.open(); doc.write(html); doc.close(); ``` **Key finding:** The reference host uses `document.write()` instead of `srcdoc` to load HTML into the inner iframe. This is critical because `document.write()` inherits the CSP of the parent document (the sandbox proxy), while `srcdoc` creates an opaque origin with no CSP inheritance. --- ## Question 4: Could CSP block inline scripts in single-file HTML? **Yes, but the spec explicitly allows inline scripts.** ### CSP defaults from the spec: ``` default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; ``` ### Reference host CSP (serve.ts:buildCspHeader): ``` script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: {resourceDomains} style-src 'self' 'unsafe-inline' blob: data: {resourceDomains} ``` **Both include `'unsafe-inline'`**, which explicitly allows: - ` ``` ### CSP compatibility: - **With `'unsafe-inline'` in CSP:** ✅ Works fine — this is the MCP Apps default - **Without `'unsafe-inline'`:** ❌ ALL scripts would be blocked — but the app wouldn't render at all, which is NOT our symptom - **With nonce-based CSP:** ❌ Would block scripts unless nonces match — but MCP Apps spec doesn't use nonces **Verdict:** vite-plugin-singlefile output is compatible with the MCP Apps CSP defaults. If CSP were blocking our scripts, NOTHING would render — not even the visual components. --- ## Question 6: React event handlers in sandboxed iframes ### Do React event handlers work in sandboxed iframes? **Yes, fully.** React's synthetic event system works correctly inside `sandbox="allow-scripts"` iframes because: 1. **React uses event delegation** — attaches listeners to the root container (`#root`), not individual elements 2. **All events go through `addEventListener()`** — not inline HTML handlers 3. **No DOM access to parent needed** — React only touches its own subtree 4. **No form submission needed** — React handles forms via `e.preventDefault()` + state ### Known edge cases (none apply to our situation): - `sandbox` without `allow-same-origin`: localStorage/sessionStorage throw errors — but React doesn't need these for basic functionality - `sandbox` without `allow-forms`: native `
` submissions blocked — but React prevents default submission anyway - `sandbox` without `allow-popups`: `window.open()` blocked — irrelevant for React rendering ### The one real gotcha — `allow-same-origin` and `postMessage`: Without `allow-same-origin`, the iframe gets an **opaque origin** (`null`). This means: - `event.origin` in postMessage handlers will be `"null"` (the string) - Origin validation checks may fail - **This could break the MCP Apps SDK communication layer** (App↔Host postMessage relay) --- ## Root Cause Analysis: What's Actually Happening Given our symptoms — **visual rendering works, interactivity fails silently** — the issue is NOT sandbox/CSP. Here's why: | If the problem were... | We would see... | We see... | |------------------------|-----------------|-----------| | Missing `allow-scripts` | Nothing renders at all | Components render ✅ | | CSP blocking inline scripts | Nothing renders at all | Components render ✅ | | Missing `allow-forms` | Native form submit blocked | React doesn't use native submit | | Missing `allow-same-origin` | postMessage origin issues | **Possible** — needs investigation | ### Most likely causes: 1. **App SDK not initialized / `app.connect()` not called** — The `@modelcontextprotocol/ext-apps` App class must call `connect()` to establish the postMessage channel. If this fails silently, tool calls and state updates won't work. 2. **postMessage relay broken** — The double-iframe architecture requires messages to relay through the sandbox proxy. If the proxy isn't loaded or the origins don't match, communication fails silently. 3. **React event handlers depend on App SDK responses** — If button clicks call `app.callServerTool()` and the promise never resolves (because the postMessage channel is broken), the UI appears frozen. 4. **`srcdoc` vs `document.write()` origin mismatch** — If Goose uses `srcdoc` but the sandbox proxy expects `document.write()`, origin validation could fail. --- ## Proposed Fixes ### Immediate debugging: 1. **Add console.log to every event handler** — verify events fire at all 2. **Check browser DevTools console** in the iframe — look for CSP violations, postMessage errors, or SecurityError exceptions 3. **Test with the reference basic-host** — if it works there but not in Goose, the issue is Goose-specific ### If postMessage is the issue: 4. **Verify `app.connect()` succeeds** — wrap in try/catch, log result 5. **Monitor postMessage traffic** — add a global listener in the iframe to see if messages arrive 6. **Check if Goose implements the sandbox proxy pattern** — older Goose uses direct srcdoc, newer follows the double-iframe spec ### If CSP is the issue (unlikely given rendering works): 7. **Check DevTools → Network/Console for CSP violations** — they appear as `[Report Only]` or `Refused to execute` errors 8. **Add CSP metadata to resource response:** ```json { "_meta": { "ui": { "csp": { "connectDomains": ["http://localhost:3001"], "resourceDomains": [] } } } } ``` ### Architecture change (if all else fails): 9. **Don't use vite-plugin-singlefile** — serve the HTML from a URL instead of inline, which avoids all CSP/srcdoc complications 10. **Use the MCP Apps SDK's recommended Vite setup** — the official examples use Vite to produce a single-file bundle that's served via `resources/read`, which is the tested path --- ## References - [MCP Apps Specification (2026-01-26)](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx) - [MCP Apps Reference Host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) - [Goose MCP-UI allow-forms Issue #4117](https://github.com/block/goose/issues/4117) - [Goose MCP Apps Discussion #6069](https://github.com/block/goose/discussions/6069) - [vite-plugin-singlefile](https://github.com/richardtallent/vite-plugin-singlefile)