MCP App Resource Metadata: CSP, Permissions, and ChatGPT Widget Fields
MCP App resource metadata tells hosts how to sandbox, secure, and present your app iframe.
Most MCP App setup guides stop after the tool points at a ui:// resource. That gets an iframe on screen, but it does not answer the next set of questions developers search for:
- Why is my
fetch()blocked? - Where do I request clipboard, camera, or geolocation access?
- Should
_meta["openai/widgetCSP"]go on the tool or the resource? - What is the difference between
_meta.ui.cspand_meta["openai/widgetDomain"]? - Why does the same ChatGPT App look framed in one host and borderless in another?
Those questions all live in resource metadata.
TL;DR: Tool metadata routes a tool to a UI resource. Resource metadata controls the iframe that renders that UI. Put CSP, sandbox permissions, widget domains, border hints, and resource descriptions on the resource. Prefer standard _meta.ui fields for portable MCP Apps, then add ChatGPT compatibility keys when you need ChatGPT-specific behavior. Test resource metadata with protocol assertions before debugging React.
Resource Metadata vs Tool Metadata
An MCP App has at least two pieces:
- A tool the model can call.
- An HTML resource the host renders in a sandboxed iframe.
The tool and resource both have metadata, but they answer different questions.
| Metadata | Goes on | Answers |
|---|---|---|
_meta.ui.resourceUri | Tool | Which ui:// resource should the host render? |
_meta.ui.visibility | Tool | Can the model call this tool, can the app call it, or both? |
_meta.ui.csp | Resource | What network, asset, and frame origins can the iframe use? |
_meta.ui.permissions | Resource | Which browser permissions does the iframe request? |
_meta.ui.domain | Resource | Which origin should host use for this iframe, if supported? |
_meta.ui.prefersBorder | Resource | Should the host frame this widget with a border, if supported? |
The practical rule is simple: routing fields belong on the tool. Sandbox and presentation fields belong on the resource.
If you put CSP on the tool, the host may ignore it. If you put resourceUri on the resource, the host still does not know which tool should render that resource.
The Standard Resource Shape
Here is a compact resource metadata example in the standard MCP Apps shape:
import type { ResourceConfig } from 'sunpeak';
export const resource: ResourceConfig = {
description: 'Show a customer dashboard',
_meta: {
ui: {
csp: {
connectDomains: ['https://api.example.com'],
resourceDomains: ['https://cdn.example.com'],
frameDomains: ['https://www.youtube.com'],
},
permissions: {
clipboardWrite: {},
},
domain: 'https://widgets.example.com',
prefersBorder: true,
},
},
};
Different frameworks expose this through different APIs, but the host-facing idea is the same: the resource advertises what the iframe needs before the host renders it.
In a sunpeak project, the resource config lives with the React resource component. That keeps resource behavior near the code that needs it, while the tool file handles input schema, annotations, and model-facing descriptions.
CSP Controls What the Iframe Can Reach
MCP App resources run in sandboxed iframes. The host applies a restrictive Content Security Policy by default, which means external calls and assets are blocked unless the resource allows them.
There are three domain lists most apps use:
| Field | Allows | Example |
|---|---|---|
connectDomains | fetch(), XMLHttpRequest, WebSocket | https://api.example.com |
resourceDomains | images, scripts, styles, fonts, media | https://cdn.example.com |
frameDomains | nested iframes | https://www.youtube.com |
Use the narrowest domain list that matches what your component does.
export const resource: ResourceConfig = {
description: 'Show live order status',
_meta: {
ui: {
csp: {
connectDomains: ['https://orders.example.com'],
},
},
},
};
If the resource fetches from https://orders.example.com and loads avatars from https://cdn.example.com, it needs both:
csp: {
connectDomains: ['https://orders.example.com'],
resourceDomains: ['https://cdn.example.com'],
}
Do not use CSP as a substitute for auth. CSP controls what the browser allows the iframe to reach. Your API still needs normal authentication, authorization, rate limits, and CORS behavior.
For the full domain breakdown, including wildcard subdomains and frameDomains, see the MCP App CSP guide.
Permissions Request Browser Capabilities
Some MCP Apps need browser capabilities:
- A voice note app needs microphone access.
- A photo intake flow needs camera access.
- A local store finder needs geolocation.
- A report builder needs clipboard write access for a copy button.
Those requests belong in resource metadata:
export const resource: ResourceConfig = {
description: 'Record a voice note',
_meta: {
ui: {
permissions: {
microphone: {},
},
},
},
};
A permissions declaration is a request, not a guarantee. The host may prompt the user, deny the request by policy, ignore unsupported permissions, or only allow the permission in certain display modes.
Your React code should still handle the browser path:
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 });
begin(stream);
} catch {
setError('Microphone access was denied.');
}
}
Do the same for geolocation and clipboard. Ask for the permission in metadata so the host can prepare the sandbox, then handle denial in code because users and hosts can still say no.
Domains and Origins
Resource domains are easy to mix up because the words sound similar.
resourceDomains in CSP controls external asset loading. It answers, “Can this iframe load images, scripts, fonts, or media from this origin?”
domain controls the iframe origin when the host supports a dedicated widget domain. It answers, “Which origin should this app iframe itself use?”
Those are different concerns.
_meta: {
ui: {
domain: 'https://widgets.example.com',
csp: {
connectDomains: ['https://api.example.com'],
resourceDomains: ['https://cdn.example.com'],
},
},
}
Use a dedicated widget domain when origin isolation matters. For example, you may want a stable origin for stricter browser policy, cookie separation, allowlists, analytics isolation, or enterprise review.
Many simple apps do not need it. A static card that renders server-provided structuredContent can usually run on the host’s default sandbox origin.
Border Hints Are Presentation Hints
Some hosts can render widgets with or without a visible frame. A weather card or receipt summary may look better with a border. A fullscreen dashboard or canvas-style app may look better without one.
Resource metadata can express that preference:
export const resource: ResourceConfig = {
description: 'Show a compact receipt card',
_meta: {
ui: {
prefersBorder: true,
},
},
};
Treat this as a hint. Hosts can ignore it or adapt it to their own design system. Your component should still look right with a border, without a border, in inline mode, in fullscreen mode, and in both themes.
The safest approach is to avoid hard-coding assumptions about the host chrome. Use host-provided safe area, theme, and display mode hooks, then test each display mode locally.
ChatGPT Compatibility Keys
ChatGPT Apps use the same broad MCP App model, but many examples and older integrations use OpenAI-specific _meta keys.
These keys usually live on the resource:
| ChatGPT key | Standard field | Purpose |
|---|---|---|
_meta["openai/widgetCSP"] | _meta.ui.csp | Declares allowed connect and resource domains |
_meta["openai/widgetCSP"].redirect_domains | No direct _meta.ui.csp equivalent | Allowlists trusted openExternal redirect targets |
_meta["openai/widgetDomain"] | _meta.ui.domain | Requests a dedicated widget origin |
_meta["openai/widgetPrefersBorder"] | _meta.ui.prefersBorder | Requests bordered host presentation |
_meta["openai/widgetDescription"] | resource description | Describes what the widget does |
Use the standard MCP Apps fields as your source of truth for new cross-host apps. Add ChatGPT compatibility keys when your target ChatGPT runtime or SDK path expects them.
One exception matters for ChatGPT: trusted external link redirects still use redirect_domains inside _meta["openai/widgetCSP"]. If your widget calls a host API such as openExternal for a known destination, include those redirect origins there in addition to your standard CSP fields.
export const resource: ResourceConfig = {
description: 'Show a customer dashboard',
_meta: {
ui: {
csp: {
connectDomains: ['https://api.example.com'],
resourceDomains: ['https://cdn.example.com'],
},
domain: 'https://widgets.example.com',
prefersBorder: true,
},
'openai/widgetCSP': {
connect_domains: ['https://api.example.com'],
resource_domains: ['https://cdn.example.com'],
redirect_domains: ['https://example.com'],
},
'openai/widgetDomain': 'https://widgets.example.com',
'openai/widgetPrefersBorder': true,
'openai/widgetDescription': 'Shows customer account health and recent activity.',
},
};
Do not let the standard fields and compatibility fields drift apart. If you include both, write a test that checks both contain the same origins and presentation intent.
For ChatGPT App submission, plan to use a real, unique widget domain that you control. For local development or a cross-host prototype, the host’s default sandbox origin is often enough.
A Practical Metadata Checklist
Before shipping an MCP App resource, check these items:
- Does every UI-capable tool point at the correct
ui://resource? - Does each resource declare only the CSP domains it needs?
- Are browser permissions declared only when the UI actually uses them?
- Does the component handle denied permissions?
- If you use a dedicated widget domain, is that domain real and under your control?
- If you add ChatGPT compatibility keys, do they match the standard fields?
- Does the UI render correctly with and without a border?
- Do automated tests read the resource and assert the metadata?
Most bugs in this area come from putting fields in the wrong place or copying an example that targets one host while you are trying to build a cross-host app.
How to Test Resource Metadata
Start with protocol tests. They are faster than browser tests and catch the most common mistakes.
import { test, expect } from 'sunpeak/test';
test('dashboard resource declares exact security metadata', async ({ mcp }) => {
const tools = await mcp.listTools();
const tool = tools.find((item) => item.name === 'show-dashboard');
const resourceUri = tool?._meta?.ui?.resourceUri;
expect(resourceUri).toMatch(/^ui:\/\//);
const resource = await mcp.readResource(resourceUri);
const meta = resource.contents[0]._meta;
expect(meta.ui.csp.connectDomains).toEqual(['https://api.example.com']);
expect(meta.ui.csp.resourceDomains).toEqual(['https://cdn.example.com']);
expect(meta.ui.permissions).toEqual({ clipboardWrite: {} });
expect(meta.ui.prefersBorder).toBe(true);
expect(meta['openai/widgetCSP']).toEqual({
connect_domains: ['https://api.example.com'],
resource_domains: ['https://cdn.example.com'],
redirect_domains: ['https://example.com'],
});
});
Then add browser tests for behavior:
- Render the resource in the local inspector.
- Assert allowed images and API calls work.
- Assert unexpected external requests fail or never happen.
- Click copy, record, camera, or location controls and verify graceful fallback.
- Run the same test in inline, PiP, and fullscreen modes when the host supports them.
With sunpeak, you can run these checks locally and in CI without a paid ChatGPT or Claude account. Use the inspector to reproduce host context, then run pnpm test in CI so metadata changes get reviewed like any other contract.
Where sunpeak Helps
sunpeak keeps tool metadata and resource metadata near the code that owns each concern.
Tool files define schemas, annotations, model-facing descriptions, resource links, and tool visibility. Resource files define UI behavior, CSP, permissions, and host presentation hints. The inspector then renders the app in replicated ChatGPT and Claude runtimes so you can test whether those declarations behave the way you expect.
That split matters as your app grows. A tool may be model-only, app-only, or shared. A resource may need API access, CDN images, clipboard writes, or no external access at all. Keeping those contracts explicit makes the app easier to test and easier to submit for host review.
Start with npx sunpeak new, then add a resource metadata test before you wire up real APIs. It is much cheaper to catch a blocked domain, missing permission, or stale ChatGPT compatibility field before you start debugging inside a live host.
Get Started
npx sunpeak new
Further Reading
- MCP App tool metadata - resourceUri, visibility, and app-only tools
- MCP App CSP domains - connectDomains, resourceDomains, and frameDomains
- MCP App conformance testing - verify protocol metadata in CI
- Security testing MCP Apps - CSP, auth, annotations, and input validation
- Testing MCP App data flow - content, structuredContent, _meta, and host bridge state
- MCP App lifecycle - connect(), tool input, results, and teardown
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- sunpeak docs - Resource Metadata
- sunpeak docs - CSP & CORS
- MCP Apps overview - Model Context Protocol
- Apps SDK reference - OpenAI
Frequently Asked Questions
What is MCP App resource metadata?
MCP App resource metadata is the _meta attached to the HTML resource that a host renders in a sandboxed iframe. It tells the host how to secure and present that iframe, including content security policy domains, browser permission requests, domain isolation, border hints, and host-specific compatibility fields.
What is the difference between MCP App tool metadata and resource metadata?
Tool metadata tells the host which UI resource to render and who can call the tool. Resource metadata tells the host how the rendered iframe should behave. Put resourceUri and visibility on the tool. Put CSP, permissions, widget domain, border preferences, and resource descriptions on the resource.
Where do CSP settings go in an MCP App?
CSP settings belong on the MCP App resource metadata, usually under _meta.ui.csp in the standard MCP Apps shape or _meta["openai/widgetCSP"] for ChatGPT compatibility. Use connectDomains for fetch and WebSocket requests, resourceDomains for images, scripts, styles, fonts, and media, and frameDomains for nested iframes.
What browser permissions can an MCP App request?
MCP App resources can request permissions such as camera, microphone, geolocation, and clipboardWrite. The host decides whether to grant them, usually with a user prompt or host policy. Your app should still use browser feature detection and handle denied permissions gracefully.
What is openai/widgetDomain in ChatGPT Apps?
_meta["openai/widgetDomain"] is a ChatGPT compatibility field that asks ChatGPT to serve a widget from a dedicated origin instead of the default shared sandbox origin. Use it when you need stricter origin isolation, cookie separation, a stable domain for security policy, or ChatGPT App submission readiness. It belongs on the resource, not the tool.
Do Claude Connectors use the same resource metadata as ChatGPT Apps?
Interactive Claude Connectors are MCP Apps, so the standard MCP Apps resource metadata applies. ChatGPT-specific fields such as _meta["openai/widgetCSP"], _meta["openai/widgetDomain"], and _meta["openai/widgetPrefersBorder"] are compatibility aliases for ChatGPT. Prefer the standard _meta.ui fields as your source of truth when building cross-host apps.
How do I test MCP App resource metadata?
Write a protocol test that calls the UI tool, reads the referenced resource, and asserts the resource _meta contains the exact CSP domains, permissions, and compatibility keys you expect. Add a Playwright test that loads the resource in a local inspector and verifies blocked network calls, allowed assets, permission fallbacks, and host presentation states.