260 lines
11 KiB
Markdown
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)
|