From 021f64d7e12b570a45b08fb9b070c3827a84d75a Mon Sep 17 00:00:00 2001 From: Jameson Steiner Date: Tue, 6 Oct 2026 14:02:58 +0200 Subject: [PATCH 1/4] Add clipboard-write as the canonical key for clipboard write access Permission keys are the Permission Policy feature names. clipboardWrite stays as a legacy alias and is deprecated in its favor. --- specification/draft/apps.mdx | 19 +++++++++++++---- src/app-bridge.test.ts | 13 ++++++++++++ src/app-bridge.ts | 6 ++++-- src/generated/schema.json | 40 +++++++++++++++++++++++++++++++----- src/generated/schema.ts | 13 +++++++++++- src/spec.types.ts | 8 ++++++++ 6 files changed, 87 insertions(+), 12 deletions(-) diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index f2523f25e..d671ce3f3 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -165,6 +165,8 @@ interface UIResourceMeta { * Sandbox permissions requested by the UI * * Servers declare which browser capabilities their UI needs. + * Keys are Permission Policy feature names. + * * Hosts MAY honor these by setting appropriate iframe `allow` attributes. * Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback. */ @@ -192,6 +194,12 @@ interface UIResourceMeta { * * Maps to Permission Policy `clipboard-write` feature */ + "clipboard-write"?: {}, + /** + * Legacy alias for `clipboard-write` + * + * @deprecated Use `"clipboard-write"` instead + */ clipboardWrite?: {}, }, /** @@ -247,10 +255,11 @@ The resource content is returned via `resources/read`: baseUriDomains?: string[]; // Allowed base URIs for the document (base-uri directive). }; permissions?: { - camera?: {}; // Request camera access - microphone?: {}; // Request microphone access - geolocation?: {}; // Request geolocation access - clipboardWrite?: {}; // Request clipboard write access + camera?: {}; // Request camera access + microphone?: {}; // Request microphone access + geolocation?: {}; // Request geolocation access + "clipboard-write"?: {}; // Request clipboard write access + clipboardWrite?: {}; // Legacy alias for clipboard-write }; domain?: string; prefersBorder?: boolean; @@ -702,6 +711,7 @@ interface HostCapabilities { camera?: {}; microphone?: {}; geolocation?: {}; + "clipboard-write"?: {}; clipboardWrite?: {}; }; /** CSP domains approved by the host. */ @@ -1503,6 +1513,7 @@ These messages are reserved for web-based hosts that implement the recommended d camera?: {}, microphone?: {}, geolocation?: {}, + "clipboard-write"?: {}, clipboardWrite?: {}, } } diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index a87b0e7db..02a35e007 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -3109,6 +3109,18 @@ describe("buildAllowAttribute", () => { "clipboard-write", ); }); + + it("when only clipboard-write is set", () => { + expect(buildAllowAttribute({ "clipboard-write": {} })).toBe( + "clipboard-write", + ); + }); + + it("when clipboard-write is requested under both its key and its alias, emits it once", () => { + expect( + buildAllowAttribute({ "clipboard-write": {}, clipboardWrite: {} }), + ).toBe("clipboard-write"); + }); }); describe("returns multiple directives joined with '; '", () => { @@ -3124,6 +3136,7 @@ describe("buildAllowAttribute", () => { camera: {}, microphone: {}, geolocation: {}, + "clipboard-write": {}, clipboardWrite: {}, }), ).toBe("camera; microphone; geolocation; clipboard-write"); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 1ac4db485..5ab8dde2a 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -177,7 +177,7 @@ export function isToolVisibilityAppOnly(tool: Partial): boolean { * format used by iframes (e.g., "microphone; clipboard-write"). * * @param permissions - Permissions requested by the UI resource - * @returns Space-separated permission directives, or empty string if none + * @returns Semicolon-separated permission directives, or empty string if none * * @example * ```typescript @@ -195,7 +195,9 @@ export function buildAllowAttribute( if (permissions.camera) allowList.push("camera"); if (permissions.microphone) allowList.push("microphone"); if (permissions.geolocation) allowList.push("geolocation"); - if (permissions.clipboardWrite) allowList.push("clipboard-write"); + if (permissions["clipboard-write"] || permissions.clipboardWrite) { + allowList.push("clipboard-write"); + } return allowList.join("; "); } diff --git a/src/generated/schema.json b/src/generated/schema.json index 246ed7d9b..281456bba 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -387,11 +387,17 @@ "properties": {}, "additionalProperties": false }, - "clipboardWrite": { + "clipboard-write": { "description": "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", "type": "object", "properties": {}, "additionalProperties": false + }, + "clipboardWrite": { + "description": "Legacy alias for `clipboard-write`.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false @@ -2798,11 +2804,17 @@ "properties": {}, "additionalProperties": false }, - "clipboardWrite": { + "clipboard-write": { "description": "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", "type": "object", "properties": {}, "additionalProperties": false + }, + "clipboardWrite": { + "description": "Legacy alias for `clipboard-write`.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false @@ -4315,11 +4327,17 @@ "properties": {}, "additionalProperties": false }, - "clipboardWrite": { + "clipboard-write": { "description": "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", "type": "object", "properties": {}, "additionalProperties": false + }, + "clipboardWrite": { + "description": "Legacy alias for `clipboard-write`.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false @@ -4357,11 +4375,17 @@ "properties": {}, "additionalProperties": false }, - "clipboardWrite": { + "clipboard-write": { "description": "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", "type": "object", "properties": {}, "additionalProperties": false + }, + "clipboardWrite": { + "description": "Legacy alias for `clipboard-write`.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false @@ -4484,11 +4508,17 @@ "properties": {}, "additionalProperties": false }, - "clipboardWrite": { + "clipboard-write": { "description": "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", "type": "object", "properties": {}, "additionalProperties": false + }, + "clipboardWrite": { + "description": "Legacy alias for `clipboard-write`.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 214b227c2..cc63e1396 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -288,6 +288,8 @@ export const McpUiResourceCspSchema = z.object({ * @description Sandbox permissions requested by the UI resource. * * Servers declare which browser capabilities their UI needs. + * Keys are Permission Policy feature names. + * * Hosts MAY honor these by setting appropriate iframe `allow` attributes. * Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback. */ @@ -330,12 +332,21 @@ export const McpUiResourcePermissionsSchema = z.object({ * * Maps to Permission Policy `clipboard-write` feature. */ - clipboardWrite: z + "clipboard-write": z .object({}) .optional() .describe( "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", ), + /** + * @description Legacy alias for `clipboard-write`. + * + * @deprecated Use `"clipboard-write"` instead. + */ + clipboardWrite: z + .object({}) + .optional() + .describe("Legacy alias for `clipboard-write`."), }); /** diff --git a/src/spec.types.ts b/src/spec.types.ts index 0c4539637..970ad4dd2 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -658,6 +658,8 @@ export interface McpUiResourceCsp { * @description Sandbox permissions requested by the UI resource. * * Servers declare which browser capabilities their UI needs. + * Keys are Permission Policy feature names. + * * Hosts MAY honor these by setting appropriate iframe `allow` attributes. * Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback. */ @@ -685,6 +687,12 @@ export interface McpUiResourcePermissions { * * Maps to Permission Policy `clipboard-write` feature. */ + "clipboard-write"?: {}; + /** + * @description Legacy alias for `clipboard-write`. + * + * @deprecated Use `"clipboard-write"` instead. + */ clipboardWrite?: {}; } From 71b3b96a11de1176d7ea34e84b5f9da41fe8bfce Mon Sep 17 00:00:00 2001 From: Jameson Steiner Date: Tue, 6 Oct 2026 14:40:31 +0200 Subject: [PATCH 2/4] Add media permissions to the UI resource permissions Extend McpUiResourcePermissions with fullscreen, picture-in-picture, autoplay and encrypted-media. --- specification/draft/apps.mdx | 48 ++++++++++++-- src/app-bridge.test.ts | 36 +++++++++- src/app-bridge.ts | 4 ++ src/generated/schema.json | 124 ++++++++++++++++++++++++++++++++++- src/generated/schema.ts | 48 +++++++++++++- src/spec.types.ts | 26 +++++++- 6 files changed, 274 insertions(+), 12 deletions(-) diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index d671ce3f3..997572e1a 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -201,6 +201,30 @@ interface UIResourceMeta { * @deprecated Use `"clipboard-write"` instead */ clipboardWrite?: {}, + /** + * Request Fullscreen API access + * + * Maps to Permission Policy `fullscreen` feature + */ + fullscreen?: {}, + /** + * Request Picture-in-Picture API access + * + * Maps to Permission Policy `picture-in-picture` feature + */ + "picture-in-picture"?: {}, + /** + * Request media autoplay with sound + * + * Maps to Permission Policy `autoplay` feature + */ + autoplay?: {}, + /** + * Request Encrypted Media Extensions access for DRM-protected media playback + * + * Maps to Permission Policy `encrypted-media` feature + */ + "encrypted-media"?: {}, }, /** * Dedicated origin for view @@ -255,11 +279,15 @@ The resource content is returned via `resources/read`: baseUriDomains?: string[]; // Allowed base URIs for the document (base-uri directive). }; permissions?: { - camera?: {}; // Request camera access - microphone?: {}; // Request microphone access - geolocation?: {}; // Request geolocation access - "clipboard-write"?: {}; // Request clipboard write access - clipboardWrite?: {}; // Legacy alias for clipboard-write + camera?: {}; // Request camera access + microphone?: {}; // Request microphone access + geolocation?: {}; // Request geolocation access + "clipboard-write"?: {}; // Request clipboard write access + clipboardWrite?: {}; // Legacy alias for clipboard-write + fullscreen?: {}; // Request Fullscreen API access + "picture-in-picture"?: {}; // Request Picture-in-Picture API access + autoplay?: {}; // Request media autoplay with sound + "encrypted-media"?: {}; // Request Encrypted Media Extensions access for DRM-protected media playback }; domain?: string; prefersBorder?: boolean; @@ -706,13 +734,17 @@ interface HostCapabilities { logging?: {}; /** Sandbox configuration applied by the host. */ sandbox?: { - /** Permissions granted by the host (camera, microphone, geolocation, clipboard-write). */ + /** Permission Policy features granted by the host. */ permissions?: { camera?: {}; microphone?: {}; geolocation?: {}; "clipboard-write"?: {}; clipboardWrite?: {}; + fullscreen?: {}; + "picture-in-picture"?: {}; + autoplay?: {}; + "encrypted-media"?: {}; }; /** CSP domains approved by the host. */ csp?: { @@ -1515,6 +1547,10 @@ These messages are reserved for web-based hosts that implement the recommended d geolocation?: {}, "clipboard-write"?: {}, clipboardWrite?: {}, + fullscreen?: {}, + "picture-in-picture"?: {}, + autoplay?: {}, + "encrypted-media"?: {}, } } } diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 02a35e007..845de87cc 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -3121,6 +3121,34 @@ describe("buildAllowAttribute", () => { buildAllowAttribute({ "clipboard-write": {}, clipboardWrite: {} }), ).toBe("clipboard-write"); }); + + it("when only fullscreen is set", () => { + expect(buildAllowAttribute({ fullscreen: {} })).toBe("fullscreen"); + }); + + it("when only picture-in-picture is set", () => { + expect(buildAllowAttribute({ "picture-in-picture": {} })).toBe( + "picture-in-picture", + ); + }); + + it("when only autoplay is set", () => { + expect(buildAllowAttribute({ autoplay: {} })).toBe("autoplay"); + }); + + it("when only encrypted-media is set", () => { + expect(buildAllowAttribute({ "encrypted-media": {} })).toBe( + "encrypted-media", + ); + }); + }); + + describe("skips entries whose value is undefined", () => { + it("when one of two permissions is undefined", () => { + expect(buildAllowAttribute({ camera: undefined, fullscreen: {} })).toBe( + "fullscreen", + ); + }); }); describe("returns multiple directives joined with '; '", () => { @@ -3138,8 +3166,14 @@ describe("buildAllowAttribute", () => { geolocation: {}, "clipboard-write": {}, clipboardWrite: {}, + fullscreen: {}, + "picture-in-picture": {}, + autoplay: {}, + "encrypted-media": {}, }), - ).toBe("camera; microphone; geolocation; clipboard-write"); + ).toBe( + "camera; microphone; geolocation; clipboard-write; fullscreen; picture-in-picture; autoplay; encrypted-media", + ); }); }); }); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 5ab8dde2a..d83326d5c 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -198,6 +198,10 @@ export function buildAllowAttribute( if (permissions["clipboard-write"] || permissions.clipboardWrite) { allowList.push("clipboard-write"); } + if (permissions.fullscreen) allowList.push("fullscreen"); + if (permissions["picture-in-picture"]) allowList.push("picture-in-picture"); + if (permissions.autoplay) allowList.push("autoplay"); + if (permissions["encrypted-media"]) allowList.push("encrypted-media"); return allowList.join("; "); } diff --git a/src/generated/schema.json b/src/generated/schema.json index 281456bba..23d8274d6 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -366,7 +366,7 @@ "type": "object", "properties": { "permissions": { - "description": "Permissions granted by the host (camera, microphone, geolocation).", + "description": "Permission Policy features granted by the host.", "type": "object", "properties": { "camera": { @@ -398,6 +398,30 @@ "type": "object", "properties": {}, "additionalProperties": false + }, + "fullscreen": { + "description": "Request Fullscreen API access.\n\nMaps to Permission Policy `fullscreen` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "picture-in-picture": { + "description": "Request Picture-in-Picture API access.\n\nMaps to Permission Policy `picture-in-picture` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "autoplay": { + "description": "Request media autoplay with sound.\n\nMaps to Permission Policy `autoplay` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "encrypted-media": { + "description": "Request Encrypted Media Extensions access for DRM-protected media playback.\n\nMaps to Permission Policy `encrypted-media` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false @@ -2783,7 +2807,7 @@ "type": "object", "properties": { "permissions": { - "description": "Permissions granted by the host (camera, microphone, geolocation).", + "description": "Permission Policy features granted by the host.", "type": "object", "properties": { "camera": { @@ -2815,6 +2839,30 @@ "type": "object", "properties": {}, "additionalProperties": false + }, + "fullscreen": { + "description": "Request Fullscreen API access.\n\nMaps to Permission Policy `fullscreen` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "picture-in-picture": { + "description": "Request Picture-in-Picture API access.\n\nMaps to Permission Policy `picture-in-picture` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "autoplay": { + "description": "Request media autoplay with sound.\n\nMaps to Permission Policy `autoplay` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "encrypted-media": { + "description": "Request Encrypted Media Extensions access for DRM-protected media playback.\n\nMaps to Permission Policy `encrypted-media` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false @@ -4338,6 +4386,30 @@ "type": "object", "properties": {}, "additionalProperties": false + }, + "fullscreen": { + "description": "Request Fullscreen API access.\n\nMaps to Permission Policy `fullscreen` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "picture-in-picture": { + "description": "Request Picture-in-Picture API access.\n\nMaps to Permission Policy `picture-in-picture` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "autoplay": { + "description": "Request media autoplay with sound.\n\nMaps to Permission Policy `autoplay` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "encrypted-media": { + "description": "Request Encrypted Media Extensions access for DRM-protected media playback.\n\nMaps to Permission Policy `encrypted-media` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false @@ -4386,6 +4458,30 @@ "type": "object", "properties": {}, "additionalProperties": false + }, + "fullscreen": { + "description": "Request Fullscreen API access.\n\nMaps to Permission Policy `fullscreen` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "picture-in-picture": { + "description": "Request Picture-in-Picture API access.\n\nMaps to Permission Policy `picture-in-picture` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "autoplay": { + "description": "Request media autoplay with sound.\n\nMaps to Permission Policy `autoplay` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "encrypted-media": { + "description": "Request Encrypted Media Extensions access for DRM-protected media playback.\n\nMaps to Permission Policy `encrypted-media` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false @@ -4519,6 +4615,30 @@ "type": "object", "properties": {}, "additionalProperties": false + }, + "fullscreen": { + "description": "Request Fullscreen API access.\n\nMaps to Permission Policy `fullscreen` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "picture-in-picture": { + "description": "Request Picture-in-Picture API access.\n\nMaps to Permission Policy `picture-in-picture` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "autoplay": { + "description": "Request media autoplay with sound.\n\nMaps to Permission Policy `autoplay` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "encrypted-media": { + "description": "Request Encrypted Media Extensions access for DRM-protected media playback.\n\nMaps to Permission Policy `encrypted-media` feature.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false diff --git a/src/generated/schema.ts b/src/generated/schema.ts index cc63e1396..9ff73270d 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -347,6 +347,50 @@ export const McpUiResourcePermissionsSchema = z.object({ .object({}) .optional() .describe("Legacy alias for `clipboard-write`."), + /** + * @description Request Fullscreen API access. + * + * Maps to Permission Policy `fullscreen` feature. + */ + fullscreen: z + .object({}) + .optional() + .describe( + "Request Fullscreen API access.\n\nMaps to Permission Policy `fullscreen` feature.", + ), + /** + * @description Request Picture-in-Picture API access. + * + * Maps to Permission Policy `picture-in-picture` feature. + */ + "picture-in-picture": z + .object({}) + .optional() + .describe( + "Request Picture-in-Picture API access.\n\nMaps to Permission Policy `picture-in-picture` feature.", + ), + /** + * @description Request media autoplay with sound. + * + * Maps to Permission Policy `autoplay` feature. + */ + autoplay: z + .object({}) + .optional() + .describe( + "Request media autoplay with sound.\n\nMaps to Permission Policy `autoplay` feature.", + ), + /** + * @description Request Encrypted Media Extensions access for DRM-protected media playback. + * + * Maps to Permission Policy `encrypted-media` feature. + */ + "encrypted-media": z + .object({}) + .optional() + .describe( + "Request Encrypted Media Extensions access for DRM-protected media playback.\n\nMaps to Permission Policy `encrypted-media` feature.", + ), }); /** @@ -554,9 +598,9 @@ export const McpUiHostCapabilitiesSchema = z.object({ /** @description Sandbox configuration applied by the host. */ sandbox: z .object({ - /** @description Permissions granted by the host (camera, microphone, geolocation). */ + /** @description Permission Policy features granted by the host. */ permissions: McpUiResourcePermissionsSchema.optional().describe( - "Permissions granted by the host (camera, microphone, geolocation).", + "Permission Policy features granted by the host.", ), /** @description CSP domains approved by the host. */ csp: McpUiResourceCspSchema.optional().describe( diff --git a/src/spec.types.ts b/src/spec.types.ts index 970ad4dd2..b51d5a39a 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -512,7 +512,7 @@ export interface McpUiHostCapabilities { logging?: {}; /** @description Sandbox configuration applied by the host. */ sandbox?: { - /** @description Permissions granted by the host (camera, microphone, geolocation). */ + /** @description Permission Policy features granted by the host. */ permissions?: McpUiResourcePermissions; /** @description CSP domains approved by the host. */ csp?: McpUiResourceCsp; @@ -694,6 +694,30 @@ export interface McpUiResourcePermissions { * @deprecated Use `"clipboard-write"` instead. */ clipboardWrite?: {}; + /** + * @description Request Fullscreen API access. + * + * Maps to Permission Policy `fullscreen` feature. + */ + fullscreen?: {}; + /** + * @description Request Picture-in-Picture API access. + * + * Maps to Permission Policy `picture-in-picture` feature. + */ + "picture-in-picture"?: {}; + /** + * @description Request media autoplay with sound. + * + * Maps to Permission Policy `autoplay` feature. + */ + autoplay?: {}; + /** + * @description Request Encrypted Media Extensions access for DRM-protected media playback. + * + * Maps to Permission Policy `encrypted-media` feature. + */ + "encrypted-media"?: {}; } /** From 3125d7ab88189ec77dff7e811ce310c0ac0b55dc Mon Sep 17 00:00:00 2001 From: Jameson Steiner Date: Tue, 6 Oct 2026 15:51:51 +0200 Subject: [PATCH 3/4] Accept any Permission Policy feature in the UI resource permissions Add a string index signature to McpUiResourcePermissions so views can request features without a named key, using the feature name verbatim. --- specification/draft/apps.mdx | 11 +- src/app-bridge.test.ts | 21 +++- src/app-bridge.ts | 40 ++++--- src/generated/schema.json | 10 +- src/generated/schema.ts | 202 ++++++++++++++++++----------------- src/spec.types.ts | 10 ++ 6 files changed, 175 insertions(+), 119 deletions(-) diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index 997572e1a..b3ce82dfe 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -168,6 +168,8 @@ interface UIResourceMeta { * Keys are Permission Policy feature names. * * Hosts MAY honor these by setting appropriate iframe `allow` attributes. + * Hosts SHOULD recognize the named keys, MAY honor other feature names, and + * MUST NOT apply keys that are not valid Permission Policy feature names. * Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback. */ permissions?: { @@ -225,6 +227,10 @@ interface UIResourceMeta { * Maps to Permission Policy `encrypted-media` feature */ "encrypted-media"?: {}, + /** + * Any other Permission Policy feature, keyed by its feature name + */ + [feature: string]: {}, }, /** * Dedicated origin for view @@ -288,6 +294,7 @@ The resource content is returned via `resources/read`: "picture-in-picture"?: {}; // Request Picture-in-Picture API access autoplay?: {}; // Request media autoplay with sound "encrypted-media"?: {}; // Request Encrypted Media Extensions access for DRM-protected media playback + [feature: string]: {}; // Any other Permission Policy feature, by name }; domain?: string; prefersBorder?: boolean; @@ -537,7 +544,7 @@ If the Host is a web page, it MUST wrap the View and communicate with it through - If `baseUriDomains` is provided, allow base URIs from declared origins; otherwise use `base-uri 'self'` - 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 `permissions` is declared, the Sandbox MAY set the inner iframe's `allow` attribute accordingly, applying only keys that are valid Permission Policy feature names 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. @@ -745,6 +752,7 @@ interface HostCapabilities { "picture-in-picture"?: {}; autoplay?: {}; "encrypted-media"?: {}; + [feature: string]: {}; }; /** CSP domains approved by the host. */ csp?: { @@ -1551,6 +1559,7 @@ These messages are reserved for web-based hosts that implement the recommended d "picture-in-picture"?: {}, autoplay?: {}, "encrypted-media"?: {}, + [feature: string]: {}, } } } diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 845de87cc..876621c18 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -3141,14 +3141,31 @@ describe("buildAllowAttribute", () => { "encrypted-media", ); }); + + it("when the key is a feature without a named property, passes it through verbatim", () => { + expect(buildAllowAttribute({ "xr-spatial-tracking": {} })).toBe( + "xr-spatial-tracking", + ); + }); }); - describe("skips entries whose value is undefined", () => { - it("when one of two permissions is undefined", () => { + describe("drops entries that cannot be emitted", () => { + it("when the value is undefined", () => { expect(buildAllowAttribute({ camera: undefined, fullscreen: {} })).toBe( "fullscreen", ); }); + + it("when the key is not a bare Permission Policy feature name", () => { + expect( + buildAllowAttribute({ + "camera 'self'": {}, + Fullscreen: {}, + "camera;": {}, + toString: {}, + }), + ).toBe(""); + }); }); describe("returns multiple directives joined with '; '", () => { diff --git a/src/app-bridge.ts b/src/app-bridge.ts index d83326d5c..464982a42 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -170,11 +170,24 @@ export function isToolVisibilityAppOnly(tool: Partial): boolean { return false; } +/** + * Permission Policy feature names for the {@link McpUiResourcePermissions `McpUiResourcePermissions`} + * keys that are not spelled as their feature name. Every other key is its own feature name. + */ +const PERMISSION_POLICY_FEATURE_ALIASES: Record = { + clipboardWrite: "clipboard-write", +}; + +/** Bare Permission Policy feature tokens: lowercase letters, digits and hyphens. */ +const PERMISSION_POLICY_FEATURE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; + /** * Build iframe `allow` attribute string from permissions. * - * Maps McpUiResourcePermissions to the Permission Policy allow attribute - * format used by iframes (e.g., "microphone; clipboard-write"). + * Emits each requested key as its Permission Policy feature name in the + * allow attribute format used by iframes (e.g., "microphone; clipboard-write"). + * Keys that are not valid feature names are dropped, so the result can be + * assigned to the attribute as is. * * @param permissions - Permissions requested by the UI resource * @returns Semicolon-separated permission directives, or empty string if none @@ -192,16 +205,19 @@ export function buildAllowAttribute( if (!permissions) return ""; const allowList: string[] = []; - if (permissions.camera) allowList.push("camera"); - if (permissions.microphone) allowList.push("microphone"); - if (permissions.geolocation) allowList.push("geolocation"); - if (permissions["clipboard-write"] || permissions.clipboardWrite) { - allowList.push("clipboard-write"); - } - if (permissions.fullscreen) allowList.push("fullscreen"); - if (permissions["picture-in-picture"]) allowList.push("picture-in-picture"); - if (permissions.autoplay) allowList.push("autoplay"); - if (permissions["encrypted-media"]) allowList.push("encrypted-media"); + + for (const [key, value] of Object.entries(permissions)) { + if (!value) continue; + + const feature = PERMISSION_POLICY_FEATURE_ALIASES[key] ?? key; + + if ( + PERMISSION_POLICY_FEATURE_NAME_PATTERN.test(feature) && + !allowList.includes(feature) + ) { + allowList.push(feature); + } + } return allowList.join("; "); } diff --git a/src/generated/schema.json b/src/generated/schema.json index 23d8274d6..62a7c4d86 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -424,7 +424,7 @@ "additionalProperties": false } }, - "additionalProperties": false + "additionalProperties": {} }, "csp": { "description": "CSP domains approved by the host.", @@ -2865,7 +2865,7 @@ "additionalProperties": false } }, - "additionalProperties": false + "additionalProperties": {} }, "csp": { "description": "CSP domains approved by the host.", @@ -4412,7 +4412,7 @@ "additionalProperties": false } }, - "additionalProperties": false + "additionalProperties": {} }, "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).", @@ -4484,7 +4484,7 @@ "additionalProperties": false } }, - "additionalProperties": false + "additionalProperties": {} }, "McpUiResourceTeardownRequest": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -4641,7 +4641,7 @@ "additionalProperties": false } }, - "additionalProperties": false + "additionalProperties": {} } }, "required": ["html"], diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 9ff73270d..0152f59d6 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -291,107 +291,111 @@ export const McpUiResourceCspSchema = z.object({ * Keys are Permission Policy feature names. * * Hosts MAY honor these by setting appropriate iframe `allow` attributes. + * Hosts SHOULD recognize the named keys, MAY honor other feature names, and + * MUST NOT apply keys that are not valid Permission Policy feature names. * Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback. */ -export const McpUiResourcePermissionsSchema = z.object({ - /** - * @description Request camera access. - * - * Maps to Permission Policy `camera` feature. - */ - camera: z - .object({}) - .optional() - .describe( - "Request camera access.\n\nMaps to Permission Policy `camera` feature.", - ), - /** - * @description Request microphone access. - * - * Maps to Permission Policy `microphone` feature. - */ - microphone: z - .object({}) - .optional() - .describe( - "Request microphone access.\n\nMaps to Permission Policy `microphone` feature.", - ), - /** - * @description Request geolocation access. - * - * Maps to Permission Policy `geolocation` feature. - */ - geolocation: z - .object({}) - .optional() - .describe( - "Request geolocation access.\n\nMaps to Permission Policy `geolocation` feature.", - ), - /** - * @description Request clipboard write access. - * - * Maps to Permission Policy `clipboard-write` feature. - */ - "clipboard-write": z - .object({}) - .optional() - .describe( - "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", - ), - /** - * @description Legacy alias for `clipboard-write`. - * - * @deprecated Use `"clipboard-write"` instead. - */ - clipboardWrite: z - .object({}) - .optional() - .describe("Legacy alias for `clipboard-write`."), - /** - * @description Request Fullscreen API access. - * - * Maps to Permission Policy `fullscreen` feature. - */ - fullscreen: z - .object({}) - .optional() - .describe( - "Request Fullscreen API access.\n\nMaps to Permission Policy `fullscreen` feature.", - ), - /** - * @description Request Picture-in-Picture API access. - * - * Maps to Permission Policy `picture-in-picture` feature. - */ - "picture-in-picture": z - .object({}) - .optional() - .describe( - "Request Picture-in-Picture API access.\n\nMaps to Permission Policy `picture-in-picture` feature.", - ), - /** - * @description Request media autoplay with sound. - * - * Maps to Permission Policy `autoplay` feature. - */ - autoplay: z - .object({}) - .optional() - .describe( - "Request media autoplay with sound.\n\nMaps to Permission Policy `autoplay` feature.", - ), - /** - * @description Request Encrypted Media Extensions access for DRM-protected media playback. - * - * Maps to Permission Policy `encrypted-media` feature. - */ - "encrypted-media": z - .object({}) - .optional() - .describe( - "Request Encrypted Media Extensions access for DRM-protected media playback.\n\nMaps to Permission Policy `encrypted-media` feature.", - ), -}); +export const McpUiResourcePermissionsSchema = z + .object({ + /** + * @description Request camera access. + * + * Maps to Permission Policy `camera` feature. + */ + camera: z + .object({}) + .optional() + .describe( + "Request camera access.\n\nMaps to Permission Policy `camera` feature.", + ), + /** + * @description Request microphone access. + * + * Maps to Permission Policy `microphone` feature. + */ + microphone: z + .object({}) + .optional() + .describe( + "Request microphone access.\n\nMaps to Permission Policy `microphone` feature.", + ), + /** + * @description Request geolocation access. + * + * Maps to Permission Policy `geolocation` feature. + */ + geolocation: z + .object({}) + .optional() + .describe( + "Request geolocation access.\n\nMaps to Permission Policy `geolocation` feature.", + ), + /** + * @description Request clipboard write access. + * + * Maps to Permission Policy `clipboard-write` feature. + */ + "clipboard-write": z + .object({}) + .optional() + .describe( + "Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature.", + ), + /** + * @description Legacy alias for `clipboard-write`. + * + * @deprecated Use `"clipboard-write"` instead. + */ + clipboardWrite: z + .object({}) + .optional() + .describe("Legacy alias for `clipboard-write`."), + /** + * @description Request Fullscreen API access. + * + * Maps to Permission Policy `fullscreen` feature. + */ + fullscreen: z + .object({}) + .optional() + .describe( + "Request Fullscreen API access.\n\nMaps to Permission Policy `fullscreen` feature.", + ), + /** + * @description Request Picture-in-Picture API access. + * + * Maps to Permission Policy `picture-in-picture` feature. + */ + "picture-in-picture": z + .object({}) + .optional() + .describe( + "Request Picture-in-Picture API access.\n\nMaps to Permission Policy `picture-in-picture` feature.", + ), + /** + * @description Request media autoplay with sound. + * + * Maps to Permission Policy `autoplay` feature. + */ + autoplay: z + .object({}) + .optional() + .describe( + "Request media autoplay with sound.\n\nMaps to Permission Policy `autoplay` feature.", + ), + /** + * @description Request Encrypted Media Extensions access for DRM-protected media playback. + * + * Maps to Permission Policy `encrypted-media` feature. + */ + "encrypted-media": z + .object({}) + .optional() + .describe( + "Request Encrypted Media Extensions access for DRM-protected media playback.\n\nMaps to Permission Policy `encrypted-media` feature.", + ), + }) + .passthrough(); /** * @description Notification of UI size changes (View -> Host). diff --git a/src/spec.types.ts b/src/spec.types.ts index b51d5a39a..10d26d298 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -661,6 +661,8 @@ export interface McpUiResourceCsp { * Keys are Permission Policy feature names. * * Hosts MAY honor these by setting appropriate iframe `allow` attributes. + * Hosts SHOULD recognize the named keys, MAY honor other feature names, and + * MUST NOT apply keys that are not valid Permission Policy feature names. * Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback. */ export interface McpUiResourcePermissions { @@ -718,6 +720,14 @@ export interface McpUiResourcePermissions { * Maps to Permission Policy `encrypted-media` feature. */ "encrypted-media"?: {}; + /** + * Any other Permission Policy feature, keyed by its feature name. + * + * Typed `unknown` because the schema generator turns only that form into a + * passthrough object, so parsing keeps keys not declared above instead of + * stripping them. + */ + [feature: string]: unknown; } /** From e057bdc35b5e00687d2efdcd4e9ef3cc763f8a8b Mon Sep 17 00:00:00 2001 From: Jameson Steiner Date: Tue, 6 Oct 2026 16:31:51 +0200 Subject: [PATCH 4/4] Add media permissions in the video MCP example The example declares fullscreen, picture-in-picture, autoplay and encrypted-media; the native video controls show the first two taking effect in basic-host. --- examples/video-resource-server/server.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/examples/video-resource-server/server.ts b/examples/video-resource-server/server.ts index 27070ca61..711dcd91c 100644 --- a/examples/video-resource-server/server.ts +++ b/examples/video-resource-server/server.ts @@ -180,7 +180,23 @@ ${Object.entries(VIDEO_LIBRARY) ); return { contents: [ - { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }, + { + uri: RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + // Media permissions a video player requests; the native controls + // use fullscreen and picture-in-picture + permissions: { + fullscreen: {}, + "picture-in-picture": {}, + autoplay: {}, + "encrypted-media": {}, + }, + }, + }, + }, ], }; },