You hit ‘publish’ on your MCP App widget and watch it load on ChatGPT. Then nothing. A blank iframe stares back at you.
The server’s working. The API’s responding. The code looks clean. But somewhere between your backend and that sandboxed iframe, the browser’s killing your requests before they even leave the frame. Welcome to the #1 developer headache in MCP App development: Content Security Policy, or CSP.
It’s not a bug. It’s a feature. And it’s designed specifically to stop exactly what you’re trying to do — unless you tell the host environment which domains you trust.
How the Sandbox Actually Works
Every MCP App widget runs inside a sandboxed iframe. On ChatGPT, that iframe lives at something like yourapp.web-sandbox.oaiusercontent.com. On Claude, it’s computed from a hash of your server URL. On VS Code, the host controls it entirely.
The sandbox blocks everything by default. No external API calls. No CDN images. No Google Fonts. No WebSocket connections. Nothing leaves the frame unless you explicitly declare it safe.
“The sandbox blocks everything by default. No external API calls. No CDN images. No Google Fonts. No WebSocket connections. Nothing leaves the iframe unless you explicitly declare it.”
You declare what’s allowed by setting three domain arrays in _meta.ui.csp on your MCP resource. The host reads this configuration, sets the iframe’s Content Security Policy header, and if a domain isn’t declared, the browser kills the request before it even happens. Silent failure. No console error. Just a blank widget.
Which Array Do You Actually Need?
Here’s where most developers get it wrong. The three arrays sound simple until you realize they control completely different kinds of traffic—and mixing them up is the fastest way to debug for hours.
connectDomains handles runtime connections: fetch(), XMLHttpRequest, WebSocket, EventSource, navigator.sendBeacon(). Maps to the CSP connect-src directive. Use this when your widget calls an API at runtime.
A fetch to Stripe’s API will be blocked unless api.stripe.com is in connectDomains. Same for WebSocket connections. The rule is dead simple: if your JavaScript code initiates it at runtime, it lives here.
resourceDomains controls static asset loading: <script src>, <link rel="stylesheet">, , `<video>`, `<audio>`, `@font-face`, CSS `url()`, `@import`. Maps to `script-src`, `style-src`, `img-src`, `font-src`, `media-src`. Use this when your widget loads things from external CDNs. A `<script>` tag pulling Chart.js from a CDN? `resourceDomains`. A `<link>` tag loading Google Fonts? `resourceDomains`. An tag pointing to Cloudinary? resourceDomains.
frameDomains is for embedding third-party content inside iframes. YouTube videos. Google Maps. Spotify players. Maps to CSP frame-src. Without it, nested iframes are blocked entirely. ChatGPT reviews apps with frameDomains more strictly—only use it when you actually embed iframes. Don’t add it as a precaution.
The Mistakes That Kill Your Widget (And How to Spot Them)
Mistake #1: API domain in the wrong array. This is the most common error. Your widget calls fetch() to an API, and you put the domain in resourceDomains because “it’s a resource.” It isn’t. fetch() is a runtime connection. It goes in connectDomains. Period.
The telltale sign? Your widget renders, but network requests silently fail. The browser blocks them without logging anything obvious.
Mistake #2: Google Fonts partial declaration. Google Fonts is a two-domain system that catches everyone. The CSS is served from fonts.googleapis.com, but the actual font files (.woff2) come from fonts.gstatic.com. If you only declare the first, the CSS loads but the fonts don’t. Your widget renders with fallback system fonts—a subtle visual bug that’s easy to miss during dev work but obvious to actual users.
Mistake #3: WebSocket scheme mismatch. WebSocket connections use wss://, not https://. If you declare the HTTPS version, the WebSocket connection still fails. Seems obvious in retrospect, but the error messages are minimal, and debugging it means digging into the Network tab looking for a connection that was blocked before it even tried.
Mistake #4: Services that need both arrays. Some services—Mapbox, Cloudinary, Firebase, Supabase—serve API responses AND static assets from the same or related domains. If you only put Mapbox in connectDomains, the API calls work but the map tiles don’t render. The fix: declare the domain in both arrays.
The ChatGPT Publishing Gotcha
ChatGPT’s developer mode is forgiving. Published mode is not.
Developer mode lets you get away without declaring _meta.ui.domain. Published mode requires it—this is the domain ChatGPT uses to scope your widget’s sandbox. You’ll publish, test, and find that your published app behaves differently than your dev version. It’s not a bug; it’s stricter security enforcement on the production side.
The fix is straightforward: set _meta.ui.domain to your actual server domain before publishing. But if you skip it, you’ll discover the problem only after your app goes live.
What This Actually Means
CSP isn’t bureaucracy. It’s how modern app hosts prevent malicious code from exfiltrating data or making unauthorized requests on behalf of users. The sandbox is working exactly as designed—which is why blank widgets are so frustrating. Your code is fine. Your server is fine. The framework is just being extremely protective about what you’re allowed to do.
Once you understand the three arrays and the specific mistakes that trigger silent failures, debugging becomes trivial. Wrong array? Move the domain. Missing a paired domain? Add it. Wrong scheme? Fix the URL. Then test in the actual published environment, not just dev mode.
The irony: once you get it right, you never think about CSP again. But getting there the first time? It’s the #1 way developers waste three hours chasing a problem that doesn’t exist.
🧬 Related Insights
- Read more: rs-trafilatura Unlocks Firecrawl’s Hidden Precision
- Read more: Token Refresh Stampedes Are Wrecking Apps Everywhere — 40 Lines to Stop the Madness
Frequently Asked Questions
What does connectDomains vs resourceDomains mean in MCP CSP?
Connect domains allow runtime requests like fetch() and WebSocket. Resource domains allow static assets like scripts, stylesheets, images, and fonts. If your JavaScript code calls it at runtime, use connectDomains. If an HTML tag loads it, use resourceDomains.
Why is my MCP widget blank even though the API works?
Your API domain is likely in the wrong CSP array or missing entirely. Check the Network tab in dev tools for blocked requests. If a domain isn’t declared in the matching CSP array, the browser silently blocks it. Also verify the domain scheme matches (https vs wss) and that any paired domains (like Google Fonts) are both declared.
Does MCP App CSP work differently on ChatGPT vs Claude?
The CSP mechanism is the same across platforms, but ChatGPT enforces stricter rules in published mode than dev mode and requires _meta.ui.domain. Claude and VS Code have similar sandboxing but different iframe origins. Always test your published app in the actual host environment, not just locally.