Concepts

MCP Apps CSP explained

Every MCP App runs under a strict Content Security Policy. Anything your widget loads from another origin has to be declared in _meta.ui.csp, or the host blocks it and the widget renders blank.

7 min read

What is the MCP Apps CSP?

The MCP Apps CSP is the Content Security Policy a host applies to the sandboxed iframe that renders your ui:// resource. You never write the header yourself. You list origins in the resource's _meta.ui.csp (connectDomains, resourceDomains, frameDomains, baseUriDomains) and the host builds the policy from that list. Whatever you do not declare is blocked. That one rule explains most reports of an MCP app widget that is blank in Claude or ChatGPT but works when you open the HTML file in a browser.

The rules come from the MCP Apps specification (SEP-1865, stable since 26 January 2026). If you are new to MCP Apps, start with what MCP Apps are.

The default policy when _meta.ui.csp is omitted

If your resource declares no csp at all, the specification says the host must apply this policy:

text
default-src 'none';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
media-src 'self' data:;
connect-src 'none';

Read it line by line and you know what works out of the box:

  • Inline <script> and <style> in your document run. A single self-contained HTML file is fine.
  • Images and media load only from the document's own origin or from data: URIs. An <img src="https://..."> from anywhere else is refused.
  • connect-src 'none': no fetch, no XHR, no WebSocket. Not even to your own API.
  • default-src 'none' covers everything not listed, so fonts and nested frames from other origins are blocked too.

The specification also says the host must not allow undeclared domains and must block object-src in every case. Declaring origins widens this baseline; nothing else does.

The four fields of _meta.ui.csp

Each field maps to one or more CSP directives. These mappings are in the spec and in the SDK types of @modelcontextprotocol/ext-apps:

FieldCSP directivesUse it forWhen omitted
connectDomainsconnect-srcfetch, XHR, WebSocketno network connections
resourceDomainsimg-src, script-src, style-src, font-src, media-srcimages, scripts, stylesheets, fonts, audio and videono external static resources
frameDomainsframe-srcnested iframes (a video player, an embedded map)frame-src 'none'
baseUriDomainsbase-uria <base href> pointing elsewherebase-uri 'self'

The policy lives on the resource, not on the tool. This is what the resource contents look like when a widget shows product photos from a CDN and calls a pricing API:

json
{
  "uri": "ui://shop/product-card.html",
  "mimeType": "text/html;profile=mcp-app",
  "text": "<!doctype html>...",
  "_meta": {
    "ui": {
      "prefersBorder": true,
      "csp": {
        "resourceDomains": ["https://images.example-shop.com"],
        "connectDomains": ["https://api.example-shop.com"]
      }
    }
  }
}

With the official TypeScript SDK you put the same object in the contents your resource callback returns:

ts
import { RESOURCE_MIME_TYPE, registerAppResource } from '@modelcontextprotocol/ext-apps/server'

registerAppResource(server, 'Product card', 'ui://shop/product-card.html', {}, async () => ({
  contents: [
    {
      uri: 'ui://shop/product-card.html',
      mimeType: RESOURCE_MIME_TYPE,
      text: html,
      _meta: {
        ui: {
          csp: {
            resourceDomains: ['https://images.example-shop.com'],
            connectDomains: ['https://api.example-shop.com'],
          },
        },
      },
    },
  ],
}))

Wildcards and redirects

Wildcard subdomains are supported: https://*.example.com matches every subdomain of example.com. Use them sparingly. resourceDomains feeds script-src as well as img-src, so a wildcard on a domain where anyone can upload files lets scripts from there run inside your widget. Exact origins are easier to review, and ChatGPT's review asks you to keep each list as narrow as possible.

Watch redirects. If an image URL redirects, the origin that actually serves the bytes is the one the browser checks. A placeholder service that answers from a CDN subdomain needs that subdomain declared.

How Claude and ChatGPT apply the policy

Every host has to follow the same rules, but the two big ones add their own restrictions. As of October 2026:

  • Claude blocks all external origins by default and reads _meta.ui.csp as described above. Its design guidelines say frameDomains "is restricted in Claude pending security review", so do not count on nested iframes there. If you load Claude's own font with applyHostFonts, Claude's theming guide tells you to add https://assets.claude.ai to resourceDomains.
  • ChatGPT implements the MCP Apps standard and reads the same _meta.ui.csp. OpenAI's UI guide says nested frames are blocked by default, that frameDomains is only for components that must embed specific iframe origins (with a justification at submission), and that plugin review checks the declared policy against what the UI actually does.
  • Other hosts (VS Code, Goose, Microsoft 365 Copilot and the rest in the clients guide) implement the same specification. Assume each one blocks what you do not declare, and test in the hosts you care about.

_meta.ui.domain is related but separate: it asks for a dedicated sandbox origin (useful for CORS allowlists or OAuth callbacks), and its format is host specific, such as a hashed subdomain of claudemcpcontent.com on Claude or a subdomain of oaiusercontent.com on ChatGPT.

openai/widgetCSP and the snake_case names

