MCP App Iframe Sandbox: Origins, CSP, postMessage, and CORS (September 2026)

MCP App resources run inside host-controlled iframes with their own origin, CSP, and bridge rules.
An MCP App can fail before its React tree renders. The host may reject the resource, the sandbox may block an asset, CSP may stop a request from leaving, CORS may stop JavaScript from reading the response, or the app may wait for a bridge message it never registered to receive.
These failures look similar in the UI, often as a blank card or a spinner. They need different fixes because the browser enforces each boundary at a different point.
TL;DR: Treat an MCP App as untrusted UI inside a host-owned sandbox. Put CSP on the UI resource, use CORS for the iframe’s actual origin, and keep authentication and authorization on the server. Use the MCP Apps SDK for the JSON-RPC bridge instead of custom parent-window messages. Debug the path in order: tool metadata, resource read, MIME type, effective CSP, browser network, CORS, authentication, JavaScript, then bridge lifecycle.
The request passes through five security gates
An MCP App resource crosses several independent gates before it can show private data or complete an action.
| Gate | Owner | What it decides |
|---|---|---|
| Resource contract | MCP server and host | Which ui:// resource to read and whether it is valid MCP App HTML |
| Iframe sandbox | Host | Which browser powers the View receives |
| Content Security Policy | Host, based on resource metadata | Which origins the View may contact, load, or frame |
| CORS | Target API and browser | Whether View JavaScript may read a cross-origin response |
| Authorization | Your server | Whether this user may perform this action on this object |
Passing one gate says nothing about the next. Adding an API origin to connectDomains does not make its CORS policy accept the iframe. A successful CORS response does not authorize an invoice update. A valid OAuth token does not let the iframe bypass CSP.
Keeping those decisions separate makes error reports much more useful. Log “CSP blocked api.example.com” or “API returned 403 for invoice 123,” not “iframe failed.”
The host owns the iframe boundary
MCP Apps Views run in sandboxed iframes. The sandbox keeps the View away from the host’s DOM, cookies, storage, and privileged browser behavior. Communication with the host uses MCP JSON-RPC over postMessage, but application code should use the SDK or framework hooks rather than raw messages.
A web host can add an intermediate sandbox proxy between the host page and the View. The proxy has a different origin, receives the HTML and security metadata, creates the inner View, and forwards protocol messages. This means an app must not assume:
window.parentis the host page- the parent origin has a known product domain
- there is only one iframe
- same-origin DOM access is part of the contract
- top-level navigation, popups, or direct downloads are available
The portable contract is the bridge. Register handlers before App.connect(), then use methods such as callServerTool(), openLink(), downloadFile(), sendMessage(), and updateModelContext() only when the negotiated host capability exists.
Resource metadata defines the security request
The host gets UI security metadata from the ui:// resource. Put the effective metadata on the content item returned by resources/read:
import { readFile } from 'node:fs/promises';
import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
const html = await readFile(new URL('./dist/index.html', import.meta.url), 'utf8');
registerAppResource(
server,
'invoice-review',
'ui://invoice-review/v3.html',
{ description: 'Review one invoice before approval' },
async () => ({
contents: [
{
uri: 'ui://invoice-review/v3.html',
mimeType: RESOURCE_MIME_TYPE,
text: html,
_meta: {
ui: {
domain: 'https://invoice-review.example.com',
csp: {
connectDomains: ['https://api.example.com'],
resourceDomains: ['https://static.example.com'],
},
prefersBorder: true,
},
},
},
],
})
);
The MIME type is text/html;profile=mcp-app. Use RESOURCE_MIME_TYPE so a package update, typo, or compatibility layer cannot split your resource declaration from the SDK.
Static metadata may also appear on a resources/list entry, which lets a host inspect it before reading the content. When both locations contain _meta.ui, the content item’s value takes precedence. Test the result of resources/read because that is the policy attached to the HTML the host renders.
Each CSP field has one job
MCP Apps resource CSP uses origin lists:
| Field | Browser behavior it covers | Common examples |
|---|---|---|
connectDomains | fetch, XHR, WebSocket, EventSource | JSON APIs, telemetry endpoint |
resourceDomains | scripts, styles, images, fonts, media | CDN, font host, image host |
frameDomains | nested iframes created by the View | First-party editor or viewer |
baseUriDomains | targets allowed in an HTML <base> element | A controlled app base URL |
List full origins, including the scheme. A production https://api.example.com entry does not include http://localhost:3000, another subdomain, or an arbitrary port.
If CSP metadata is absent, compliant hosts use restrictive defaults. External connections are blocked, nested frames are blocked, and object embeds are blocked. A host may make the policy stricter, but it must not silently grant undeclared origins.
That makes CSP an allowlist, not a deployment note. Generate it from reviewed configuration, keep it small, and add a test that fails when code introduces a new outbound origin without updating the resource contract.
resourceDomains does not allow fetch
An image loaded with <img src> needs resourceDomains. Fetching the same image as a blob needs connectDomains. If code fetches a font file and passes the bytes to a renderer, that request also uses connectDomains.
The destination alone does not pick the field. The browser operation picks the CSP directive.
frameDomains governs the child frame, not its network
When a View embeds https://editor.example.com, its own CSP must allow that origin in frameDomains. The embedded page then runs under its own CSP and origin rules. The parent View’s connectDomains and resourceDomains do not control requests made inside that child page.
That is a separate security boundary, so review the embedded page, its auth, and its outbound network behavior. OpenAI’s current iframe guidance says nested frames are blocked by default and asks developers to keep frameDomains narrow.
Use baseUriDomains only when the document needs <base>
A <base> tag changes how every relative URL resolves. Most bundled MCP Apps do not need it because their HTML includes inlined JavaScript and CSS or absolute asset URLs. Omitting baseUriDomains keeps the base URI restricted and removes one way for an injected tag to redirect relative links and form targets.
A dedicated domain is an origin, not a hosting URL
_meta.ui.domain asks the host for a stable sandbox origin. The app HTML still arrives through resources/read; the host does not fetch the HTML from that domain.
A stable origin helps when:
- an API allowlists exact browser origins
- the View needs predictable origin-scoped storage for recoverable UI state
- OAuth or API configuration needs a known browser origin
- production review requires a dedicated origin
The domain format is host-specific. Do not compute or hardcode one format for every host. Check each host’s current rules and use a portable server-tool path when the host cannot supply the requested origin.
For ChatGPT plugin submission, OpenAI’s component resource reference currently requires a unique _meta.ui.domain for UI. ChatGPT uses a hosted sandbox origin when it is absent during development, but a production submission should declare the app’s reviewed origin.
The domain field does not authenticate the View. Origin allowlisting can reduce accidental cross-site access, but the API must still validate the user’s session, scopes, tenant, and object permission.
CSP and CORS solve different halves of fetch
Suppose the View origin is https://invoice-review.example.com and it calls https://api.example.com/invoices/123.
First, the browser checks the View’s connect-src, which the host built from connectDomains. If the API origin is missing, the browser blocks the request. The API sees no traffic.
Next, CORS checks whether the API allows JavaScript from the View origin to read the response. A preflighted request may look like this:
OPTIONS /invoices/123 HTTP/1.1
Origin: https://invoice-review.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: authorization, content-type
The API should validate the origin against a fixed allowlist, then answer with the exact policy it supports:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://invoice-review.example.com
Access-Control-Allow-Methods: PATCH, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Vary: Origin
Add Access-Control-Allow-Credentials: true only when the browser must send cookies or HTTP authentication. When credentials are enabled, Access-Control-Allow-Origin: * is invalid.
Do not mirror any incoming Origin into the response without checking it. That turns an exact allowlist into “every website that asks.”
CORS is not authentication or CSRF protection
CORS prevents a page from reading a disallowed cross-origin response. It does not prove who made the request, and it does not stop every request from reaching the server.
Your API still needs:
- an authenticated user or service identity
- tenant and object-level authorization
- CSRF protection when browser cookies authorize writes
- input validation
- replay and idempotency controls for sensitive actions
- audit records for changes
If an iframe would need a long-lived bearer token to call the API, move that operation behind an MCP server tool. The server already sits at the trusted data boundary and can enforce the same authorization for model-initiated and View-initiated calls.
Prefer server tools for private work
Direct browser fetch is useful for public maps, public images, short-lived signed URLs, and low-risk services designed for browser clients. Private records and write actions fit the MCP tool path better:
- The host or View calls a named MCP tool.
- The server validates its input and user identity.
- The server checks scopes, tenant, and object access.
- The tool performs the read or write.
- It returns model-readable
content, sharedstructuredContent, and View-only_metaas needed.
This path keeps private credentials out of HTML and reduces origin configuration. It also gives you one audit point.
import { useCallServerTool } from 'sunpeak';
export function ApproveInvoice({ invoiceId }: { invoiceId: string }) {
const callServerTool = useCallServerTool();
async function approve() {
await callServerTool({
name: 'approve_invoice',
arguments: { invoiceId },
});
}
return (
<button type="button" onClick={approve}>
Approve invoice
</button>
);
}
The UI call does not create authority. The tool must treat invoiceId as untrusted and repeat every server-side check.
Permissions add another independent gate
Camera, microphone, geolocation, and clipboard write are requested under _meta.ui.permissions. The host may map those requests to the inner iframe’s Permission Policy.
_meta: {
ui: {
permissions: {
microphone: {},
},
},
}
That declaration does not grant access. The host can decline it, the browser API can be missing, and the user can reject or later revoke permission. Check the browser API at runtime, ask after a clear user action, and provide a denied-state fallback.
Some browser APIs remain unavailable by host policy even when a normal top-level web page could use them. OpenAI’s plugin security guide, for example, documents host restrictions on several privileged browser APIs. Feature detection and a server-side alternative keep the View usable.
Use the bridge instead of custom postMessage
postMessage is the transport beneath the MCP Apps bridge. Raw message listeners make it easy to accept the wrong source, race initialization, collide with reserved methods, or depend on one host’s frame structure.
Use the App lifecycle directly when working at the protocol level:
import { App } from '@modelcontextprotocol/ext-apps';
const app = new App({ name: 'invoice-review', version: '1.0.0' }, {});
app.addEventListener('toolinput', ({ arguments: input }) => {
renderInput(input);
});
app.addEventListener('toolresult', (result) => {
renderResult(result);
});
await app.connect();
Register handlers before connect() so early input cannot arrive between initialization and handler setup. Let the SDK validate protocol messages and use getHostCapabilities() before optional host actions.
If a custom integration truly needs a raw message listener, check event.source, validate the complete JSON-RPC shape, reject unknown methods, and never pass a browser message straight into a server write.
Cookies and storage are weak foundations
The View cannot read host cookies, localStorage, or sessionStorage. Its own origin may have storage, but browsers can partition it by top-level site, hosts can use temporary origins, and users can clear it.
Use storage according to its lifetime:
| State | Better owner |
|---|---|
| OAuth access and refresh tokens | Server-side token store |
| Durable records and preferences | Database behind authenticated tools |
| Draft that must survive reloads | Server draft with a version or idempotency key |
| Current tab, filter, or expanded row | React state |
| Model-visible UI selection | ui/update-model-context |
| View-only data for this tool result | Tool result _meta |
If you use a View cookie, give it Secure, the narrowest path and lifetime, and a server-backed recovery path. Do not put bearer tokens in localStorage.
Debug blank iframes by finding the first failed boundary
Work in protocol and browser order. Later symptoms often disappear once the first failure is fixed.
1. Inspect the tool descriptor
Confirm the invoked tool points to the expected _meta.ui.resourceUri. Check the exact URI and version. A stale resource URI can make the host render an older bundle even when the server code changed.
2. Read the resource through MCP
Call resources/read for that URI and inspect the returned content item:
- URI matches the request
- MIME type is
text/html;profile=mcp-app textorblobcontains the current build_meta.ui.csp,permissions, anddomainmatch the release
Do not infer the effective policy from a TypeScript config file. Assert the wire result.
3. Check the iframe console
An exception before the first render, an invalid import, or a hydration error can produce the same empty card as a policy failure. Save the first console error and its stack before reloading.
4. Use the network panel as a branch point
| Observation | Likely boundary |
|---|---|
No request appears and console names connect-src | CSP connectDomains |
| Image or script is blocked by a source directive | CSP resourceDomains |
| Nested frame refuses to load | CSP frameDomains or child frame-ancestors |
OPTIONS fails | CORS preflight |
| Request succeeds but browser hides the response | Missing or wrong CORS response header |
| API returns 401 or 403 | Authentication, scope, tenant, or object authorization |
| Tool result arrives but UI stays pending | Bridge handler or state transition |
The API log is also useful. No request points back toward CSP or DNS. A logged preflight without the real request points toward CORS. A real request with a 403 belongs to authorization, not CORS.
5. Test a minimal result
Render the same resource with the smallest valid structuredContent. If that works, add fields and actions one at a time. Turn the failing input into a deterministic fixture so the blank state cannot return unnoticed.
Test security policies as contracts
Do not stop at a happy-path screenshot. Add negative cases that prove the sandbox blocks undeclared behavior.
| Test | Expected result |
|---|---|
| Resource read contract | Exact MIME type and reviewed _meta.ui |
| Declared API fetch | Request succeeds and response is readable |
| Undeclared API fetch | CSP blocks it before the API receives traffic |
| Disallowed CORS origin | API omits the allow-origin response and browser blocks the read |
| Missing asset origin | Browser reports the correct resource directive |
| Nested frame not declared | Frame stays blocked |
| Host context changes | View keeps working without parent DOM access |
| Server tool write | Server repeats auth, validation, confirmation, and idempotency checks |
Inspect console errors and failed requests in browser tests. A page can look correct while an undeclared telemetry call or font request fails in the background.
Reproduce iframe states locally with sunpeak
sunpeak 0.20.85 is an open-source, server-agnostic MCP App framework and testing tool. Its Inspector can render tools and resources from an existing MCP server, while simulations pin tool input, results, host context, and error states for repeatable browser tests.
Inspect a running server:
npx sunpeak inspect --server http://localhost:3000/mcp
Scaffold tests for the same server:
npx sunpeak test init --server http://localhost:3000/mcp
Add fixtures for a valid CSP, missing connectDomains, a failed preflight, an unauthorized server-tool call, a blank result, and a long error message. Use a narrow real-host test to confirm its dedicated-origin and review rules after the portable resource passes locally.
The iframe boundary gets easier to debug when each policy has one owner and one test. Start with the MCP App framework, or use sunpeak to inspect the resource contract you already have.
Get Started
npx sunpeak newFurther Reading
- MCP App CSP domains - connectDomains, resourceDomains, and frameDomains
- MCP App resource metadata - CSP, permissions, and UI fields
- MCP App lifecycle - resource loading and the host bridge
- MCP App permissions - camera, microphone, location, and clipboard
- MCP App authentication - OAuth 2.1 and secure token handling
- Security testing MCP Apps - sandbox, CSP, auth, and input validation
- MCP App capability detection and fallbacks
- MCP App framework
- ChatGPT App framework
- sunpeak testing framework
- MCP Apps overview - Model Context Protocol
- MCP Apps CSP and CORS guide
- Add UI to an MCP server - OpenAI Developers
- Plugin security and privacy - OpenAI Developers
Frequently Asked Questions
Why do MCP Apps run in sandboxed iframes?
MCP Apps use sandboxed iframes so interactive UI cannot read the host page, conversation DOM, account cookies, or storage. The host controls the sandbox, builds a Content Security Policy from resource metadata, and exposes a narrow JSON-RPC bridge for tool data and app actions. This lets a host render server-provided HTML without giving it the privileges of the host application.
What is the difference between MCP App CSP and CORS?
CSP controls which network destinations and assets the iframe may use. CORS is enforced by the browser against the target API response and decides whether iframe JavaScript may read that response. A request can satisfy one policy and fail the other. Neither CSP nor CORS authenticates a user or authorizes access to a record.
Where should MCP App CSP metadata be declared?
Declare CSP in the UI resource metadata under _meta.ui.csp. Put the effective metadata on the resource content returned by resources/read. Static metadata may also appear on the resources/list entry, but the content item takes precedence. Use connectDomains, resourceDomains, frameDomains, and baseUriDomains only for origins the app actually needs.
Does _meta.ui.domain host my MCP App?
No. The domain field requests a stable, dedicated sandbox origin from the host. It does not point to the server that hosts your HTML because MCP App HTML arrives through resources/read. The accepted domain format is host-specific, and some hosts may restrict or ignore a requested value. Use a stable origin when an API must allowlist the iframe origin through CORS.
Can an MCP App iframe use cookies or localStorage?
The iframe cannot read host cookies or host storage. Storage on its own sandbox origin may also be partitioned, cleared, or unavailable across hosts. Keep durable business state and OAuth tokens on the server. Use local UI state for recoverable presentation details, and treat iframe cookies as an optional optimization rather than the source of truth.
Should an MCP App call an API directly from the iframe?
Direct fetch is reasonable for public or low-risk data when CSP and CORS are configured. Use an MCP server tool for private data, write actions, secrets, or authorization-sensitive work. Server tools keep credentials out of the iframe and give the model, UI, and backend one validated contract.
How do I debug a blank MCP App iframe?
Verify the tool resourceUri, read the ui:// resource directly, confirm the MCP App MIME type, inspect effective resource metadata, and open the iframe console. Then separate CSP failures, CORS preflight failures, authentication errors, missing assets, JavaScript exceptions, and bridge initialization errors. Test a minimal tool result locally before repeating the same case in a real host.