MCP-app widgets run inside a sandboxed `<iframe>` in the host (Claude Desktop,
claude.ai). The sandbox and CSP attributes lock down what the widget can do.
Every item below was observed failing with a silent blank iframe until the
fix was applied — the error only appears in the iframe's own devtools console,
not the host's.
---
## Problem → fix table
| Symptom | Root cause | Fix |
|---|---|---|
| Widget renders as blank rectangle, no error | CSP `script-src` blocks esm.sh fetching transitive `@modelcontextprotocol/sdk` deps | Inline the `ext-apps/app-with-deps` bundle into the HTML |
| `window.open()` does nothing | Sandbox lacks `allow-popups` | Use `app.openLink({ url })` |
| `<a target="_blank">` does nothing | Same | `e.preventDefault()` + `app.openLink({ url })` on click |
| External `<img src>` broken | CSP `img-src` + referrer hotlink blocking | Fetch server-side, ship as `data:` URL in the tool result payload |
| Widget edits don't appear after server restart | Host caches UI resources | Fully quit the host (⌘Q / Alt+F4) and relaunch |
| Top-level `await` throws | Older iframe contexts | Wrap module body in an async IIFE |
---
## Inlining the ext-apps bundle
`@modelcontextprotocol/ext-apps` ships a self-contained browser build at the
`app-with-deps` export (~300KB). It's minified ESM ending in `export{…}`; to
use it from an inline `<script type="module">` block, rewrite the export
The host renders the iframe inside its own card chrome — paint a **transparent** background and adopt host CSS tokens so the widget blends in across light/dark and across hosts.
if (ctx?.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
}
app.onhostcontextchanged = applyHostContext;
await app.connect();
applyHostContext(app.getHostContext());
```
`applyHostStyleVariables` writes the host's `--color-*` / `--font-*` / `--border-radius-*` tokens onto `:root`; the hex values above are fallbacks for hosts that don't supply them.