MCP App CSP Domains: connectDomains vs resourceDomains vs frameDomains (May 2026)
Configure CSP domains to control what your MCP App resource can load and connect to.
TL;DR: MCP App resources run in sandboxed iframes with no external access by default. Use connectDomains for API calls (fetch, WebSocket), resourceDomains for static assets (images, fonts, scripts), and frameDomains for nested iframes (embeds). All three go in _meta.ui.csp on your resource config. This applies to MCP Apps in ChatGPT, Claude, and all ext-apps-compatible hosts.
The Three CSP Domain Types
Every MCP App resource runs in a sandboxed iframe. The host applies a Content Security Policy (CSP) that blocks all external connections and resource loading by default. You open up access by declaring specific origins in _meta.ui.csp.
There are three domain arrays, and they control different things:
connectDomains (runtime network requests)
Maps to the CSP connect-src directive. Controls:
fetch()callsXMLHttpRequestWebSocketconnections
Use this when your resource component calls an API at runtime. If your component calls fetch('https://api.example.com/data'), you need https://api.example.com in connectDomains.
resourceDomains (static asset loading)
Maps to the CSP img-src, script-src, style-src, font-src, and media-src directives. Controls:
<img src="...">tags and CSSbackground-imageURLs<script src="...">tags<link rel="stylesheet" href="...">tags@font-faceURLs<video>and<audio>sources
Use this when your resource loads assets from external CDNs. If your component renders <img src="https://cdn.example.com/photo.jpg" />, you need https://cdn.example.com in resourceDomains.
frameDomains (nested iframes)
Maps to the CSP frame-src directive. Controls:
<iframe src="...">tags inside your resource
Use this when your resource embeds third-party content like YouTube players, Google Maps, or any iframe-based widget. Without it, nested iframes are blocked entirely.
Quick Reference
| I need to… | Use | CSP directive |
|---|---|---|
Call an API with fetch() | connectDomains | connect-src |
| Open a WebSocket | connectDomains | connect-src |
| Load images from a CDN | resourceDomains | img-src |
| Load fonts from Google Fonts | resourceDomains | font-src |
| Load a script from a CDN | resourceDomains | script-src |
| Embed a YouTube video | frameDomains | frame-src |
| Embed Google Maps | frameDomains | frame-src |
There is also baseUriDomains (maps to base-uri), but most apps never need it.
Common Patterns
API-only resource
Your resource fetches data from a backend. No external images or embeds.
export const resource: ResourceConfig = {
description: 'Live usage dashboard',
_meta: {
ui: {
csp: {
connectDomains: ['https://api.example.com'],
},
},
},
};
Resource with CDN images
Your resource renders images hosted on an external CDN. No runtime API calls.
export const resource: ResourceConfig = {
description: 'Photo gallery',
_meta: {
ui: {
csp: {
resourceDomains: ['https://cdn.example.com'],
},
},
},
};
Map resource (needs both)
Mapbox (and similar map services) serve both API responses and tile images from the same origins. You need both connectDomains and resourceDomains:
export const resource: ResourceConfig = {
description: 'Map view',
_meta: {
ui: {
csp: {
connectDomains: ['https://api.mapbox.com', 'https://events.mapbox.com'],
resourceDomains: ['https://api.mapbox.com', 'https://events.mapbox.com'],
},
},
},
};
Embedded video
Your resource embeds a YouTube player inside a nested iframe:
export const resource: ResourceConfig = {
description: 'Video player',
_meta: {
ui: {
csp: {
frameDomains: ['https://www.youtube.com'],
resourceDomains: ['https://img.youtube.com'], // thumbnail images
},
},
},
};
Everything at once
A music player resource that calls an API, loads album art from a CDN, and embeds an audio player widget:
export const resource: ResourceConfig = {
description: 'Music player',
_meta: {
ui: {
csp: {
connectDomains: ['https://api.spotify.com'],
resourceDomains: ['https://i.scdn.co', 'https://fonts.googleapis.com'],
frameDomains: ['https://open.spotify.com'],
},
},
},
};
Sandbox Permissions
Beyond CSP domain declarations, the ext-apps spec defines four sandbox permissions you can request via _meta.ui.permissions:
export const resource: ResourceConfig = {
description: 'Video call widget',
_meta: {
ui: {
csp: {
connectDomains: ['https://api.daily.co'],
frameDomains: ['https://daily.co'],
},
permissions: {
camera: {},
microphone: {},
},
},
},
};
| Permission | Use case |
|---|---|
camera | Photo capture, video calls |
microphone | Voice input, audio recording |
geolocation | Maps, location-based features |
clipboardWrite | Copy-to-clipboard buttons |
Hosts are not required to grant these. ChatGPT and Claude may present a permission prompt to the user, or they may ignore the request entirely. Always use JavaScript feature detection as a fallback so your app degrades gracefully when a permission is denied.
Why fetch() Fails by Default
If you write this in a resource without declaring connectDomains:
useEffect(() => {
fetch('https://api.example.com/data')
.then(r => r.json())
.then(setData);
}, []);
The browser blocks the request before it leaves the iframe and logs a CSP violation in the console. The code itself is fine. The CSP just doesn’t include that origin.
The same applies to images. This won’t render without resourceDomains:
<img src="https://cdn.example.com/photo.jpg" />
And this won’t load without frameDomains:
<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" />
The fix is always the same: add the origin to the right domain array.
Wildcard Subdomains
You can use wildcard subdomains to allow all subdomains of a domain:
connectDomains: ['https://*.example.com']
This allows https://api.example.com, https://cdn.example.com, https://v2.api.example.com, etc.
A bare * (all origins) is rejected. CSP keywords like 'unsafe-inline', 'unsafe-eval', and 'none' are also rejected. Declare explicit origins or wildcard-subdomain patterns only.
Keep your domain lists tight. Every origin you add is an origin the resource can leak data to. Only declare what you actually use.
CSP in Development
sunpeak automatically adds localhost origins to your CSP when you run pnpm dev:
http://localhost:<port>intoresourceDomains(for bundled JS and CSS from Vite)http://localhost:<port>andws://localhost:<hmr-port>intoconnectDomains(for Vite HMR)
You don’t need to add these manually. Your declared domains are merged with the injected localhost entries.
This means you can develop against a local API (e.g., http://localhost:3001/api) without touching your CSP config. sunpeak handles it.
CSP vs CORS
CSP and CORS solve different problems and operate at different layers:
CSP is on the browser side. It controls whether the browser allows your iframe to make the request at all. You configure it with connectDomains in your resource metadata.
CORS is on the API server side. It controls whether the server accepts requests from your app’s origin. The API server configures this with Access-Control-Allow-Origin headers.
You need both for a fetch() call to work. CSP lets the request leave the iframe. CORS lets the server accept it.
Public APIs that respond with Access-Control-Allow-Origin: * work without any CORS configuration on your end. Just add the origin to connectDomains and the request works.
For APIs that allowlist specific origins, you need your MCP App to have a stable origin. By default, MCP App iframes have ephemeral origins (srcdoc or blob: URLs). Set _meta.ui.domain to assign a stable one.
The domain format is host-specific. Each host assigns sandbox origins differently:
| Host | Domain format | Example |
|---|---|---|
| Claude | SHA-256 hash of MCP server URL | a904794854a047f6.claudemcpcontent.com |
| ChatGPT | URL-derived subdomain | www-example-com.oaiusercontent.com |
For multi-host MCP Apps, sunpeak computes the correct domain per host at request time using clientInfo.name:
export const resource: ResourceConfig = {
description: 'Dashboard with CORS-restricted API',
_meta: {
ui: {
csp: {
connectDomains: ['https://api.example.com'],
},
domain: {
claude: computeClaudeDomain(SERVER_URL),
'openai-mcp': computeChatGPTDomain(SERVER_URL),
},
},
},
};
For most apps, this isn’t something you need. Public APIs with Access-Control-Allow-Origin: * (or APIs secured with API keys rather than origin allowlists) work with just connectDomains and no domain at all. You only need domain when your API server checks the Origin header and rejects unknown origins.
See the CSP & CORS docs and Resource Metadata reference for the full computation and per-host formats.
Passing Auth Tokens to fetch()
The sandboxed iframe has no access to localStorage, sessionStorage, or cookies from the parent. If your resource calls an authenticated API, you need to get tokens into the iframe safely.
The recommended pattern is to pass tokens through tool output. Your tool handler reads the token from a secure server-side store and includes it in structuredContent. The resource component picks it up from useToolData and passes it in the Authorization header:
// src/tools/get-dashboard.ts
export default async function (args: Args, _extra: ToolHandlerExtra) {
const token = process.env.INTERNAL_API_TOKEN;
return {
structuredContent: {
userId: args.userId,
apiToken: token,
},
};
}
// src/resources/dashboard/dashboard.tsx
export function DashboardResource() {
const { output } = useToolData<unknown, { userId: string; apiToken: string }>(undefined, undefined);
const [data, setData] = useState(null);
useEffect(() => {
if (!output) return;
fetch(`https://api.example.com/usage/${output.userId}`, {
headers: { Authorization: `Bearer ${output.apiToken}` },
})
.then(r => r.json())
.then(setData);
}, [output?.userId]);
// render...
}
The token travels from the server to the resource only when the tool runs, inside the structured content envelope. It never touches browser storage.
For OAuth-based authentication where users log in with their own credentials, see the MCP App Authentication guide.
For a deeper look at server-side vs client-side data fetching patterns (including polling, pagination, and error handling), see Fetching Data in MCP Apps.
Testing CSP Configuration
Bad CSP config is one of the most common issues in MCP App submissions. Either you forget to add an origin and your fetch fails silently, or you declare too many origins and the review flags it as a security concern.
Write automated tests to catch both problems. Using sunpeak’s mcp test fixture, you can assert on the CSP fields directly:
import { test, expect } from 'sunpeak/test';
test('dashboard resource has correct CSP', async ({ mcp }) => {
const result = await mcp.callTool('get-dashboard', { userId: 'user_123' });
const csp = result.content[0]._meta?.ui?.csp;
expect(csp?.connectDomains).toEqual(['https://api.example.com']);
expect(csp?.resourceDomains).toBeUndefined();
expect(csp?.frameDomains).toBeUndefined();
});
For end-to-end verification that the fetch actually works inside the sandboxed iframe, use page.route() to intercept the external call:
import { test, expect } from 'sunpeak/test';
test('dashboard fetches and displays usage data', async ({ inspector, page }) => {
await page.route('https://api.example.com/usage/user_123', route =>
route.fulfill({
status: 200,
body: JSON.stringify({ requests: 412, credits: 3.20 }),
})
);
const result = await inspector.renderTool('get-dashboard', { userId: 'user_123' });
const app = result.app();
await expect(app.locator('text=412')).toBeVisible();
});
No real API calls, no AI credits. Run pnpm test:e2e to execute against the local sunpeak Inspector.
For more CSP security testing patterns (injection testing, overly-broad domain detection, CI/CD integration), see Security Testing MCP Apps.
Get Started
npx sunpeak new
Further Reading
- CSP & CORS reference - full technical spec for all CSP fields
- Resource Metadata reference - all _meta.ui fields including domain and permissions
- Security Testing MCP Apps - automated CSP verification and injection tests
- Fetching Data in MCP Apps - server-side vs client-side fetch patterns
- MCP App Authentication - OAuth 2.1 and secure token handling
- MCP App Error Handling - handle fetch failures and cancelled states
- MCP App TypeScript Types - type reference for ResourceConfig including _meta.ui.csp
- MCP App framework
- Testing framework
Frequently Asked Questions
What is the difference between connectDomains and resourceDomains in MCP App CSP?
connectDomains controls runtime network requests: fetch(), XMLHttpRequest, and WebSocket connections. It maps to the CSP connect-src directive. resourceDomains controls static asset loading: images, scripts, stylesheets, fonts, and media. It maps to img-src, script-src, style-src, font-src, and media-src. If you are calling an API, use connectDomains. If you are loading an image or font from a CDN, use resourceDomains. Some services like Mapbox need both because they serve API responses and map tile images from the same origin.
When do I need frameDomains in an MCP App?
frameDomains controls nested iframes inside your MCP App resource. It maps to the CSP frame-src directive. Use it when embedding third-party content like YouTube videos, Vimeo players, Google Maps embeds, or any other iframe-based widget. If you are not embedding iframes, you do not need frameDomains. Without it, nested iframes are blocked entirely (frame-src none).
Why is my fetch() call blocked in an MCP App resource?
MCP App resources run in sandboxed iframes with a restrictive Content Security Policy. All external connections are blocked by default. To allow fetch() calls to a specific API, add its origin to the connectDomains array in your resource _meta.ui.csp field. Without that declaration, the browser blocks the request before it leaves the iframe.
Why is my image not loading in an MCP App resource?
Images from external URLs are blocked by CSP unless you add the image host to resourceDomains. If you are loading images from https://cdn.example.com, add that origin to resourceDomains in your resource _meta.ui.csp field. The same applies to external fonts, scripts, and stylesheets.
Can I use wildcards in MCP App CSP domains?
You can use wildcard subdomains like https://*.example.com to allow all subdomains of a domain. You cannot use a bare * wildcard to allow all origins. CSP keywords like unsafe-inline, unsafe-eval, and none are also rejected. Declare explicit origins or wildcard-subdomain patterns only.
How does sunpeak handle CSP in development?
sunpeak automatically adds localhost origins to your CSP in dev mode. When you run pnpm dev, it injects http://localhost and ws://localhost entries for Vite HMR and bundled assets. You do not need to add localhost to connectDomains or resourceDomains manually. Your declared domains are merged with the injected localhost entries.
What is the difference between CSP and CORS for MCP Apps?
CSP is configured in your resource metadata and controls what the browser allows the iframe to load or connect to. CORS is configured on the API server and controls which origins the server accepts requests from. You need CSP (connectDomains) to let the request leave the iframe, and the API server needs CORS headers to accept the request. Public APIs with Access-Control-Allow-Origin: * work without CORS configuration on your end.
How do I test that my MCP App CSP configuration is correct?
Write integration tests using the sunpeak mcp fixture. Call mcp.callTool() to get a resource response and inspect the _meta.ui.csp field. Assert that connectDomains, resourceDomains, and frameDomains contain only the origins your app needs. Write a Playwright test that loads the resource and asserts that fetch() calls succeed and images render. Run with pnpm test:e2e.