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/clientlibrary 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) - ✅
postMessagecommunication
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 codeonclickand 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 inindex.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:
- React uses event delegation — attaches listeners to the root container (
#root), not individual elements - All events go through
addEventListener()— not inline HTML handlers - No DOM access to parent needed — React only touches its own subtree
- No form submission needed — React handles forms via
e.preventDefault()+ state
Known edge cases (none apply to our situation):
sandboxwithoutallow-same-origin: localStorage/sessionStorage throw errors — but React doesn't need these for basic functionalitysandboxwithoutallow-forms: native<form>submissions blocked — but React prevents default submission anywaysandboxwithoutallow-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.originin 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:
-
App SDK not initialized /
app.connect()not called — The@modelcontextprotocol/ext-appsApp class must callconnect()to establish the postMessage channel. If this fails silently, tool calls and state updates won't work. -
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.
-
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. -
srcdocvsdocument.write()origin mismatch — If Goose usessrcdocbut the sandbox proxy expectsdocument.write(), origin validation could fail.
Proposed Fixes
Immediate debugging:
- Add console.log to every event handler — verify events fire at all
- Check browser DevTools console in the iframe — look for CSP violations, postMessage errors, or SecurityError exceptions
- Test with the reference basic-host — if it works there but not in Goose, the issue is Goose-specific
If postMessage is the issue:
- Verify
app.connect()succeeds — wrap in try/catch, log result - Monitor postMessage traffic — add a global listener in the iframe to see if messages arrive
- 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):
- Check DevTools → Network/Console for CSP violations — they appear as
[Report Only]orRefused to executeerrors - Add CSP metadata to resource response:
{
"_meta": {
"ui": {
"csp": {
"connectDomains": ["http://localhost:3001"],
"resourceDomains": []
}
}
}
}
Architecture change (if all else fails):
- Don't use vite-plugin-singlefile — serve the HTML from a URL instead of inline, which avoids all CSP/srcdoc complications
- 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