Skip to content

Commit 9acc52c

Browse files
idosalCopilotochafik
authored
feat: enhance sandbox capability negotiation (#158)
* feat: enhance sandbox capability negotiation * Update specification/draft/apps.mdx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add all csp and permissions to host capabilities + update SEP * feat: add clipboard-write permission support Adds clipboardWrite to McpUiResourcePermissions for clipboard access. Maps to Permission Policy 'clipboard-write' feature. * rm files added by mistake * refactor: change McpUiResourcePermissions properties to empty objects * chore: remove scratch files and redundant cross-env dependency --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Olivier Chafik <ochafik@anthropic.com>
1 parent 9f09932 commit 9acc52c

8 files changed

Lines changed: 496 additions & 16 deletions

File tree

examples/basic-host/src/implementation.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge";
1+
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions } from "@modelcontextprotocol/ext-apps/app-bridge";
22
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
44
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
@@ -43,6 +43,7 @@ export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
4343
interface UiResourceData {
4444
html: string;
4545
csp?: McpUiResourceCsp;
46+
permissions?: McpUiResourcePermissions;
4647
}
4748

4849
export interface ToolCallInfo {
@@ -105,15 +106,16 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiRes
105106

106107
const html = "blob" in content ? atob(content.blob) : content.text;
107108

108-
// Extract CSP metadata from resource content._meta.ui.csp (or content.meta for Python SDK)
109+
// Extract CSP and permissions metadata from resource content._meta.ui (or content.meta for Python SDK)
109110
log.info("Resource content keys:", Object.keys(content));
110111
log.info("Resource content._meta:", (content as any)._meta);
111112

112113
// Try both _meta (spec) and meta (Python SDK quirk)
113114
const contentMeta = (content as any)._meta || (content as any).meta;
114115
const csp = contentMeta?.ui?.csp;
116+
const permissions = contentMeta?.ui?.permissions;
115117

116-
return { html, csp };
118+
return { html, csp, permissions };
117119
}
118120

119121

@@ -168,10 +170,10 @@ export async function initializeApp(
168170
new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!),
169171
);
170172

171-
// Load inner iframe HTML with CSP metadata
172-
const { html, csp } = await appResourcePromise;
173-
log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "");
174-
await appBridge.sendSandboxResourceReady({ html, csp });
173+
// Load inner iframe HTML with CSP and permissions metadata
174+
const { html, csp, permissions } = await appResourcePromise;
175+
log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "", permissions ? `(Permissions: ${JSON.stringify(permissions)})` : "");
176+
await appBridge.sendSandboxResourceReady({ html, csp, permissions });
175177

176178
// Wait for inner iframe to be ready
177179
log.info("Waiting for MCP App to initialize...");

examples/basic-host/src/sandbox.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,24 @@ const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
6666
// Security: CSP is enforced via HTTP headers on sandbox.html (set by serve.ts
6767
// based on ?csp= query param). This is tamper-proof unlike meta tags.
6868

