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

260 lines
11 KiB
Markdown

# 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:
- `<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:
```html
<!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:
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)