MCP App CSP Explained: Why Your Widget Won't Render
You built an MCP App. The tool works. The server returns data. But the widget renders as a blank iframe. 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 three 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 computed from a hash of your server URL. 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 e
You built an MCP App. The tool works. The server returns data. But the widget renders as a blank iframe.
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 three 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 computed from a hash of your server URL. 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"], } } }_meta: { ui: { csp: { connectDomains: ["https://api.example.com"], resourceDomains: ["https://cdn.example.com"], } } }Enter fullscreen mode
Exit fullscreen mode
Simple enough. But the devil is in knowing which array to put each domain in.
The three domain arrays
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");// 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");`
Enter fullscreen mode
Exit fullscreen mode
resourceDomains — static assets
Controls: , , ,
Maps to CSP script-src, style-src, img-src, font-src, media-src.
Use when: your widget loads assets from external CDNs.
`
`
Enter fullscreen mode
Exit fullscreen mode
frameDomains — nested iframes
Controls:
Maps to CSP frame-src.
Use when: your widget embeds third-party content like YouTube videos, Google Maps, or Spotify players.
Enter fullscreen mode
Exit fullscreen mode
Without frameDomains, nested iframes are blocked entirely. Note that ChatGPT reviews apps with frameDomains more strictly — only use it when you actually embed iframes.
The five 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"] }// Wrong: API domain in resourceDomains csp: { resourceDomains: ["https://api.example.com"] }// Correct: API domain in connectDomains csp: { connectDomains: ["https://api.example.com"] }`
Enter fullscreen mode
Exit fullscreen mode
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"] }// 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" ] }`
Enter fullscreen mode
Exit fullscreen mode
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"] }// 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" ] }`
Enter fullscreen mode
Exit fullscreen mode
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"] }// 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"] }`
Enter fullscreen mode
Exit fullscreen mode
Other services that commonly need both: Cloudinary (API + image CDN), Firebase (API + hosting), Supabase (API + storage).
5. Works in dev, breaks when published
ChatGPT has a more relaxed CSP in developer mode. When you publish your app, stricter rules apply. Two things that catch people:
Missing meta.ui.domain. Developer mode works without it. Published mode requires it — this is the domain ChatGPT uses to scope your widget's sandbox origin.
_meta: { ui: { domain: "https://myapp.example.com", // required for published apps csp: { /* ... */ } } }_meta: { ui: { domain: "https://myapp.example.com", // required for published apps csp: { /* ... */ } } }Enter fullscreen mode
Exit fullscreen mode
Missing openai/widgetCSP. Some published apps need the ChatGPT-specific CSP format alongside the standard meta.ui.csp:
_meta: { ui: { csp: { connectDomains: ["https://api.example.com"], resourceDomains: ["https://cdn.example.com"] } }, // ChatGPT compatibility layer "openai/widgetCSP": { connect_domains: ["https://api.example.com"], resource_domains: ["https://cdn.example.com"] } }_meta: { ui: { csp: { connectDomains: ["https://api.example.com"], resourceDomains: ["https://cdn.example.com"] } }, // ChatGPT compatibility layer "openai/widgetCSP": { connect_domains: ["https://api.example.com"], resource_domains: ["https://cdn.example.com"] } }Enter fullscreen mode
Exit fullscreen mode
Note the naming difference: connectDomains (camelCase) in the standard spec vs connect_domains (snake_case) in the ChatGPT extension.
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 (F12 or Cmd+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'"Refused to connect to 'https://api.example.com/data' because it violates the following Content Security Policy directive: "connect-src 'self'"Enter fullscreen mode
Exit fullscreen mode
This tells you:
-
What was blocked: https://api.example.com/data
-
Which directive: connect-src — you need connectDomains
-
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'"Refused to load the font 'https://fonts.gstatic.com/s/inter/...' because it violates the following Content Security Policy directive: "font-src 'self'"Enter fullscreen mode
Exit fullscreen mode
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 to errors
-
For each error, identify the directive (connect-src, font-src, script-src, etc.)
-
Map the directive to the right array:
connect-src → connectDomains
script-src, style-src, img-src, font-src, media-src → resourceDomains
frame-src → frameDomains
-
Add the blocked domain to the correct array
-
Restart your MCP server and test again
Copy-paste patterns
Here are CSP declarations for common use cases:
API calls only:
csp: { connectDomains: ["https://api.yourbackend.com"] }csp: { connectDomains: ["https://api.yourbackend.com"] }Enter fullscreen mode
Exit fullscreen mode
CDN images:
csp: { resourceDomains: ["https://cdn.yourbackend.com"] }csp: { resourceDomains: ["https://cdn.yourbackend.com"] }Enter fullscreen mode
Exit fullscreen mode
Google Fonts:
csp: { resourceDomains: [ "https://fonts.googleapis.com", "https://fonts.gstatic.com" ] }csp: { resourceDomains: [ "https://fonts.googleapis.com", "https://fonts.gstatic.com" ] }Enter fullscreen mode
Exit fullscreen mode
Full stack — API + CDN + Fonts:
csp: { connectDomains: ["https://api.yourbackend.com"], resourceDomains: [ "https://cdn.yourbackend.com", "https://fonts.googleapis.com", "https://fonts.gstatic.com" ] }csp: { connectDomains: ["https://api.yourbackend.com"], resourceDomains: [ "https://cdn.yourbackend.com", "https://fonts.googleapis.com", "https://fonts.gstatic.com" ] }Enter fullscreen mode
Exit fullscreen mode
Mapbox maps:
csp: { connectDomains: [ "https://api.mapbox.com", "https://events.mapbox.com" ], resourceDomains: [ "https://api.mapbox.com", "https://cdn.mapbox.com" ] }csp: { connectDomains: [ "https://api.mapbox.com", "https://events.mapbox.com" ], resourceDomains: [ "https://api.mapbox.com", "https://cdn.mapbox.com" ] }Enter fullscreen mode
Exit fullscreen mode
Embedded YouTube:
csp: { frameDomains: ["https://www.youtube.com"] }csp: { frameDomains: ["https://www.youtube.com"] }Enter fullscreen mode
Exit fullscreen mode
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 throw SecurityError. Use in-memory state instead.
-
eval() / new Function() — blocked by default. Some charting libraries use eval() 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 Widget domain Notes
ChatGPT
_meta.ui.csp + openai/widgetCSP_
{domain}.web-sandbox.oaiusercontent.com
Requires _meta.ui.domain for published apps_
Claude
_meta.ui.csp
SHA-256 of MCP server URL
Own sandbox model_
VS Code
_meta.ui.csp
Host-controlled
Had bugs with resourceDomains mapping in older versions_
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 your meta.ui.csp declarations, and injects the correct CSP headers automatically — so you declare once and it works across ChatGPT, Claude, and VS Code.
cargo install mcpr mcpr --config mcpr.tomlcargo install mcpr mcpr --config mcpr.tomlEnter fullscreen mode
Exit fullscreen mode
If 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.
-
Star us on GitHub
-
Get started with MCPR
TL;DR:
-
connectDomains = fetch(), WebSocket, XHR (runtime connections)
-
resourceDomains = images, fonts, scripts, stylesheets (static assets)
-
frameDomains = nested iframes (use sparingly)
-
Debug with DevTools Console — look for "Refused to" errors
-
Google Fonts needs both fonts.googleapis.com AND fonts.gstatic.com
-
Test on every platform you ship to — they're all slightly different
Sign in to highlight and annotate this article

Conversation starters
Daily AI Digest
Get the top 5 AI stories delivered to your inbox every morning.
Knowledge Map
Connected Articles — Knowledge Graph
This article is connected to other articles through shared AI topics and tags.
More in Releases

7 CVEs in 48 Hours: How PraisonAI Got Completely Owned — And What Every Agent Framework Should Learn
PraisonAI is a popular multi-agent Python framework supporting 100+ LLMs. On April 3, 2026, seven CVEs dropped simultaneously. Together they enable complete system compromise from zero authentication to arbitrary code execution. I spent the day analyzing each vulnerability. Here is what I found, why it matters, and the patterns every agent framework developer should audit for immediately. The Sandbox Bypass (CVE-2026-34938, CVSS 10.0) This is the most technically interesting attack I have seen this year. PraisonAI's execute_code() function runs a sandbox with three protection layers. The innermost wrapper, _safe_getattr , calls startswith() on incoming arguments to check for dangerous imports like os , subprocess , and sys . The attack: create a Python class that inherits from str and over

I Built a Zero-Login Postman Alternative in 5 Weeks. My Cofounder Is an AI and I Work Long Shifts.
I started this because I wanted to know if the hype was real. Not the AI hype specifically. The whole thing — the idea that someone without a CS degree, without a team, without anyone around them who even knows what Claude.ai is, could build something real on weekends. I work long demanding shifts at a job that has nothing to do with software. My coworkers don't know what an API is. I barely knew what one was when I started. Five weeks later I have a live product with Stripe payments, a Pro tier, and an AI that generates production-ready API requests from plain English. I'm still not entirely sure what I'd use it for in my day job. But I know the journey was worth it. If you can't learn, you're done. Why This Exists One night I needed to test an API endpoint. I opened Postman. It asked me






Discussion
Sign in to join the discussion
No comments yet — be the first to share your thoughts!