|
| 1 | +/** |
| 2 | + * SEP-1865 sandbox primitives: Content-Security-Policy and Permission Policy |
| 3 | + * builders for hosts that render MCP App `ui://` bundles in iframes. |
| 4 | + * |
| 5 | + * These are pure functions — no DOM, no fetch — so they're safe to call in |
| 6 | + * Node, the renderer process, or a service worker. The spec mandates two |
| 7 | + * different CSP shapes: |
| 8 | + * |
| 9 | + * 1. **Restrictive default** (when the resource has no `_meta.ui.csp` at |
| 10 | + * all): `connect-src 'none'`, no external resource origins. |
| 11 | + * See spec §UI Resource Format → "Restrictive Default". |
| 12 | + * 2. **Constructed default** (when the resource declares any `csp` block, |
| 13 | + * even with empty arrays): `connect-src 'self'` plus declared domains, |
| 14 | + * `frame-src 'none'` unless overridden, `base-uri 'self'` unless |
| 15 | + * overridden. See spec §Security Implications → "CSP Construction". |
| 16 | + * |
| 17 | + * The host MUST always set `default-src 'none'` and `object-src 'none'`. |
| 18 | + */ |
| 19 | + |
| 20 | +/** Resource-level `_meta.ui.csp` block per SEP-1865. All fields optional. */ |
| 21 | +export interface McpAppsCspInput { |
| 22 | + /** Origins for network requests (fetch/XHR/WebSocket). Maps to `connect-src`. */ |
| 23 | + connectDomains?: string[]; |
| 24 | + /** |
| 25 | + * Origins for static resources (scripts, images, styles, fonts, media). |
| 26 | + * Maps to `script-src`, `style-src`, `img-src`, `font-src`, `media-src`. |
| 27 | + */ |
| 28 | + resourceDomains?: string[]; |
| 29 | + /** Origins for nested iframes. Maps to `frame-src`. */ |
| 30 | + frameDomains?: string[]; |
| 31 | + /** Allowed base URIs for the document. Maps to `base-uri`. */ |
| 32 | + baseUriDomains?: string[]; |
| 33 | +} |
| 34 | + |
| 35 | +/** Resource-level `_meta.ui.permissions` block per SEP-1865. */ |
| 36 | +export interface McpAppsPermissionsInput { |
| 37 | + /** Maps to Permission Policy `camera` feature. */ |
| 38 | + camera?: Record<string, unknown>; |
| 39 | + /** Maps to Permission Policy `microphone` feature. */ |
| 40 | + microphone?: Record<string, unknown>; |
| 41 | + /** Maps to Permission Policy `geolocation` feature. */ |
| 42 | + geolocation?: Record<string, unknown>; |
| 43 | + /** Maps to Permission Policy `clipboard-write` feature. */ |
| 44 | + clipboardWrite?: Record<string, unknown>; |
| 45 | +} |
| 46 | + |
| 47 | +/** Spec-mandated restrictive default applied when `_meta.ui.csp` is entirely absent. */ |
| 48 | +const RESTRICTIVE_DEFAULT_CSP = |
| 49 | + "default-src 'none'; " + |
| 50 | + "script-src 'self' 'unsafe-inline'; " + |
| 51 | + "style-src 'self' 'unsafe-inline'; " + |
| 52 | + "img-src 'self' data:; " + |
| 53 | + "media-src 'self' data:; " + |
| 54 | + "connect-src 'none'; " + |
| 55 | + "frame-src 'none'; " + |
| 56 | + "object-src 'none'; " + |
| 57 | + "base-uri 'self'"; |
| 58 | + |
| 59 | +/** |
| 60 | + * Build the `Content-Security-Policy` header value for an MCP App view per |
| 61 | + * SEP-1865 §UI Resource Format and §Security Implications. |
| 62 | + * |
| 63 | + * Pass `_meta.ui.csp` from the resolved `resources/read` content item. If the |
| 64 | + * resource omits `_meta.ui.csp` entirely, pass `undefined` to apply the |
| 65 | + * restrictive default (`connect-src 'none'`). |
| 66 | + * |
| 67 | + * The host MAY further restrict the returned policy but MUST NOT add |
| 68 | + * undeclared domains (spec §UI Resource Format → "No Loosening"). |
| 69 | + * |
| 70 | + * @example |
| 71 | + * ```ts |
| 72 | + * const meta = uiResource._meta?.ui; |
| 73 | + * res.setHeader("Content-Security-Policy", buildMcpAppsCspHeader(meta?.csp)); |
| 74 | + * ``` |
| 75 | + */ |
| 76 | +export function buildMcpAppsCspHeader(csp: McpAppsCspInput | undefined): string { |
| 77 | + if (!csp) { |
| 78 | + return RESTRICTIVE_DEFAULT_CSP; |
| 79 | + } |
| 80 | + const resourceDomains = (csp.resourceDomains ?? []).join(" "); |
| 81 | + const connectDomains = (csp.connectDomains ?? []).join(" "); |
| 82 | + const frameDomains = csp.frameDomains?.length ? csp.frameDomains.join(" ") : "'none'"; |
| 83 | + const baseUriDomains = csp.baseUriDomains?.length ? csp.baseUriDomains.join(" ") : "'self'"; |
| 84 | + const trail = (extra: string) => (extra ? ` ${extra}` : ""); |
| 85 | + return [ |
| 86 | + "default-src 'none'", |
| 87 | + `script-src 'self' 'unsafe-inline'${trail(resourceDomains)}`, |
| 88 | + `style-src 'self' 'unsafe-inline'${trail(resourceDomains)}`, |
| 89 | + `connect-src 'self'${trail(connectDomains)}`, |
| 90 | + `img-src 'self' data:${trail(resourceDomains)}`, |
| 91 | + `font-src 'self'${trail(resourceDomains)}`, |
| 92 | + `media-src 'self' data:${trail(resourceDomains)}`, |
| 93 | + `frame-src ${frameDomains}`, |
| 94 | + "object-src 'none'", |
| 95 | + `base-uri ${baseUriDomains}`, |
| 96 | + ].join("; "); |
| 97 | +} |
| 98 | + |
| 99 | +/** |
| 100 | + * Build the value for the iframe `allow` attribute (Permission Policy) from |
| 101 | + * an MCP App view's `_meta.ui.permissions` block per SEP-1865. |
| 102 | + * |
| 103 | + * Note `clipboardWrite` maps to the hyphenated `clipboard-write` Permission |
| 104 | + * Policy feature name. |
| 105 | + * |
| 106 | + * @example |
| 107 | + * ```ts |
| 108 | + * const allow = buildMcpAppsAllowAttribute(uiResource._meta?.ui?.permissions); |
| 109 | + * iframe.setAttribute("allow", allow); |
| 110 | + * ``` |
| 111 | + */ |
| 112 | +export function buildMcpAppsAllowAttribute(permissions: McpAppsPermissionsInput | undefined): string { |
| 113 | + if (!permissions) return ""; |
| 114 | + const features: string[] = []; |
| 115 | + if (permissions.camera) features.push("camera"); |
| 116 | + if (permissions.microphone) features.push("microphone"); |
| 117 | + if (permissions.geolocation) features.push("geolocation"); |
| 118 | + if (permissions.clipboardWrite) features.push("clipboard-write"); |
| 119 | + return features.join("; "); |
| 120 | +} |
0 commit comments