MCP App Resource Metadata: CSP, Permissions, and ChatGPT Widget Fields (August 2026)

MCP App resource metadata tells hosts how to sandbox, secure, and present your app iframe.
An MCP App can return valid HTML and still fail before React mounts. The host may block an API request, reject a sandbox domain, omit a browser permission, or frame the View differently than your design expects. Resource metadata is the contract that controls those decisions.
The current MCP Apps standard puts that contract under _meta.ui on a ui:// resource. The details matter because the metadata can appear at two protocol locations, its secure defaults deny external access, and some fields have different rules in Claude and ChatGPT.
TL;DR: Put resourceUri and visibility on tools. Put csp, permissions, domain, and prefersBorder on resources. The metadata on a resources/read content item overrides listing metadata. Declare only the origins and permissions the View needs, treat domain as host-specific, keep ChatGPT compatibility fields in sync, and test the protocol contract before debugging the UI.
Resource Metadata and Tool Metadata Have Different Jobs
An interactive MCP tool joins two protocol objects:
- The tool tells the host which UI resource belongs with its result.
- The resource supplies the HTML and tells the host how to run it.
| Field | Placement | Question it answers |
|---|---|---|
_meta.ui.resourceUri | Tool descriptor | Which ui:// View should render this tool result? |
_meta.ui.visibility | Tool descriptor | Can the model, the app, or both call this tool? |
_meta.ui.csp | Resource descriptor or content item | Which external origins can the View reach? |
_meta.ui.permissions | Resource descriptor or content item | Which browser capabilities does the View request? |
_meta.ui.domain | Resource descriptor or content item | Which stable sandbox origin should the host assign? |
_meta.ui.prefersBorder | Resource descriptor or content item | Does the View prefer host-provided border and background chrome? |
This split is useful for security review. The model-facing tool description does not need browser policy, and the iframe resource does not decide who can call a server tool. See the tool metadata guide for resourceUri, visibility, and app-only tools.
The Current MCP App Resource Shape
The stable MCP Apps View protocol uses HTML with the text/html;profile=mcp-app MIME type. A complete resource policy can include four CSP lists, four browser permissions, a domain, and a border hint:
import type { ResourceConfig } from 'sunpeak';
export const resource: ResourceConfig = {
title: 'Customer dashboard',
description: 'Review account health and recent customer activity',
mimeType: 'text/html;profile=mcp-app',
_meta: {
ui: {
csp: {
connectDomains: [
'https://api.example.com',
'wss://events.example.com',
],
resourceDomains: ['https://cdn.example.com'],
frameDomains: [],
baseUriDomains: [],
},
permissions: {
clipboardWrite: {},
},
prefersBorder: true,
},
},
};
All fields are optional. Omission is meaningful: no external connections, resources, or nested frames are allowed by default; only same-origin base URLs are allowed; no extra browser permissions are requested; and the host chooses its default origin and border treatment.
Metadata Can Appear in Two Protocol Responses
The MCP Apps specification lets a server return UIResourceMeta in two places:
| Location | Best use |
|---|---|
Resource descriptor in resources/list | Static policy that a host can inspect during discovery |
Content item in resources/read | The policy applied to the fetched HTML, including request-specific values |
When both contain _meta.ui, the content-item value takes precedence. Hosts must check the content item first and fall back to the listing descriptor.
That rule prevents a subtle production bug. A server can advertise one CSP during discovery but return a different policy with the HTML. If the content item accidentally omits an API origin, the listing entry does not merge it back in. The content-item _meta.ui object wins as a whole.
For static apps, return the same policy in both places. That gives the host an early reviewable descriptor and keeps the rendered policy identical. For dynamic policy, generate the content-item object deliberately and test every branch.
sunpeak co-locates resource metadata with the resource component and sends the resolved metadata in both resources/list and resources/read. That keeps a single ResourceConfig as the source of truth.
CSP Has Four Separate Origin Lists
Resource CSP is an allowlist. Each field maps to a different browser directive, so adding an origin to the wrong list does not grant the access you expected.
| Field | Browser behavior | Secure default |
|---|---|---|
connectDomains | fetch, XHR, EventSource, and WebSocket through connect-src | No external connections |
resourceDomains | Scripts, styles, images, fonts, and media | No external resources |
frameDomains | Nested iframes through frame-src | frame-src 'none' |
baseUriDomains | URLs allowed in an HTML <base> element | base-uri 'self' |
Declare origins, not paths. Use https://api.example.com, not https://api.example.com/v1/orders. Include wss:// separately when the app opens a WebSocket. The standard supports wildcard subdomains such as https://*.example.com, but a short explicit list is easier to audit.
baseUriDomains deserves special attention because a <base> element changes how every relative URL resolves. Most bundled MCP Apps do not need one, so an empty list or the same-origin default is safer.
csp: {
connectDomains: ['https://api.example.com', 'wss://events.example.com'],
resourceDomains: ['https://cdn.example.com'],
frameDomains: [],
baseUriDomains: [],
}
A host may make this policy stricter, but it must not silently allow undeclared external origins. Claude currently restricts frameDomains pending security review. ChatGPT also treats nested frames as a higher-review feature, so prefer a host-mediated external link when an embed is optional.
CSP and CORS Solve Different Problems
CSP answers, “May this View try to contact that origin?” CORS answers, “Will that server accept this browser origin?” You often need both.
Suppose a View fetches https://api.example.com/orders:
- Add
https://api.example.comtoconnectDomainsso the iframe can send the request. - Configure the API to return an
Access-Control-Allow-Originvalue that accepts the View’s sandbox origin. - Keep authentication and authorization on the API or MCP server. CSP is not an auth control.
If the API accepts Access-Control-Allow-Origin: *, a stable sandbox origin may be unnecessary. If the API uses an origin allowlist, _meta.ui.domain can give the View a deterministic origin. That field has host-specific rules, covered below.
Do not put bearer tokens or long-lived API keys in the HTML resource. Keep sensitive work behind MCP tools where the server can authenticate the user, enforce scopes, rate-limit calls, and redact logs. Direct browser calls are most useful for public assets, low-risk APIs, or real-time streams designed for browser clients.
Permissions Are Requests, Not Grants
The standard permission object supports four fields:
| Field | Browser permission policy | Typical use |
|---|---|---|
camera | camera | Photo or video capture |
microphone | microphone | Voice input or recording |
geolocation | geolocation | Maps and nearby search |
clipboardWrite | clipboard-write | Copy buttons and exports |
Each value is an empty object because presence requests the capability:
permissions: {
microphone: {},
clipboardWrite: {},
}
The host may add iframe permission-policy directives, show a prompt, deny the request, or ignore a permission it does not support. Your UI still needs feature detection and a denial state:
async function startRecording() {
if (!navigator.mediaDevices?.getUserMedia) {
setError('Microphone access is not available in this host.');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
beginRecording(stream);
} catch {
setError('Microphone access was denied.');
}
}
Host support is part of the contract. Claude’s current mobile WebViews do not allow camera, microphone, or location access, even when metadata requests them. Build a fallback such as file upload, typed input, or a server-side tool, then test it in a mobile host profile.
ui.domain Is Host-Specific
_meta.ui.domain requests a stable sandbox origin. It is useful when an OAuth redirect, CORS allowlist, or API-key restriction needs a predictable origin. It is not a general URL for your MCP server or product site.
| Host | Current rule |
|---|---|
| Claude | Exact {hash}.claudemcpcontent.com, where hash is the first 32 hex characters of the SHA-256 digest of the full connector URL |
| ChatGPT | A unique dedicated origin is required when submitting a plugin with UI; ChatGPT otherwise defaults to its shared sandbox origin |
| Other MCP App hosts | Host-defined; omit the field unless the host documents a format |
Claude validates the domain against the full connector URL. A trailing slash, path change, or different URL string changes the hash and can produce Invalid ui.domain format or ui.domain mismatch. Compute it from the exact production connector URL.
A cross-host server cannot safely send one hard-coded value to every host. sunpeak supports a map keyed by MCP clientInfo.name and resolves it before returning the resource:
import type { ResourceConfig } from 'sunpeak';
import { computeClaudeDomain, computeChatGPTDomain } from 'sunpeak/mcp';
const serverUrl = 'https://mcp.example.com/mcp';
export const resource: ResourceConfig = {
title: 'Customer dashboard',
description: 'Review customer account health',
mimeType: 'text/html;profile=mcp-app',
_meta: {
ui: {
csp: {
connectDomains: ['https://api.example.com'],
},
domain: {
claude: computeClaudeDomain(serverUrl),
'openai-mcp': computeChatGPTDomain(serverUrl),
},
prefersBorder: true,
},
},
};
The current sunpeak production server can also compute the host default when it knows the public server URL. If you register resources by hand, inspect clientInfo.name and return the correct string in resources/read.
prefersBorder Is a Hint With Visible Consequences
prefersBorder asks the host to add or omit a visible boundary and background around the View:
_meta: {
ui: {
prefersBorder: false,
},
}
Set an explicit value because host defaults differ. Claude currently renders an unspecified View borderless on web and bordered on mobile. A borderless View runs closer to the container edge, so it must honor hostContext.safeAreaInsets. A bordered View may receive host padding, but the component should still handle narrow widths and fullscreen mode.
Treat the value as a preference. The host owns its chrome and can apply a different presentation. Test both bordered and borderless states so spacing, focus rings, and backgrounds remain usable.
ChatGPT Compatibility Fields in 2026
ChatGPT now documents the standard nested MCP Apps fields as the preferred resource metadata. Several OpenAI-specific keys remain for compatibility or behavior that the standard does not express.
| ChatGPT field | Standard field | Current use |
|---|---|---|
_meta["openai/widgetCSP"] | _meta.ui.csp | Legacy CSP mirror; still owns redirect_domains |
_meta["openai/widgetDomain"] | _meta.ui.domain | Legacy dedicated-origin alias |
_meta["openai/widgetPrefersBorder"] | _meta.ui.prefersBorder | Legacy border alias |
_meta["openai/widgetDescription"] | Resource description, with different model-facing behavior | Helps ChatGPT avoid repeating what the component already shows |
Use _meta.ui.csp as the primary policy for new cross-host apps. Add _meta["openai/widgetCSP"] when an older ChatGPT path expects it or when the View calls window.openai.openExternal() for trusted destinations. The standard has no redirect allowlist, so redirect_domains remains ChatGPT-specific.
_meta: {
ui: {
csp: {
connectDomains: ['https://api.example.com'],
resourceDomains: ['https://cdn.example.com'],
},
prefersBorder: true,
},
'openai/widgetCSP': {
connect_domains: ['https://api.example.com'],
resource_domains: ['https://cdn.example.com'],
redirect_domains: ['https://app.example.com'],
},
'openai/widgetPrefersBorder': true,
'openai/widgetDescription':
'Shows customer health, alerts, and recent activity.',
}
If you publish both shapes, generate them from one policy object or assert both in tests. A stale compatibility allowlist can make a View work in one host and fail in ChatGPT.
Version Resource URIs When the Contract Changes
Hosts may prefetch and cache UI resources because tool metadata points to the resource before a tool call. Keep the HTML and its metadata versioned together.
If a release changes the bundle, CSP, permission set, stable-origin assumptions, or bridge behavior, use a new URI such as:
ui://customer-dashboard/v4/index.html
Then update the tool’s _meta.ui.resourceUri in the same release. During rollback, keep the prior resource available long enough for conversations that still reference it. The resource caching guide covers version changes and rollout order in detail.
Test the Protocol Contract First
Start with a protocol test that inspects the descriptor. sunpeak mirrors a resource’s ResourceConfig metadata into the listing and content item, so listResources() catches the configured policy without parsing HTML:
import { test, expect } from 'sunpeak/test';
test('dashboard resource exposes its reviewed policy', async ({ mcp }) => {
const { resources } = await mcp.listResources();
const dashboard = resources.find(
(resource) => resource.uri === 'ui://customer-dashboard'
);
expect(dashboard?.mimeType).toBe('text/html;profile=mcp-app');
expect(dashboard?._meta?.ui).toMatchObject({
csp: {
connectDomains: ['https://api.example.com'],
resourceDomains: ['https://cdn.example.com'],
frameDomains: [],
baseUriDomains: [],
},
permissions: {
clipboardWrite: {},
},
prefersBorder: true,
});
});
Add a tool assertion in the same suite so the link cannot drift:
test('dashboard tool points at the dashboard resource', async ({ mcp }) => {
const { tools } = await mcp.listTools();
const dashboard = tools.find((tool) => tool.name === 'show-dashboard');
expect(dashboard?._meta?.ui).toMatchObject({
resourceUri: 'ui://customer-dashboard',
});
});
Protocol assertions catch missing fields and wrong placement. Browser tests prove enforcement and fallback behavior. Cover these paths in every target host profile:
- Allowed API, WebSocket, asset, and font origins load successfully.
- An undeclared request is blocked and does not leak data.
- The API accepts the actual sandbox
Originheader and rejects unapproved origins. - Permission success, user denial, missing browser API, and mobile fallback all render usable states.
- The View works with and without host border chrome, at 320px, and in each supported display mode.
- Claude receives its hash-derived domain, while ChatGPT receives its own dedicated origin.
- External links use host APIs and the right ChatGPT
redirect_domainsentry where needed.
With sunpeak, the local Inspector can reproduce ChatGPT and Claude host contexts, and the same tests can run in CI without paid host accounts or AI credits. Use live-host smoke tests for the parts a replica cannot prove, such as production plugin review, Claude’s domain validation, real permission prompts, and host policy changes.
Production Review Checklist
Before shipping the resource, verify:
- Every UI tool points to an existing, versioned
ui://resource. - The resource MIME type is
text/html;profile=mcp-app. - Listing and content-item metadata agree unless an override is intentional.
- Each CSP list contains only origins the built View actually uses.
baseUriDomainsandframeDomainsare empty unless the HTML needs them.- The API’s CORS policy accepts the real sandbox origin.
- Requested permissions have tested denial and unsupported-host fallbacks.
domainmatches each host’s documented format and exact production connector URL.prefersBorderis explicit, and both presentation states remain usable.- Standard and ChatGPT compatibility metadata stay synchronized.
- Protocol, browser, mobile, and narrow live-host smoke tests pass.
Resource metadata is small, but it defines the iframe’s operating limits. Review it like an API schema: keep one source of truth, version changes with the HTML, assert the serialized protocol shape, and verify enforcement in the hosts you support.
Start with npx sunpeak new, declare the narrowest resource policy that works, then run the Inspector and automated tests before connecting the server to ChatGPT or Claude.
Get Started
npx sunpeak newFurther Reading
- MCP App tool metadata - resourceUri, visibility, and app-only tools
- MCP App CSP domains - connectDomains, resourceDomains, frameDomains, and baseUriDomains
- MCP App iframe sandbox, origins, and CORS
- MCP App browser permissions - camera, microphone, geolocation, and clipboard
- MCP App resource caching and versioned UI resources
- Cross-host testing for ChatGPT Apps and Claude Connectors
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- sunpeak resource metadata reference
- MCP Apps specification - resource metadata
- OpenAI Plugins reference - component resource metadata
- Claude MCP Apps design guidelines
Frequently Asked Questions
What is MCP App resource metadata?
MCP App resource metadata is the _meta.ui object attached to a ui:// resource descriptor or resource content item. It tells the host how to secure and present the rendered View, including allowed network and asset origins, requested browser permissions, a host-specific stable sandbox domain, and a border preference.
Where should I put _meta.ui resource metadata?
You can put _meta.ui on the resources/list descriptor, the contents item returned by resources/read, or both. Content-item metadata takes precedence when both exist. Put static metadata at listing level so hosts can review it during discovery, and make sure the content item contains the policy that must apply to the rendered View.
What is the difference between MCP App tool metadata and resource metadata?
Tool metadata connects a tool to a View and controls tool visibility. Put resourceUri and visibility under the tool _meta.ui object. Resource metadata controls the View sandbox and presentation. Put csp, permissions, domain, and prefersBorder under the resource _meta.ui object.
What CSP fields can an MCP App resource declare?
The standard MCP Apps CSP object supports connectDomains for fetch, XHR, and WebSocket; resourceDomains for scripts, styles, images, fonts, and media; frameDomains for nested iframes; and baseUriDomains for HTML base URLs. Omitted external domains stay blocked. Hosts may restrict the policy further but must not allow undeclared origins.
What browser permissions can an MCP App request?
The MCP Apps resource metadata can request camera, microphone, geolocation, and clipboardWrite. A declaration only asks the host to enable the related browser permission policy. The host, browser, or user can still deny it, and Claude mobile currently does not allow camera, microphone, or location access in MCP Apps.
What does _meta.ui.domain mean for MCP Apps?
_meta.ui.domain asks the host for a stable sandbox origin. Its format is host-specific, so it is not simply your MCP server or product website domain. Claude requires the first 32 hexadecimal characters of the SHA-256 hash of the full connector URL followed by .claudemcpcontent.com. ChatGPT requires a unique dedicated origin for a submitted plugin with UI.
Do I still need openai/widgetCSP for a ChatGPT App?
Use standard _meta.ui.csp for new ChatGPT App security metadata. The legacy openai/widgetCSP key remains useful as a compatibility mirror, and its redirect_domains field is still required to allow trusted window.openai.openExternal destinations because standard _meta.ui.csp has no redirect equivalent.
How do I test MCP App resource metadata?
Use protocol tests to inspect resources/list and assert exact metadata, then use browser tests in each target host profile to verify allowed requests, blocked requests, permission denial, stable-origin CORS, borders, and mobile behavior. sunpeak can run these checks locally and in CI without spending ChatGPT or Claude credits.