Skip to main content
All posts

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

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingIframe SandboxContent Security PolicyCORSWeb Security
MCP App resources run inside host-controlled iframes with their own origin, CSP, and bridge rules.

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.

GateOwnerWhat it decides
Resource contractMCP server and hostWhich ui:// resource to read and whether it is valid MCP App HTML
Iframe sandboxHostWhich browser powers the View receives
Content Security PolicyHost, based on resource metadataWhich origins the View may contact, load, or frame
CORSTarget API and browserWhether View JavaScript may read a cross-origin response
AuthorizationYour serverWhether 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.parent is 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:

FieldBrowser behavior it coversCommon examples
connectDomainsfetch, XHR, WebSocket, EventSourceJSON APIs, telemetry endpoint
resourceDomainsscripts, styles, images, fonts, mediaCDN, font host, image host
frameDomainsnested iframes created by the ViewFirst-party editor or viewer
baseUriDomainstargets allowed in an HTML <base> elementA 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:

  1. The host or View calls a named MCP tool.
  2. The server validates its input and user identity.
  3. The server checks scopes, tenant, and object access.
  4. The tool performs the read or write.
  5. It returns model-readable content, shared structuredContent, and View-only _meta as 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:

StateBetter owner
OAuth access and refresh tokensServer-side token store
Durable records and preferencesDatabase behind authenticated tools
Draft that must survive reloadsServer draft with a version or idempotency key
Current tab, filter, or expanded rowReact state
Model-visible UI selectionui/update-model-context
View-only data for this tool resultTool 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
  • text or blob contains the current build
  • _meta.ui.csp, permissions, and domain match 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

ObservationLikely boundary
No request appears and console names connect-srcCSP connectDomains
Image or script is blocked by a source directiveCSP resourceDomains
Nested frame refuses to loadCSP frameDomains or child frame-ancestors
OPTIONS failsCORS preflight
Request succeeds but browser hides the responseMissing or wrong CORS response header
API returns 401 or 403Authentication, scope, tenant, or object authorization
Tool result arrives but UI stays pendingBridge 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.

TestExpected result
Resource read contractExact MIME type and reviewed _meta.ui
Declared API fetchRequest succeeds and response is readable
Undeclared API fetchCSP blocks it before the API receives traffic
Disallowed CORS originAPI omits the allow-origin response and browser blocks the read
Missing asset originBrowser reports the correct resource directive
Nested frame not declaredFrame stays blocked
Host context changesView keeps working without parent DOM access
Server tool writeServer 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

Documentation →
npx sunpeak new

Further Reading

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.