69+
// Build iframe allow attribute from permissions
70+
function buildAllowAttribute(permissions?: {
71+
camera?: boolean;
72+
microphone?: boolean;
73+
geolocation?: boolean;
74+
clipboardWrite?: boolean;
75+
}): string {
76+
if (!permissions) return "";
77+
78+
const allowList: string[] = [];
79+
if (permissions.camera) allowList.push("camera");
80+
if (permissions.microphone) allowList.push("microphone");
81+
if (permissions.geolocation) allowList.push("geolocation");
82+
if (permissions.clipboardWrite) allowList.push("clipboard-write");
83+
84+
return allowList.join("; ");
85+
}
86+
6987
window.addEventListener("message", async (event) => {
7088
if (event.source === window.parent) {
7189
// Validate that messages from parent come from the expected host origin.
@@ -81,10 +99,16 @@ window.addEventListener("message", async (event) => {
8199
}
82100

83101
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
84-
const { html, sandbox } = event.data.params;
102+
const { html, sandbox, permissions } = event.data.params;
85103
if (typeof sandbox === "string") {
86104
inner.setAttribute("sandbox", sandbox);
87105
}
106+
// Set Permission Policy allow attribute if permissions are requested
107+
const allowAttribute = buildAllowAttribute(permissions);
108+
if (allowAttribute) {
109+
console.log("[Sandbox] Setting allow attribute:", allowAttribute);
110+
inner.setAttribute("allow", allowAttribute);
111+
}
88112
if (typeof html === "string") {
89113
// Use document.write instead of srcdoc for WebGL compatibility.
90114
// srcdoc creates an opaque origin which prevents WebGL canvas updates

specification/draft/apps.mdx

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,23 @@ interface McpUiResourceCsp {
132132
*/
133133
resourceDomains?: string[],
134134
/**
135-
* Origins for nested iframes (frame-src directive).
135+
* Origins for nested iframes
136+
*
137+
* - Empty or omitted = no nested iframes allowed (`frame-src 'none'`)
138+
* - Maps to CSP `frame-src` directive
139+
*
140+
* @example
141+
* ["https://www.youtube.com", "https://player.vimeo.com"]
136142
*/
137143
frameDomains?: string[],
138144
/**
139-
* Allowed base URIs for the document (base-uri directive).
145+
* Allowed base URIs for the document
146+
*
147+
* - Empty or omitted = only same origin allowed (`base-uri 'self'`)
148+
* - Maps to CSP `base-uri` directive
149+
*
150+
* @example
151+
* ["https://cdn.example.com"]
140152
*/
141153
baseUriDomains?: string[],
142154
}
@@ -149,6 +161,39 @@ interface UIResourceMeta {
149161
* Hosts use this to enforce appropriate CSP headers.
150162
*/
151163
csp?: McpUiResourceCsp,
164+
/**
165+
* Sandbox permissions requested by the UI
166+
*
167+
* Servers declare which browser capabilities their UI needs.
168+
* Hosts MAY honor these by setting appropriate iframe `allow` attributes.
169+
* Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback.
170+
*/
171+
permissions?: {
172+
/**
173+
* Request camera access
174+
*
175+
* Maps to Permission Policy `camera` feature
176+
*/
177+
camera?: boolean,
178+
/**
179+
* Request microphone access
180+
*
181+
* Maps to Permission Policy `microphone` feature
182+
*/
183+
microphone?: boolean,
184+
/**
185+
* Request geolocation access
186+
*
187+
* Maps to Permission Policy `geolocation` feature
188+
*/
189+
geolocation?: boolean,
190+
/**
191+
* Request clipboard write access
192+
*
193+
* Maps to Permission Policy `clipboard-write` feature
194+
*/
195+
clipboardWrite?: boolean,
196+
},
152197
/**
153198
* Dedicated origin for widget
154199
*
@@ -193,6 +238,12 @@ The resource content is returned via `resources/read`:
193238
frameDomains?: string[]; // Origins for nested iframes (frame-src directive).
194239
baseUriDomains?: string[]; // Allowed base URIs for the document (base-uri directive).
195240
};
241+
permissions?: {
242+
camera?: boolean; // Request camera access
243+
microphone?: boolean; // Request microphone access
244+
geolocation?: boolean; // Request geolocation access
245+
clipboardWrite?: boolean; // Request clipboard write access
246+
};
196247
domain?: string;
197248
prefersBorder?: boolean;
198249
};
@@ -416,9 +467,11 @@ If the Host is a web page, it MUST wrap the Guest UI and communicate with it thr
416467
4. Once the Sandbox is ready, the Host MUST send the raw HTML resource to load in a `ui/notifications/sandbox-resource-ready` notification.
417468
5. The Sandbox MUST load the raw HTML of the Guest UI with CSP settings that:
418469
- Enforce the domains declared in `ui.csp` metadata
419-
- Prevent nested iframes (`frame-src 'none'`)
420-
- Block dangerous features (`object-src 'none'`, `base-uri 'self'`)
470+
- If `frameDomains` is provided, allow nested iframes from declared origins; otherwise use `frame-src 'none'`
471+
- If `baseUriDomains` is provided, allow base URIs from declared origins; otherwise use `base-uri 'self'`
472+
- Block dangerous features (`object-src 'none'`)
421473
- Apply restrictive defaults if no CSP metadata is provided
474+
- If `permissions` is declared, the Sandbox MAY set the inner iframe's `allow` attribute accordingly
422475
6. The Sandbox MUST forward messages sent by the Host to the Guest UI, 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 Guest UI. The Host MUST NOT send any request or notification to the Guest UI before it receives an `initialized` notification.
423476
7. The Sandbox SHOULD NOT create/send any requests to the Host or to the Guest UI (this would require synthesizing new request ids).
424477
8. The Host MAY forward any message from the Guest UI (coming via the Sandbox) to the MCP Apps server, for any method that doesn’t start with `ui/`. While the Host SHOULD ensure the Guest UI’s MCP connection is spec-compliant, it MAY decide to block some messages or subject them to further user approval.
@@ -535,6 +588,53 @@ Example:
535588
}
536589
```
537590

591+
### Host Capabilities
592+
593+
`HostCapabilities` are sent to the Guest UI as part of the response to `ui/initialize` (inside `McpUiInitializeResult`).
594+
They describe the features and capabilities that the Host supports.
595+
596+
```typescript
597+
interface HostCapabilities {
598+
/** Experimental features (structure TBD). */
599+
experimental?: {};
600+
/** Host supports opening external URLs. */
601+
openLinks?: {};
602+
/** Host can proxy tool calls to the MCP server. */
603+
serverTools?: {
604+
/** Host supports tools/list_changed notifications. */
605+
listChanged?: boolean;
606+
};
607+
/** Host can proxy resource reads to the MCP server. */
608+
serverResources?: {
609+
/** Host supports resources/list_changed notifications. */
610+
listChanged?: boolean;
611+
};
612+
/** Host accepts log messages. */
613+
logging?: {};
614+
/** Sandbox configuration applied by the host. */
615+
sandbox?: {
616+
/** Permissions granted by the host (camera, microphone, geolocation, clipboard-write). */
617+
permissions?: {
618+
camera?: boolean;
619+
microphone?: boolean;
620+
geolocation?: boolean;
621+
clipboardWrite?: boolean;
622+
};
623+
/** CSP domains approved by the host. */
624+
csp?: {
625+
/** Approved origins for network requests (fetch/XHR/WebSocket). */
626+
connectDomains?: string[];
627+
/** Approved origins for static resources (scripts, images, styles, fonts). */
628+
resourceDomains?: string[];
629+
/** Approved origins for nested iframes (frame-src directive). */
630+
frameDomains?: string[];
631+
/** Approved base URIs for the document (base-uri directive). */
632+
baseUriDomains?: string[];
633+
};
634+
};
635+
}
636+
```
637+
538638
### Container Dimensions
539639

540640
The `HostContext` provides sizing information via `containerDimensions`:
@@ -1028,12 +1128,24 @@ These messages are reserved for web-based hosts that implement the recommended d
10281128
method: "ui/notifications/sandbox-resource-ready",
10291129
params: {
10301130
html: string, // HTML content to load
1031-
sandbox: string // Optional override for inner iframe `sandbox` attribute
1131+
sandbox?: string, // Optional override for inner iframe `sandbox` attribute
1132+
csp?: { // CSP configuration from resource metadata
1133+
connectDomains?: string[],
1134+
resourceDomains?: string[],
1135+
frameDomains?: string[],
1136+
baseUriDomains?: string[],
1137+
},
1138+
permissions?: { // Sandbox permissions from resource metadata
1139+
camera?: boolean,
1140+
microphone?: boolean,
1141+
geolocation?: boolean,
1142+
clipboardWrite?: boolean,
1143+
}
10321144
}
10331145
}
10341146
```
10351147

1036-
These messages facilitate the communication between the outer sandbox proxy iframe and the host, enabling secure loading of untrusted HTML content.
1148+
These messages facilitate the communication between the outer sandbox proxy iframe and the host, enabling secure loading of untrusted HTML content. The `permissions` field maps to the inner iframe's `allow` attribute for Permission Policy features.
10371149

10381150
### Lifecycle
10391151

@@ -1489,6 +1601,7 @@ Hosts MUST enforce Content Security Policies based on resource metadata.
14891601

14901602
```typescript
14911603
const csp = resource._meta?.ui?.csp;
1604+
const permissions = resource._meta?.ui?.permissions;
14921605

14931606
const cspValue = `
14941607
default-src 'none';
@@ -1498,10 +1611,17 @@ const cspValue = `
14981611
img-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''};
14991612
font-src 'self' ${csp?.resourceDomains?.join(' ') || ''};
15001613
media-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''};
1501-
frame-src 'none';
1614+
frame-src ${csp?.frameDomains?.join(' ') || "'none'"};
15021615
object-src 'none';
1503-
base-uri 'self';
1616+
base-uri ${csp?.baseUriDomains?.join(' ') || "'self'"};
15041617
`;
1618+
1619+
// Permission Policy for iframe allow attribute
1620+
const allowList: string[] = [];
1621+
if (permissions?.camera) allowList.push('camera');
1622+
if (permissions?.microphone) allowList.push('microphone');
1623+
if (permissions?.geolocation) allowList.push('geolocation');
1624+
const allowAttribute = allowList.join(' ');
15051625
```
15061626

15071627
**Security Requirements:**

0 commit comments

Comments
 (0)