clawdbot-workspace/research/mcp-iframe-sandbox-research.md

11 KiB

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):

iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");

Inner iframe (set by sandbox proxy in sandbox.ts):

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):

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):

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:

// 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:

  • <script> tags with inline code
  • onclick and other inline event handlers in HTML attributes
  • <style> tags with inline CSS

However — CSP delivery method matters:

The reference host sets CSP via HTTP headers on sandbox.html (the proxy page), which is tamper-proof. When the inner iframe content is loaded via document.write(), it inherits this CSP.

If a host delivers CSP differently (e.g., meta tags, or doesn't pass through the proxy architecture), inline scripts could be blocked. A host that sets CSP without 'unsafe-inline' would silently block all inline <script> tags.

Goose-specific risk:

If Goose uses srcdoc (as the old MCP-UI path did), CSP is NOT inherited — srcdoc iframes get a null origin with default browser CSP, which does allow inline scripts by default. So srcdoc + sandbox="allow-scripts" should work for inline scripts.


Question 5: Does vite-plugin-singlefile produce CSP-blocked output?

What vite-plugin-singlefile does:

  • Takes all JS/CSS chunks that Vite produces as separate files
  • Inlines them as <script> and <style> tags directly in index.html
  • Result: single HTML file with ALL code inline

Output format:

<!DOCTYPE html>
<html>
<head>
  <style>/* all CSS inlined here */</style>
</head>
<body>
  <div id="root"></div>
  <script type="module">/* all JS inlined here, including React */</script>
</body>
</html>

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 <form> 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:

  1. Verify app.connect() succeeds — wrap in try/catch, log result
  2. Monitor postMessage traffic — add a global listener in the iframe to see if messages arrive
  3. 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):

  1. Check DevTools → Network/Console for CSP violations — they appear as [Report Only] or Refused to execute errors
  2. Add CSP metadata to resource response:
{
  "_meta": {
    "ui": {
      "csp": {
        "connectDomains": ["http://localhost:3001"],
        "resourceDomains": []
      }
    }
  }
}

Architecture change (if all else fails):

  1. Don't use vite-plugin-singlefile — serve the HTML from a URL instead of inline, which avoids all CSP/srcdoc complications
  2. 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