Widget Content Security Policy (CSP)
Hosted widget UIs (for example in ChatGPT Apps or MCP Apps) run inside a sandboxed iframe. The host applies a strict Content Security Policy: only domains you declare are allowed for img-src, connect-src, frame-src, and related directives.
NitroStack lets you declare those allowlists from the @Widget decorator. The framework mirrors metadata into the places hosts and the MCP Apps spec expect:
- Tool descriptor —
_meta['openai/widgetCSP'](snake_case:resource_domains,connect_domains,frame_domains) and a richer_meta.uiobject (camelCase:resourceUri, optionalcsp,domain,prefersBorder). - Widget template resource —
resources/readreturns HTML incontents[]and, when configured,contents[]._metaincluding_meta.ui.csp(MCP Apps style) and the same OpenAI passthrough keys.
This matches the patterns described in the OpenAI Apps SDK — Build your MCP server documentation.
Declaring CSP with @Widget
Use the object form of @Widget and supply a csp object (all keys optional):
| Key | Maps to (OpenAI) | Purpose |
|---|---|---|
resourceDomains | resource_domains | Static assets: images, fonts, scripts loaded from URLs |
connectDomains | connect_domains | fetch / XHR targets your widget calls |
frameDomains | frame_domains | Origins you embed as iframes inside the widget (use sparingly; hosts may review strictly) |
Example: allow Unsplash images for shop photos:
import { ToolDecorator as Tool, Widget, ExecutionContext, z } from '@nitrostack/core';
function myWidget(route: string) {
return {
route,
prefersBorder: true,
csp: {
resourceDomains: ['https://images.unsplash.com'],
},
};
}
@Tool({ name: 'show_catalog', description: '...', inputSchema: z.object({}) })
@Widget(myWidget('catalog'))
async showCatalog(_input: unknown, _ctx: ExecutionContext) {
return { items: [] };
}
domain and prefersBorder
These are not CSP keys but are part of the same widget metadata surface:
prefersBorder— boolean; maps toopenai/widgetPrefersBorder(host may draw a border around the widget).domain— HTTPS origin string (for examplehttps://myapp.example.com); maps toopenai/widgetDomainvia the component’s internalsubdomainfield. Required for some ChatGPT submission flows so the host can route the widget sandbox.
@Widget({
route: 'checkout',
prefersBorder: true,
domain: 'https://myapp.example.com',
csp: {
connectDomains: ['https://api.myapp.example.com'],
resourceDomains: ['https://cdn.myapp.example.com'],
},
})
String vs object @Widget
Backward compatible:
@Widget('product-card')
Object form — route is required; other fields are optional:
@Widget({
route: 'product-card',
csp: { resourceDomains: ['https://cdn.example.com'] },
})
If you pass an object without a non-empty route, the decorator throws at class evaluation time.
Where metadata appears
tools/list— each tool with a widget includes_meta.openai/widgetCSP,_meta.openai/widgetDomain, etc., when set, and_meta.uiwithresourceUriplus optionalcsp,domain,prefersBorder.resources/readon the widget’sui://…(or component) URI —contents[]._metaincludesui.csp(camelCase) and OpenAI passthrough fields for clients that read CSP from the resource body.
Practical tips
- List exact origins you use (
https://images.unsplash.com, not justhttps://unsplash.com, unless your URLs match). - If images are blocked in the host devtools, verify
resourceDomainsincludes the image host and that you restarted the server after changing@Widget. frameDomainsenables nested iframes; only enable when necessary and expect stricter app review.- For full product semantics, follow host documentation (OpenAI MCP server guide, MCP Apps
_meta.ui.csp).
See also
- UI Widgets Guide —
@Widgetand widget development - Decorators reference —
@WidgetAPI - Streamable HTTP and legacy SSE