All posts

MCP App CSP Domains: connectDomains vs resourceDomains vs frameDomains

Abe Wheeler
MCP Apps MCP App Framework ChatGPT Apps Claude Connectors Claude Apps Reference Security
Configure CSP domains to control what your MCP App resource can load and connect to.

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.


The Three CSP Domain Types

Every MCP App resource runs in a sandboxed iframe. The iframe gets 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 you’ll use, and they control different things:

connectDomains (runtime network requests)

Maps to the CSP connect-src directive. Controls:

  • fetch() calls
  • XMLHttpRequest
  • WebSocket connections

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 CSS background-image URLs
  • <script src="..."> tags
  • <link rel="stylesheet" href="..."> tags
  • @font-face URLs
  • <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…UseCSP directive
Call an API with fetch()connectDomainsconnect-src
Open a WebSocketconnectDomainsconnect-src
Load images from a CDNresourceDomainsimg-src
Load fonts from Google FontsresourceDomainsfont-src
Load a script from a CDNresourceDomainsscript-src
Embed a YouTube videoframeDomainsframe-src
Embed Google MapsframeDomainsframe-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'],
      },
    },
  },
};

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.

CSP in Development

sunpeak automatically adds localhost origins to your CSP when you run sunpeak dev:

  • http://localhost:<port> into resourceDomains (for bundled JS and CSS from Vite)
  • http://localhost:<port> and ws://localhost:<hmr-port> into connectDomains (for Vite HMR)

You don’t need to add these manually. Your declared domains are merged with the injected localhost entries.

CSP vs CORS

CSP and CORS solve different problems:

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 catch: the domain format is host-specific. Each host assigns sandbox origins differently, so a cross-platform server must compute the right value per host:

HostDomain formatExample
ClaudeSHA-256 hash of MCP server URLa904794854a047f6.claudemcpcontent.com
ChatGPTURL-derived subdomainwww-example-com.oaiusercontent.com

For most apps, this isn’t something you need to worry about. 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. Pass tokens through tool output instead:

// 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.

Testing fetch() Calls

In Playwright e2e tests, use page.route() to intercept external API calls so tests stay deterministic:

import { test, expect } from '@playwright/test';
import { createInspectorUrl } from 'sunpeak/inspector';

test('dashboard fetches and displays usage data', async ({ page }) => {
  await page.route('https://api.example.com/usage/user_123', route =>
    route.fulfill({
      status: 200,
      body: JSON.stringify({ requests: 412, credits: 3.20 }),
    })
  );

  await page.goto(createInspectorUrl({ simulation: 'get-dashboard', host: 'chatgpt' }));
  const iframe = page.frameLocator('iframe');
  await expect(iframe.locator('text=412')).toBeVisible();
});

No real API calls, no AI credits. Run pnpm test:e2e to execute against the local inspector.

Get Started

Documentation →
pnpm add -g sunpeak && sunpeak new

Further Reading

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 sunpeak 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 pass auth tokens to an API from an MCP App resource?

The recommended pattern is to pass tokens through tool output rather than storing them in the browser. Your tool handler reads the token from a secure server-side store, includes it in the structured content returned to the resource, and the resource component uses it in its fetch() Authorization header. This keeps tokens out of the browser entirely until the tool runs.