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:
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': nofetch, 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:
| Field | CSP directives | Use it for | When omitted |
|---|---|---|---|
connectDomains | connect-src | fetch, XHR, WebSocket | no network connections |
resourceDomains | img-src, script-src, style-src, font-src, media-src | images, scripts, stylesheets, fonts, audio and video | no external static resources |
frameDomains | frame-src | nested iframes (a video player, an embedded map) | frame-src 'none' |
baseUriDomains | base-uri | a <base href> pointing elsewhere | base-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:
{
"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:
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.cspas described above. Its design guidelines sayframeDomains"is restricted in Claude pending security review", so do not count on nested iframes there. If you load Claude's own font withapplyHostFonts, Claude's theming guide tells you to addhttps://assets.claude.aitoresourceDomains. - ChatGPT implements the MCP Apps standard and reads the same
_meta.ui.csp. OpenAI's UI guide says nested frames are blocked by default, thatframeDomainsis 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 SDK | MCP Apps | Notes |
|---|---|---|
_meta["openai/widgetCSP"] | _meta.ui.csp | the whole object |
connect_domains | connectDomains | fetch, XHR, WebSocket |
resource_domains | resourceDomains | images, fonts, styles, scripts |
frame_domains | frameDomains | nested iframes |
redirect_domains | (no equivalent) | OpenAI only, for openExternal redirects |
| (no equivalent) | baseUriDomains | MCP Apps only, base-uri |
_meta["openai/widgetDomain"] | _meta.ui.domain | dedicated sandbox origin |
_meta["openai/widgetPrefersBorder"] | _meta.ui.prefersBorder | visual boundary preference |
So this:
{
"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:
{
"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:
- An image from an undeclared origin. Add the origin to
resourceDomains. If the URL redirects, declare the final origin. - A
fetchto an API you did not declare. Add it toconnectDomains. 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 withapp.callServerTool. No CSP entry, no CORS, no keys in the browser. - A script from a CDN. A
<script src>or animportfrom unpkg or jsDelivr needs that origin inresourceDomains. If the library also downloads files at run time (data, models, WebAssembly), add the origin toconnectDomainsas well. Bundling everything into one HTML file, as the official quickstart does with Vite andvite-plugin-singlefile, avoids the question. - Web fonts. Google Fonts needs two origins in
resourceDomains:https://fonts.googleapis.comfor the stylesheet andhttps://fonts.gstatic.comfor the files. Or use system fonts, or the host's own fonts. - An iframe embed (video, map, form). It needs
frameDomains, which Claude restricts and ChatGPT reviews. Often a thumbnail plus a link opened withapp.openLinkis the better design. - The policy is on the tool instead of the resource.
cspbelongs in the_meta.uiof the resource. On the tool,_meta.uicarriesresourceUri. - 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.netin bothresourceDomainsandconnectDomains. You never type it. - Image domains are declared per widget. Only
httpsorigins, 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-srcandmedia-srclimited todata:,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
connectDomainsonly 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:
{
"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.