Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion examples/video-resource-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
},
},
},
},
],
};
},
Expand Down
68 changes: 62 additions & 6 deletions specification/draft/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,11 @@ 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.
* 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?: {
Expand All @@ -192,7 +196,41 @@ interface UIResourceMeta {
*
* Maps to Permission Policy `clipboard-write` feature
*/
"clipboard-write"?: {},
/**
* Legacy alias for `clipboard-write`
*
* @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"?: {},
/**
* Any other Permission Policy feature, keyed by its feature name
*/
[feature: string]: {},
},
/**
* Dedicated origin for view
Expand Down Expand Up @@ -247,10 +285,16 @@ 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
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
[feature: string]: {}; // Any other Permission Policy feature, by name
};
domain?: string;
prefersBorder?: boolean;
Expand Down Expand Up @@ -500,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.
Expand Down Expand Up @@ -697,12 +741,18 @@ 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"?: {};
[feature: string]: {};
};
/** CSP domains approved by the host. */
csp?: {
Expand Down Expand Up @@ -1503,7 +1553,13 @@ These messages are reserved for web-based hosts that implement the recommended d
camera?: {},
microphone?: {},
geolocation?: {},
"clipboard-write"?: {},
clipboardWrite?: {},
fullscreen?: {},
"picture-in-picture"?: {},
autoplay?: {},
"encrypted-media"?: {},
[feature: string]: {},
}
}
}
Expand Down
66 changes: 65 additions & 1 deletion src/app-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3109,6 +3109,63 @@ 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");
});

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",
);
});

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("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 '; '", () => {
Expand All @@ -3124,9 +3181,16 @@ describe("buildAllowAttribute", () => {
camera: {},
microphone: {},
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",
);
});
});
});
36 changes: 29 additions & 7 deletions src/app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,14 +170,27 @@ export function isToolVisibilityAppOnly(tool: Partial<Tool>): 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<string, string> = {
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 Space-separated permission directives, or empty string if none
* @returns Semicolon-separated permission directives, or empty string if none
*
* @example
* ```typescript
Expand All @@ -192,10 +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.clipboardWrite) allowList.push("clipboard-write");

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("; ");
}
Expand Down
Loading