Apps written for the original OpenAI Apps SDK declared their policy in _meta["openai/widgetCSP"] with snake_case keys. The official migration guide maps them like this:

OpenAI Apps SDKMCP AppsNotes
_meta["openai/widgetCSP"]_meta.ui.cspthe whole object
connect_domainsconnectDomainsfetch, XHR, WebSocket
resource_domainsresourceDomainsimages, fonts, styles, scripts
frame_domainsframeDomainsnested iframes
redirect_domains(no equivalent)OpenAI only, for openExternal redirects
(no equivalent)baseUriDomainsMCP Apps only, base-uri
_meta["openai/widgetDomain"]_meta.ui.domaindedicated sandbox origin
_meta["openai/widgetPrefersBorder"]_meta.ui.prefersBordervisual boundary preference

So this:

json
{
  "openai/widgetCSP": {
    "connect_domains": ["https://api.example-shop.com"],
    "resource_domains": ["https://images.example-shop.com"]
  }
}

becomes this, and one declaration then works in ChatGPT, Claude and every other MCP Apps host:

json
{
  "ui": {
    "csp": {
      "connectDomains": ["https://api.example-shop.com"],
      "resourceDomains": ["https://images.example-shop.com"]
    }
  }
}

The full move away from window.openai is covered in ChatGPT plugin UI with MCP Apps.

Why is my MCP App widget blank? A checklist

Open your browser's developer tools on the chat page, pick the widget's iframe in the console, and look for messages that start with "Refused to load" or "Refused to connect". They name the directive and the URL. Then go through the usual causes:

  1. An image from an undeclared origin. Add the origin to resourceDomains. If the URL redirects, declare the final origin.
  2. A fetch to an API you did not declare. Add it to connectDomains. Better still, fetch on the server: let the tool gather the data and pass it in the tool result, or call a server tool from the view with app.callServerTool. No CSP entry, no CORS, no keys in the browser.
  3. A script from a CDN. A <script src> or an import from unpkg or jsDelivr needs that origin in resourceDomains. If the library also downloads files at run time (data, models, WebAssembly), add the origin to connectDomains as well. Bundling everything into one HTML file, as the official quickstart does with Vite and vite-plugin-singlefile, avoids the question.
  4. Web fonts. Google Fonts needs two origins in resourceDomains: https://fonts.googleapis.com for the stylesheet and https://fonts.gstatic.com for the files. Or use system fonts, or the host's own fonts.
  5. An iframe embed (video, map, form). It needs frameDomains, which Claude restricts and ChatGPT reviews. Often a thumbnail plus a link opened with app.openLink is the better design.
  6. The policy is on the tool instead of the resource. csp belongs in the _meta.ui of the resource. On the tool, _meta.ui carries resourceUri.
  7. You only tested outside a host. A plain browser tab applies no MCP Apps policy, so everything loads. Test in a host that enforces it before you ship.

Not every blank widget is a CSP problem. Also check that the resource's MIME type is text/html;profile=mcp-app and that your view registers its tool input and tool result handlers before it calls connect(), or it can miss the data it was supposed to render.

How Widgetry handles CSP

Widgetry is a designer for MCP Apps widgets, and it writes the CSP for you from two short lists:

  • Libraries come from a closed catalog. A widget script can import only three.js 0.186.1, GSAP 3.15.0, d3 7.9.0 and Chart.js 4.5.1, each pinned and served from jsDelivr through an import map. When a widget uses any of them, its resource declares https://cdn.jsdelivr.net in both resourceDomains and connectDomains. You never type it.
  • Image domains are declared per widget. Only https origins, with no wildcard, no credentials and no path, normalized to lower case, and at most ten. Each one widens what the host lets in, which is why the list is short and exact.
  • The document carries its own image policy. The same origins go into a <meta> CSP inside the widget document (img-src and media-src limited to data:, blob: and those origins). The editor preview therefore refuses the same images Claude refuses, so a missing domain shows up while you design instead of in the chat, and an exported document behaves the same on any server.
  • No fonts to declare. Every design uses system font stacks on purpose: a web font would need its origin in every resource CSP.
  • No fetches. A Widgetry widget renders the data the model passes to its tool and never fetches on its own, so connectDomains only ever holds the library CDN.

A widget that draws with d3 and shows photos from one image host ends up with this _meta.ui, in the hosted endpoint and in the exported manifest alike:

json
{
  "ui": {
    "prefersBorder": true,
    "csp": {
      "resourceDomains": ["https://cdn.jsdelivr.net", "https://images.example-shop.com"],
      "connectDomains": ["https://cdn.jsdelivr.net"]
    }
  }
}

The export snippet passes that _meta to registerAppResource unchanged, so the policy travels with the widget. See add a UI to your MCP server for the export and linking paths, browse the widget types in templates, or let an agent set the image domains for you through the MCP tools in create widgets with an AI agent.

Your first widget, in the chat in minutes

Pick a template, drop in your data and see it as ChatGPT or Claude will show it. Then link it from your MCP server, or let your agent build the next one.