· Hien · 10 min read
MCP App CSP Explained: Why Your Widget Won't Render in ChatGPT and Claude
Your MCP App widget loads a blank iframe. No error. No hint. Here's exactly what's happening: the five CSP domain arrays, the mistakes that break your widget, and how to debug them.
You built an MCP App. The tool works. The server returns data. But the widget renders as a blank iframe. No error message. No console warning you’d notice. Just… nothing.
You’ve hit the #1 problem in MCP App development: Content Security Policy.
This post explains exactly how CSP works in MCP Apps, what the five domain arrays do, the mistakes that cause silent failures, and how to debug them. By the end, you’ll never stare at a blank widget again.
The sandbox model
Every MCP App widget runs inside a sandboxed iframe. On ChatGPT, that iframe lives at a domain like yourapp.web-sandbox.oaiusercontent.com. On Claude, it’s {sha256(serverUrl)[:32]}.claudemcpcontent.com, derived from the MCP server URL itself. On VS Code, it’s host-controlled.
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 allowed domains in _meta.ui.csp on your MCP resource. The host reads this and sets the iframe’s Content Security Policy. If a domain isn’t declared, the browser blocks the request before it even happens.
Here’s what a declaration looks like:
_meta: {
ui: {
csp: {
connectDomains: ["https://api.example.com"],
resourceDomains: ["https://cdn.example.com"],
}
}
}Simple enough. But the devil is in knowing which array to put each domain in.
The five domain arrays
Three of the five (connectDomains, resourceDomains, frameDomains) are defined by both the MCP Apps spec and the OpenAI Apps SDK. The other two are platform-specific: baseUriDomains exists only in the MCP Apps spec, redirectDomains only in the OpenAI Apps SDK.
connectDomains: runtime connections
Controls: fetch(), XMLHttpRequest, WebSocket, EventSource, navigator.sendBeacon()
Maps to the CSP connect-src directive.
Use when: your widget calls an API at runtime.
// This fetch will be BLOCKED unless api.stripe.com is in connectDomains
const charges = await fetch("https://api.stripe.com/v1/charges");
// Same for WebSockets
const ws = new WebSocket("wss://realtime.example.com/feed");resourceDomains: static assets
Controls: <script src>, <link rel="stylesheet">, <img src>, <video>, <audio>, @font-face, CSS url(), @import
Maps to CSP script-src, style-src, img-src, font-src, media-src.
Use when: your widget loads assets from external CDNs.
<!-- These will be BLOCKED unless the domains are in resourceDomains -->
<script src="https://cdn.example.com/chart.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">
<img src="https://res.cloudinary.com/demo/image/upload/sample.jpg">frameDomains: nested iframes
Controls: <iframe src>
Maps to CSP frame-src.
Use when: your widget embeds third-party content like YouTube videos, Google Maps, or Spotify players.
<!-- BLOCKED unless youtube.com is in frameDomains -->
<iframe src="https://www.youtube.com/embed/abc123"></iframe>Without frameDomains, nested iframes are blocked entirely. Note that ChatGPT reviews apps with frameDomains more strictly: only use it when you actually embed iframes.
baseUriDomains: <base href> targets (MCP Apps spec only)
Controls: <base href> element targets.
Maps to CSP base-uri.
Use when: your widget bundle relies on <base href> to resolve relative URLs (rare, but some single-page bundlers emit one).
<!-- BLOCKED unless cdn.example.com is in baseUriDomains -->
<base href="https://cdn.example.com/app/">This array is part of the MCP Apps spec but the OpenAI Apps SDK has no equivalent field, so ChatGPT ignores it. Claude and VS Code honour it.
redirectDomains: window.openai.openExternal (OpenAI Apps SDK only)
Controls: targets allowed when calling window.openai.openExternal(url) from inside the widget.
Use when: your widget needs to open user-facing URLs (docs, dashboards, marketing pages) outside the iframe.
// BLOCKED unless docs.example.com is in redirectDomains
window.openai.openExternal("https://docs.example.com/help");This is OpenAI-only: the field doesn’t exist in the MCP Apps spec, so Claude and VS Code ignore it.
The seven mistakes that break your widget
1. Using resourceDomains for API calls
This is the most common mistake. 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.
// Wrong: API domain in resourceDomains
csp: {
resourceDomains: ["https://api.example.com"]
}
// Correct: API domain in connectDomains
csp: {
connectDomains: ["https://api.example.com"]
}The rule: if your JavaScript code calls it at runtime, it goes in connectDomains. If an HTML tag loads it as a static asset, it goes in resourceDomains.
2. Forgetting the font file domain
Google Fonts is a two-domain system. 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.
// Wrong: CSS loads, fonts don't
csp: {
resourceDomains: ["https://fonts.googleapis.com"]
}
// Correct: both domains declared
csp: {
resourceDomains: [
"https://fonts.googleapis.com",
"https://fonts.gstatic.com"
]
}Your widget will render with fallback system fonts: a subtle visual bug that’s easy to miss during development but obvious to users.
3. Missing the WebSocket protocol
WebSocket connections use wss://, not https://. If you declare the HTTPS version, the WebSocket connection still fails.
// Wrong: wss:// connections are still blocked
csp: {
connectDomains: ["https://realtime.example.com"]
}
// Correct: use the wss:// scheme
csp: {
connectDomains: ["wss://realtime.example.com"]
}
// Also correct: declare both if you use both
csp: {
connectDomains: [
"https://api.example.com",
"wss://realtime.example.com"
]
}4. Services that need both arrays
Some services serve both static assets AND API responses from the same or related domains. Mapbox is a classic example: it serves API responses (tile coordinates) and image tiles (actual map pictures) from the same origins.
// Wrong: only connect, map tiles don't render
csp: {
connectDomains: ["https://api.mapbox.com"]
}
// Correct: both connect and resource
csp: {
connectDomains: ["https://api.mapbox.com"],
resourceDomains: ["https://api.mapbox.com"]
}Other services that commonly need both: Cloudinary (API + image CDN), Firebase (API + hosting), Supabase (API + storage).
5. Declaring only one CSP shape
ChatGPT and Claude read different _meta keys for the same CSP. ChatGPT reads _meta.openai/widgetCSP (snake_case keys: connect_domains, resource_domains, frame_domains, redirect_domains). Claude and VS Code read _meta.ui.csp (camelCase keys: connectDomains, resourceDomains, frameDomains, baseUriDomains).
If you declare only one, your widget breaks on the host that reads the other:
// Wrong: only ChatGPT works, Claude renders empty
_meta: {
"openai/widgetCSP": {
connect_domains: ["https://api.example.com"],
resource_domains: ["https://cdn.example.com"]
}
}
// Correct: declare both shapes
_meta: {
ui: {
csp: {
connectDomains: ["https://api.example.com"],
resourceDomains: ["https://cdn.example.com"]
}
},
"openai/widgetCSP": {
connect_domains: ["https://api.example.com"],
resource_domains: ["https://cdn.example.com"]
}
}Hosts ignore keys they don’t recognise, so emitting both is always safe.
6. Wrong domain field for ChatGPT
ChatGPT scopes the iframe’s sandbox origin from _meta.openai/widgetDomain, not _meta.ui.domain:
// Wrong: ChatGPT ignores ui.domain entirely
_meta: {
ui: { domain: "myapp.example.com", csp: { /* ... */ } }
}
// Correct: openai/widgetDomain is the field ChatGPT reads
_meta: {
"openai/widgetDomain": "myapp.example.com",
ui: { csp: { /* ... */ } }
}_meta.ui.domain is the MCP Apps spec field, but it carries Claude-specific semantics: Claude validates the value against a hash it derives from the MCP server URL itself ({sha256(url)[:32]}.claudemcpcontent.com) and rejects any value an MCP server supplies. If you set it, Claude refuses to load the widget. The safe move is to leave _meta.ui.domain absent and let Claude compute the value it expects.
7. Upstream-host leakage
When you develop locally, your MCP server might emit localhost:9000 (or your internal upstream hostname) in its CSP arrays. The host reads those values verbatim and embeds them in the iframe CSP. The widget then tries to call back to a URL that doesn’t exist in production, and every request 404s or is blocked.
// Common bug: the dev URL ships in production CSP
_meta: {
ui: {
csp: {
connectDomains: ["http://localhost:9000", "https://api.example.com"]
}
}
}Strip local and upstream-internal addresses before declaring CSP, and don’t reference your MCP server’s own hostname in CSP arrays - the iframe doesn’t talk to the MCP server, the host does.
How to debug CSP violations
When CSP blocks a request, the browser logs it to the console. Here’s how to find it:
- Open DevTools (
F12orCmd+Opt+I) - Go to the Console tab
- Look for red errors starting with
Refused to
The error message tells you exactly what was blocked:
Refused to connect to 'https://api.example.com/data'
because it violates the following Content Security Policy directive:
"connect-src 'self'"This tells you:
- What was blocked:
https://api.example.com/data - Which directive:
connect-src, you needconnectDomains - Current policy: only
'self'is allowed, the domain isn’t declared
For font issues:
Refused to load the font 'https://fonts.gstatic.com/s/inter/...'
because it violates the following Content Security Policy directive:
"font-src 'self'"This means fonts.gstatic.com needs to be in resourceDomains.
Debugging checklist
When your widget is blank or partially broken:
- Open DevTools Console: look for
Refused toerrors - For each error, identify the directive (
connect-src,font-src,script-src, etc.) - Map the directive to the right array:
connect-src→ connectDomainsscript-src,style-src,img-src,font-src,media-src→ resourceDomainsframe-src→ frameDomainsbase-uri→ baseUriDomains (Claude/VS Code only)
- Add the blocked domain to the correct array
- Restart your MCP server and test again
For OpenAI-specific external-link blocks, window.openai.openExternal failures don’t surface as CSP console errors - they throw a JavaScript error inside the widget. Check the call site and add the target host to redirectDomains.
Copy-paste patterns
Here are CSP declarations for common use cases:
API calls only:
csp: {
connectDomains: ["https://api.yourbackend.com"]
}CDN images:
csp: {
resourceDomains: ["https://cdn.yourbackend.com"]
}Google Fonts:
csp: {
resourceDomains: [
"https://fonts.googleapis.com",
"https://fonts.gstatic.com"
]
}Full stack: API + CDN + Fonts:
csp: {
connectDomains: ["https://api.yourbackend.com"],
resourceDomains: [
"https://cdn.yourbackend.com",
"https://fonts.googleapis.com",
"https://fonts.gstatic.com"
]
}Mapbox maps:
csp: {
connectDomains: [
"https://api.mapbox.com",
"https://events.mapbox.com"
],
resourceDomains: [
"https://api.mapbox.com",
"https://cdn.mapbox.com"
]
}Embedded YouTube:
csp: {
frameDomains: ["https://www.youtube.com"]
}External docs/help links (window.openai.openExternal, ChatGPT only):
_meta: {
"openai/widgetCSP": {
redirect_domains: ["https://docs.example.com", "https://help.example.com"]
}
}Other sandbox restrictions
CSP isn’t the only thing the sandbox blocks. These browser APIs are also restricted inside MCP App iframes:
localStorage/sessionStorage: may throwSecurityError. Use in-memory state instead.eval()/new Function(): blocked by default. Some charting libraries useeval()internally, check before picking a dependency.window.open(): blocked. Use the MCP Apps bridge for navigation.document.cookie: no cookies in sandboxed iframes.navigator.clipboard: blocked.alert()/confirm()/prompt(): blocked.
If your widget depends on any of these, it will fail silently even if your CSP is perfect.
Platform differences
The MCP Apps spec is standard, but each host implements it differently:
| Platform | CSP source | Domain field | Sandbox origin |
|---|---|---|---|
| ChatGPT | _meta.openai/widgetCSP (snake_case) | _meta.openai/widgetDomain | {widgetDomain}.web-sandbox.oaiusercontent.com |
| Claude | _meta.ui.csp (camelCase) | derived, do not set | {sha256(serverUrl)[:32]}.claudemcpcontent.com |
| VS Code | _meta.ui.csp (camelCase) | host-controlled | host-controlled |
A few cross-platform notes:
redirectDomainsis OpenAI-only - it lives under_meta.openai/widgetCSPand has no equivalent in_meta.ui.csp.baseUriDomainsis MCP-spec only - it lives under_meta.ui.cspand has no equivalent in_meta.openai/widgetCSP.- Setting
_meta.ui.domainto anything but the value Claude derives causes Claude to refuse the widget. Leave it absent.
If you’re building for multiple platforms, test on each. A widget that works on ChatGPT might fail on Claude or VS Code due to these subtle differences.
Skip the debugging entirely
Getting CSP right by hand is tedious. Every time you add a new external dependency (a font, an analytics script, an API endpoint) you need to update _meta.ui.csp and hope you picked the right array.
MCPR is an open-source MCP proxy that handles this for you. It sits between the AI client and your MCP server, reads a single declarative policy from mcpr.toml, merges it with whatever upstream declared, strips localhost and upstream-host leakage, and emits both _meta.openai/widgetCSP and _meta.ui.csp shapes (plus openai/widgetDomain) on every widget resource. One declaration works on ChatGPT, Claude, and VS Code.
curl -fsSL https://mcpr.app/install.sh | sh
mcpr proxy run mcpr.tomlIf you don’t want to self-host, MCPR Cloud gives you a managed tunnel with a free subdomain. Claim yours at cloud.mcpr.app and start proxying in minutes: CSP handled, auth included, every tool call observable.
TL;DR:
connectDomains=fetch(), WebSocket, XHR (runtime connections)resourceDomains= images, fonts, scripts, stylesheets (static assets)frameDomains= nested iframes (use sparingly)baseUriDomains=<base href>targets (MCP Apps spec only, ChatGPT ignores)redirectDomains=window.openai.openExternalallow-list (OpenAI only, Claude/VS Code ignore)- ChatGPT reads
_meta.openai/widgetCSP(snake_case) +openai/widgetDomain. Claude/VS Code read_meta.ui.csp(camelCase). Declare both. - Never set
_meta.ui.domain- Claude derives it from your server URL and rejects any other value - Strip
localhostand your MCP server’s own host from CSP arrays before declaring - Google Fonts needs both
fonts.googleapis.comANDfonts.gstatic.com - Debug with DevTools Console, look for “Refused to” errors
- mcp
- csp
- webdev
- guides