diff --git a/.husky/pre-commit b/.husky/pre-commit index b0d5ff53..a2143df3 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -21,8 +21,10 @@ STAGED=$(git diff --name-only --cached) npm run build:all npm run prettier:fix -# Re-stage originally-staged files (prettier may have reformatted them) -echo "$STAGED" | xargs -r git add +# Re-stage originally-staged files (prettier may have reformatted them). +# Filter to existing files only — deleted files are already staged for removal +# and `git add` would fail on them. +echo "$STAGED" | while IFS= read -r f; do [ -e "$f" ] && git add "$f"; done # Also stage generated files (regenerated by build:all from spec.types.ts) git add src/generated/ diff --git a/.playwright-mcp/page-2026-01-29T16-44-20-392Z.png b/.playwright-mcp/page-2026-01-29T16-44-20-392Z.png new file mode 100644 index 00000000..55c20c62 Binary files /dev/null and b/.playwright-mcp/page-2026-01-29T16-44-20-392Z.png differ diff --git a/.playwright-mcp/page-2026-01-29T16-45-07-754Z.png b/.playwright-mcp/page-2026-01-29T16-45-07-754Z.png new file mode 100644 index 00000000..481d0bab Binary files /dev/null and b/.playwright-mcp/page-2026-01-29T16-45-07-754Z.png differ diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index 36ecee0d..3095398b 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -1,4 +1,4 @@ -import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge"; +import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, type McpUiResourceSandbox, buildAllowAttribute, buildSandboxAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -72,6 +72,7 @@ interface UiResourceData { html: string; csp?: McpUiResourceCsp; permissions?: McpUiResourcePermissions; + sandbox?: McpUiResourceSandbox; } export interface ToolCallInfo { @@ -151,8 +152,9 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise { // Prevent reload if (iframe.src) return Promise.resolve(false); - iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); + // Set sandbox attribute on outer iframe. In practice, these will match the inner + // iframe sandbox capabilities, but at a minimum they must be a superset of the + // inner iframe's sandbox allowances. + iframe.setAttribute("sandbox", buildSandboxAttribute(sandbox)); // Set Permission Policy allow attribute based on requested permissions const allowAttribute = buildAllowAttribute(permissions); @@ -214,10 +220,10 @@ export async function initializeApp( new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!), ); - // Load inner iframe HTML with CSP and permissions metadata - const { html, csp, permissions } = await appResourcePromise; - log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "", permissions ? `(Permissions: ${JSON.stringify(permissions)})` : ""); - await appBridge.sendSandboxResourceReady({ html, csp, permissions }); + // Load inner iframe HTML with CSP, permissions, and sandbox metadata + const { html, csp, permissions, sandbox } = await appResourcePromise; + log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "", permissions ? `(Permissions: ${JSON.stringify(permissions)})` : "", sandbox ? `(Sandbox: ${JSON.stringify(sandbox)})` : ""); + await appBridge.sendSandboxResourceReady({ html, csp, permissions, sandbox }); // Wait for inner iframe to be ready log.info("Waiting for MCP App to initialize..."); diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index 3d488a79..4f787e4c 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -432,10 +432,10 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI useEffect(() => { const iframe = iframeRef.current!; - // First get CSP and permissions from resource, then load sandbox + // First get CSP, permissions, and sandbox from resource, then load sandbox // CSP is set via HTTP headers (tamper-proof), permissions via iframe allow attribute - toolCallInfo.appResourcePromise.then(({ csp, permissions }) => { - loadSandboxProxy(iframe, csp, permissions).then((firstTime) => { + toolCallInfo.appResourcePromise.then(({ csp, permissions, sandbox }) => { + loadSandboxProxy(iframe, csp, permissions, sandbox).then((firstTime) => { // The `firstTime` check guards against React Strict Mode's double // invocation (mount → unmount → remount simulation in development). // Outside of Strict Mode, this `useEffect` runs only once per diff --git a/examples/basic-host/src/sandbox.ts b/examples/basic-host/src/sandbox.ts index 6f1dd93d..d52749da 100644 --- a/examples/basic-host/src/sandbox.ts +++ b/examples/basic-host/src/sandbox.ts @@ -1,5 +1,5 @@ import type { McpUiSandboxProxyReadyNotification, McpUiSandboxResourceReadyNotification } from "@modelcontextprotocol/ext-apps/app-bridge"; -import { buildAllowAttribute } from "@modelcontextprotocol/ext-apps/app-bridge"; +import { buildAllowAttribute, buildSandboxAttribute } from "@modelcontextprotocol/ext-apps/app-bridge"; const ALLOWED_REFERRER_PATTERN = /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/; @@ -41,12 +41,15 @@ try { // iframe on a separate origin. It creates an inner iframe for untrusted HTML // content. Per the specification, the Host and the Sandbox MUST have different // origins. -const inner = document.createElement("iframe"); -inner.style = "width:100%; height:100%; border:none;"; -inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); -// Note: allow attribute is set later when receiving sandbox-resource-ready notification -// based on the permissions requested by the app -document.body.appendChild(inner); +// +// The inner iframe is created lazily when the `sandbox-resource-ready` +// notification arrives (not at file top) so that its `sandbox` attribute is set +// to the negotiated value *before* the element is inserted into the DOM. +// Per the HTML spec, mutating the `sandbox` attribute on an already-attached +// iframe only takes effect after a navigation; `document.write()` is NOT a +// navigation, so setting the attribute after attachment would leave the old +// (or default) flags in effect. +let inner: HTMLIFrameElement | null = null; const RESOURCE_READY_NOTIFICATION: McpUiSandboxResourceReadyNotification["method"] = "ui/notifications/sandbox-resource-ready"; @@ -85,15 +88,27 @@ window.addEventListener("message", async (event) => { if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) { const { html, sandbox, permissions } = event.data.params; - if (typeof sandbox === "string") { - inner.setAttribute("sandbox", sandbox); - } + // sandbox can be a string (raw override) or object (structured flags) + const sandboxAttr = typeof sandbox === "string" + ? sandbox + : buildSandboxAttribute(sandbox); + console.log("[Sandbox] Setting sandbox attribute:", sandboxAttr); + + // Create the inner iframe with the final sandbox attribute before + // inserting into the DOM, so negotiated flags are enforced immediately. + inner = document.createElement("iframe"); + inner.style.cssText = "width:100%; height:100%; border:none;"; + inner.setAttribute("sandbox", sandboxAttr); + // Set Permission Policy allow attribute if permissions are requested const allowAttribute = buildAllowAttribute(permissions); if (allowAttribute) { console.log("[Sandbox] Setting allow attribute:", allowAttribute); inner.setAttribute("allow", allowAttribute); } + + document.body.appendChild(inner); + if (typeof html === "string") { // Use document.write instead of srcdoc (which the CesiumJS Map won't work with) const doc = inner.contentDocument || inner.contentWindow?.document; @@ -112,7 +127,7 @@ window.addEventListener("message", async (event) => { inner.contentWindow.postMessage(event.data, "*"); } } - } else if (event.source === inner.contentWindow) { + } else if (inner && event.source === inner.contentWindow) { if (event.origin !== OWN_ORIGIN) { console.error( "[Sandbox] Rejecting message from inner iframe with unexpected origin:", diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index e7c343b8..4f67c795 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -194,6 +194,40 @@ interface UIResourceMeta { */ clipboardWrite?: {}, }, + /** + * Sandbox flags requested by the UI + * + * Servers declare which sandbox capabilities their UI needs beyond baseline (allow-scripts, allow-same-origin). + * Hosts MAY honor these by adding flags to the iframe sandbox attribute. + * Apps SHOULD NOT assume flags are granted; use feature detection as fallback. + * + */ + sandbox?: { + /** + * Allow form submission + * + * Maps to sandbox `allow-forms` flag + */ + forms?: {}, + /** + * Allow window.open popups + * + * Maps to sandbox `allow-popups` flag + */ + popups?: {}, + /** + * Allow alert/confirm/prompt/print dialogs + * + * Maps to sandbox `allow-modals` flag + */ + modals?: {}, + /** + * Allow file downloads + * + * Maps to sandbox `allow-downloads` flag + */ + downloads?: {}, + }, /** * Dedicated origin for view * @@ -500,6 +534,7 @@ If the Host is a web page, it MUST wrap the View and communicate with it through - Block dangerous features (`object-src 'none'`) - Apply restrictive defaults if no CSP metadata is provided - If `permissions` is declared, the Sandbox MAY set the inner iframe's `allow` attribute accordingly + - If `sandbox` is declared (as object or string), the Sandbox MAY set the inner iframe's `sandbox` attribute accordingly (baseline: `allow-scripts allow-same-origin`) 6. The Sandbox MUST forward messages sent by the Host to the View, and vice versa, for any method that doesn't start with `ui/notifications/sandbox-`. This includes lifecycle messages, e.g., `ui/initialize` request & `ui/notifications/initialized` notification both sent by the View. The Host MUST NOT send any request or notification to the View before it receives an `initialized` notification. 7. The Sandbox SHOULD NOT create/send any requests to the Host or to the View (this would require synthesizing new request ids). 8. The Host MAY forward any message from the View (coming via the Sandbox) to the MCP Apps server, for any method that doesn't start with `ui/`. While the Host SHOULD ensure the View's MCP connection is spec-compliant, it MAY decide to block some messages or subject them to further user approval. @@ -682,6 +717,13 @@ interface HostCapabilities { /** Approved base URIs for the document (base-uri directive). */ baseUriDomains?: string[]; }; + /** Sandbox flags granted by the host. */ + flags?: { + forms?: {}; + popups?: {}; + modals?: {}; + downloads?: {}; + }; }; } ``` @@ -1349,7 +1391,13 @@ These messages are reserved for web-based hosts that implement the recommended d microphone?: {}, geolocation?: {}, clipboardWrite?: {}, - } + }, + sandbox?: { // Sandbox flags from resource metadata (or raw string override) + forms?: {}, // Allow form submission (allow-forms) + popups?: {}, // Allow window.open popups (allow-popups) + modals?: {}, // Allow alert/confirm/prompt/print (allow-modals) + downloads?: {}, // Allow file downloads (allow-downloads) + } | string // Raw sandbox attribute override } } ``` diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index adbe62cd..af8f5b57 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -17,6 +17,7 @@ import { App } from "./app"; import { AppBridge, getToolUiResourceUri, + buildSandboxAttribute, isToolVisibilityModelOnly, isToolVisibilityAppOnly, type McpUiHostCapabilities, @@ -1048,6 +1049,92 @@ describe("getToolUiResourceUri", () => { }); }); +describe("buildSandboxAttribute", () => { + const BASELINE = "allow-scripts allow-same-origin"; + + describe("baseline handling", () => { + it("returns baseline for undefined", () => { + expect(buildSandboxAttribute(undefined)).toBe(BASELINE); + }); + + it("returns baseline for empty object", () => { + expect(buildSandboxAttribute({})).toBe(BASELINE); + }); + }); + + describe("single flags", () => { + it("adds forms flag", () => { + expect(buildSandboxAttribute({ forms: {} })).toBe( + `${BASELINE} allow-forms`, + ); + }); + + it("adds popups flag", () => { + expect(buildSandboxAttribute({ popups: {} })).toBe( + `${BASELINE} allow-popups`, + ); + }); + + it("adds modals flag", () => { + expect(buildSandboxAttribute({ modals: {} })).toBe( + `${BASELINE} allow-modals`, + ); + }); + + it("adds downloads flag", () => { + expect(buildSandboxAttribute({ downloads: {} })).toBe( + `${BASELINE} allow-downloads`, + ); + }); + }); + + describe("multiple flags", () => { + it("adds multiple flags", () => { + const result = buildSandboxAttribute({ + forms: {}, + popups: {}, + modals: {}, + downloads: {}, + }); + expect(result).toContain("allow-forms"); + expect(result).toContain("allow-popups"); + expect(result).toContain("allow-modals"); + expect(result).toContain("allow-downloads"); + expect(result.startsWith(BASELINE)).toBe(true); + }); + + it("adds forms and popups", () => { + const result = buildSandboxAttribute({ forms: {}, popups: {} }); + expect(result).toContain("allow-forms"); + expect(result).toContain("allow-popups"); + expect(result).not.toContain("allow-modals"); + expect(result).not.toContain("allow-downloads"); + }); + }); + + describe("undefined values in object", () => { + it("ignores undefined values", () => { + const result = buildSandboxAttribute({ + forms: {}, + popups: undefined, + modals: undefined, + downloads: undefined, + }); + expect(result).toBe(`${BASELINE} allow-forms`); + }); + + it("returns baseline when all values are undefined", () => { + const result = buildSandboxAttribute({ + forms: undefined, + popups: undefined, + modals: undefined, + downloads: undefined, + }); + expect(result).toBe(BASELINE); + }); + }); +}); + describe("isToolVisibilityModelOnly", () => { describe("returns true", () => { it("when visibility is exactly ['model']", () => { diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 014420e8..9d1560d9 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -81,6 +81,7 @@ import { McpUiRequestDisplayModeRequestSchema, McpUiRequestDisplayModeResult, McpUiResourcePermissions, + McpUiResourceSandbox, McpUiToolMeta, } from "./types"; export * from "./types"; @@ -188,6 +189,53 @@ export function buildAllowAttribute( return allowList.join("; "); } +/** + * Mapping of McpUiResourceSandbox keys to sandbox attribute values. + * @internal + */ +const SANDBOX_FLAG_MAP: Record = { + forms: "allow-forms", + popups: "allow-popups", + modals: "allow-modals", + downloads: "allow-downloads", +}; + +/** + * Baseline sandbox flags always included - required for SDK operation. + * @internal + */ +const BASELINE_SANDBOX = "allow-scripts allow-same-origin"; + +/** + * Build iframe `sandbox` attribute string from sandbox configuration. + * + * Maps McpUiResourceSandbox to sandbox attribute format, always including + * baseline flags (allow-scripts allow-same-origin). + * + * @param sandbox - Sandbox flags requested by the UI resource + * @returns Space-separated sandbox flags including baseline + * + * @example + * ```typescript + * const sandbox = buildSandboxAttribute({ forms: {}, popups: {} }); + * // Returns: "allow-scripts allow-same-origin allow-forms allow-popups" + * iframe.setAttribute("sandbox", sandbox); + * ``` + */ +export function buildSandboxAttribute( + sandbox: McpUiResourceSandbox | undefined, +): string { + if (!sandbox) return BASELINE_SANDBOX; + + const additional = Object.entries(sandbox) + .filter(([_, v]) => v !== undefined) + .map(([k]) => SANDBOX_FLAG_MAP[k as keyof McpUiResourceSandbox]) + .filter(Boolean); + + if (additional.length === 0) return BASELINE_SANDBOX; + return [BASELINE_SANDBOX, ...additional].join(" "); +} + /** * Options for configuring {@link AppBridge `AppBridge`} behavior. * diff --git a/src/generated/schema.json b/src/generated/schema.json index 522f1592..7b63065a 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -409,6 +409,37 @@ } }, "additionalProperties": false + }, + "flags": { + "description": "Sandbox flags granted by the host (forms, popups, modals, downloads).", + "type": "object", + "properties": { + "forms": { + "description": "Allow form submission (sandbox `allow-forms` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "popups": { + "description": "Allow window.open popups (sandbox `allow-popups` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "modals": { + "description": "Allow alert/confirm/prompt/print dialogs (sandbox `allow-modals` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "downloads": { + "description": "Allow file downloads (sandbox `allow-downloads` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -2749,6 +2780,37 @@ } }, "additionalProperties": false + }, + "flags": { + "description": "Sandbox flags granted by the host (forms, popups, modals, downloads).", + "type": "object", + "properties": { + "forms": { + "description": "Allow form submission (sandbox `allow-forms` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "popups": { + "description": "Allow window.open popups (sandbox `allow-popups` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "modals": { + "description": "Allow alert/confirm/prompt/print dialogs (sandbox `allow-modals` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "downloads": { + "description": "Allow file downloads (sandbox `allow-downloads` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -4179,6 +4241,37 @@ }, "additionalProperties": false }, + "sandbox": { + "description": "Sandbox flags requested by the UI.", + "type": "object", + "properties": { + "forms": { + "description": "Allow form submission (sandbox `allow-forms` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "popups": { + "description": "Allow window.open popups (sandbox `allow-popups` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "modals": { + "description": "Allow alert/confirm/prompt/print dialogs (sandbox `allow-modals` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "downloads": { + "description": "Allow file downloads (sandbox `allow-downloads` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "domain": { "description": "Dedicated origin for view sandbox.\n\nUseful when views need stable, dedicated origins for OAuth callbacks, CORS policies, or API key allowlists.\n\n**Host-dependent:** The format and validation rules for this field are determined by each host. Servers MUST consult host-specific documentation for the expected domain format. Common patterns include:\n- Hash-based subdomains (e.g., `{hash}.claudemcpcontent.com`)\n- URL-derived subdomains (e.g., `www-example-com.oaiusercontent.com`)\n\nIf omitted, host uses default sandbox origin (typically per-conversation).", "type": "string" @@ -4221,6 +4314,37 @@ }, "additionalProperties": false }, + "McpUiResourceSandbox": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "forms": { + "description": "Allow form submission (sandbox `allow-forms` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "popups": { + "description": "Allow window.open popups (sandbox `allow-popups` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "modals": { + "description": "Allow alert/confirm/prompt/print dialogs (sandbox `allow-modals` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "downloads": { + "description": "Allow file downloads (sandbox `allow-downloads` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "McpUiResourceTeardownRequest": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -4279,8 +4403,42 @@ "description": "HTML content to load into the inner iframe." }, "sandbox": { - "description": "Optional override for the inner iframe's sandbox attribute.", - "type": "string" + "description": "Sandbox configuration: structured flags object or raw attribute string override.", + "anyOf": [ + { + "type": "object", + "properties": { + "forms": { + "description": "Allow form submission (sandbox `allow-forms` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "popups": { + "description": "Allow window.open popups (sandbox `allow-popups` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "modals": { + "description": "Allow alert/confirm/prompt/print dialogs (sandbox `allow-modals` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "downloads": { + "description": "Allow file downloads (sandbox `allow-downloads` flag).", + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "string" + } + ] }, "csp": { "description": "CSP configuration from resource metadata.", diff --git a/src/generated/schema.test.ts b/src/generated/schema.test.ts index 47e59788..17af4c3c 100644 --- a/src/generated/schema.test.ts +++ b/src/generated/schema.test.ts @@ -47,6 +47,10 @@ export type McpUiSandboxProxyReadyNotificationSchemaInferredType = z.infer< typeof generated.McpUiSandboxProxyReadyNotificationSchema >; +export type McpUiResourceSandboxSchemaInferredType = z.infer< + typeof generated.McpUiResourceSandboxSchema +>; + export type McpUiResourceCspSchemaInferredType = z.infer< typeof generated.McpUiResourceCspSchema >; @@ -201,6 +205,12 @@ expectType( expectType( {} as spec.McpUiSandboxProxyReadyNotification, ); +expectType( + {} as McpUiResourceSandboxSchemaInferredType, +); +expectType( + {} as spec.McpUiResourceSandbox, +); expectType({} as McpUiResourceCspSchemaInferredType); expectType({} as spec.McpUiResourceCsp); expectType( diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 88797b50..fa3433b7 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -202,6 +202,37 @@ export const McpUiSandboxProxyReadyNotificationSchema = z.object({ params: z.object({}), }); +/** + * @description Sandbox flags requested by the UI resource. + * These control iframe sandbox attribute beyond the baseline (scripts, same-origin). + * Hosts MAY honor these by adding flags to the iframe sandbox attribute. + * Apps SHOULD NOT assume flags are granted; use feature detection as fallback. + */ +export const McpUiResourceSandboxSchema = z.object({ + /** @description Allow form submission (sandbox `allow-forms` flag). */ + forms: z + .object({}) + .optional() + .describe("Allow form submission (sandbox `allow-forms` flag)."), + /** @description Allow window.open popups (sandbox `allow-popups` flag). */ + popups: z + .object({}) + .optional() + .describe("Allow window.open popups (sandbox `allow-popups` flag)."), + /** @description Allow alert/confirm/prompt/print dialogs (sandbox `allow-modals` flag). */ + modals: z + .object({}) + .optional() + .describe( + "Allow alert/confirm/prompt/print dialogs (sandbox `allow-modals` flag).", + ), + /** @description Allow file downloads (sandbox `allow-downloads` flag). */ + downloads: z + .object({}) + .optional() + .describe("Allow file downloads (sandbox `allow-downloads` flag)."), +}); + /** * @description Content Security Policy configuration for UI resources. * @@ -533,6 +564,10 @@ export const McpUiHostCapabilitiesSchema = z.object({ csp: McpUiResourceCspSchema.optional().describe( "CSP domains approved by the host.", ), + /** @description Sandbox flags granted by the host (forms, popups, modals, downloads). */ + flags: McpUiResourceSandboxSchema.optional().describe( + "Sandbox flags granted by the host (forms, popups, modals, downloads).", + ), }) .optional() .describe("Sandbox configuration applied by the host."), @@ -596,6 +631,10 @@ export const McpUiResourceMetaSchema = z.object({ permissions: McpUiResourcePermissionsSchema.optional().describe( "Sandbox permissions requested by the UI resource.", ), + /** @description Sandbox flags requested by the UI. */ + sandbox: McpUiResourceSandboxSchema.optional().describe( + "Sandbox flags requested by the UI.", + ), /** * @description Dedicated origin for view sandbox. * @@ -771,11 +810,13 @@ export const McpUiSandboxResourceReadyNotificationSchema = z.object({ params: z.object({ /** @description HTML content to load into the inner iframe. */ html: z.string().describe("HTML content to load into the inner iframe."), - /** @description Optional override for the inner iframe's sandbox attribute. */ + /** @description Sandbox configuration: structured flags object or raw attribute string override. */ sandbox: z - .string() + .union([McpUiResourceSandboxSchema, z.string()]) .optional() - .describe("Optional override for the inner iframe's sandbox attribute."), + .describe( + "Sandbox configuration: structured flags object or raw attribute string override.", + ), /** @description CSP configuration from resource metadata. */ csp: McpUiResourceCspSchema.optional().describe( "CSP configuration from resource metadata.", diff --git a/src/spec.types.ts b/src/spec.types.ts index 8e0e2eb0..e5b22f81 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -249,8 +249,8 @@ export interface McpUiSandboxResourceReadyNotification { params: { /** @description HTML content to load into the inner iframe. */ html: string; - /** @description Optional override for the inner iframe's sandbox attribute. */ - sandbox?: string; + /** @description Sandbox configuration: structured flags object or raw attribute string override. */ + sandbox?: McpUiResourceSandbox | string; /** @description CSP configuration from resource metadata. */ csp?: McpUiResourceCsp; /** @description Sandbox permissions from resource metadata. */ @@ -503,6 +503,8 @@ export interface McpUiHostCapabilities { permissions?: McpUiResourcePermissions; /** @description CSP domains approved by the host. */ csp?: McpUiResourceCsp; + /** @description Sandbox flags granted by the host (forms, popups, modals, downloads). */ + flags?: McpUiResourceSandbox; }; /** @description Host accepts context updates (ui/update-model-context) to be included in the model's context for future turns. */ updateModelContext?: McpUiSupportedContentBlockModalities; @@ -633,6 +635,23 @@ export interface McpUiResourceCsp { baseUriDomains?: string[]; } +/** + * @description Sandbox flags requested by the UI resource. + * These control iframe sandbox attribute beyond the baseline (scripts, same-origin). + * Hosts MAY honor these by adding flags to the iframe sandbox attribute. + * Apps SHOULD NOT assume flags are granted; use feature detection as fallback. + */ +export interface McpUiResourceSandbox { + /** @description Allow form submission (sandbox `allow-forms` flag). */ + forms?: {}; + /** @description Allow window.open popups (sandbox `allow-popups` flag). */ + popups?: {}; + /** @description Allow alert/confirm/prompt/print dialogs (sandbox `allow-modals` flag). */ + modals?: {}; + /** @description Allow file downloads (sandbox `allow-downloads` flag). */ + downloads?: {}; +} + /** * @description Sandbox permissions requested by the UI resource. * @@ -675,6 +694,8 @@ export interface McpUiResourceMeta { csp?: McpUiResourceCsp; /** @description Sandbox permissions requested by the UI resource. */ permissions?: McpUiResourcePermissions; + /** @description Sandbox flags requested by the UI. */ + sandbox?: McpUiResourceSandbox; /** * @description Dedicated origin for view sandbox. * diff --git a/src/types.ts b/src/types.ts index 739da6fa..256a1726 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,6 +59,7 @@ export { type McpUiInitializedNotification, type McpUiResourceCsp, type McpUiResourcePermissions, + type McpUiResourceSandbox, type McpUiResourceMeta, type McpUiRequestDisplayModeRequest, type McpUiRequestDisplayModeResult, @@ -125,6 +126,7 @@ export { McpUiInitializedNotificationSchema, McpUiResourceCspSchema, McpUiResourcePermissionsSchema, + McpUiResourceSandboxSchema, McpUiResourceMetaSchema, McpUiRequestDisplayModeRequestSchema, McpUiRequestDisplayModeResultSchema,