MCP App CSP Guide: Fix Blank Widgets

You built an MCP App. The server returns data. The widget is still blank. Here's why—and how to fix it in five minutes.

Why Your MCP App Widget Goes Blank: The Content Security Policy Trap — theAIcatchup

Key Takeaways

  • CSP blocks all external requests by default—you must explicitly declare trusted domains in three arrays: connectDomains, resourceDomains, and frameDomains
  • The #1 mistake is putting API domains in resourceDomains instead of connectDomains; runtime connections like fetch() require connectDomains
  • Google Fonts and multi-domain services require both fonts.googleapis.com AND fonts.gstatic.com declared; missing one causes silent failures
  • WebSocket connections use wss:// not https://; scheme mismatches cause silent blocking with minimal error messages
  • ChatGPT's published mode enforces stricter CSP than dev mode and requires _meta.ui.domain; always test in production before shipping

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

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.

Aisha Patel
Written by

Former ML engineer turned writer. Covers computer vision and robotics with a practitioner perspective.

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.

Worth sharing?

Get the best AI stories of the week in your inbox — no noise, no spam.

Originally reported by Dev.to

Stay in the loop

The week's most important stories from theAIcatchup, delivered once a week.