Skip to content

Commit 699549e

Browse files
committed
feat: enhance sandbox capability negotiation
1 parent e316259 commit 699549e

File tree

9 files changed

+525
-54
lines changed

9 files changed

+525
-54
lines changed

examples/basic-host/src/implementation.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ interface UiResourceData {
4545
csp?: {
4646
connectDomains?: string[];
4747
resourceDomains?: string[];
48+
frameDomains?: string[];
49+
baseUriDomains?: string[];
50+
};
51+
permissions?: {
52+
camera?: boolean;
53+
microphone?: boolean;
54+
geolocation?: boolean;
4855
};
4956
}
5057

@@ -118,15 +125,16 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiRes
118125

119126
const html = "blob" in content ? atob(content.blob) : content.text;
120127

121-
// Extract CSP metadata from resource content._meta.ui.csp (or content.meta for Python SDK)
128+
// Extract CSP and permissions metadata from resource content._meta.ui (or content.meta for Python SDK)
122129
log.info("Resource content keys:", Object.keys(content));
123130
log.info("Resource content._meta:", (content as any)._meta);
124131

125132
// Try both _meta (spec) and meta (Python SDK quirk)
126133
const contentMeta = (content as any)._meta || (content as any).meta;
127134
const csp = contentMeta?.ui?.csp;
135+
const permissions = contentMeta?.ui?.permissions;
128136

129-
return { html, csp };
137+
return { html, csp, permissions };
130138
}
131139

132140

@@ -172,10 +180,10 @@ export async function initializeApp(
172180
new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!),
173181
);
174182

175-
// Load inner iframe HTML with CSP metadata
176-
const { html, csp } = await appResourcePromise;
177-
log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "");
178-
await appBridge.sendSandboxResourceReady({ html, csp });
183+
// Load inner iframe HTML with CSP and permissions metadata
184+
const { html, csp, permissions } = await appResourcePromise;
185+
log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "", permissions ? `(Permissions: ${JSON.stringify(permissions)})` : "");
186+
await appBridge.sendSandboxResourceReady({ html, csp, permissions });
179187

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

examples/basic-host/src/sandbox.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,16 @@ const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
5757
// intercepted here (not relayed) because the Sandbox uses it to configure and
5858
// load the inner iframe with the Guest UI HTML content.
5959
// Build CSP meta tag from domains
60-
function buildCspMetaTag(csp?: { connectDomains?: string[]; resourceDomains?: string[] }): string {
60+
function buildCspMetaTag(csp?: {
61+
connectDomains?: string[];
62+
resourceDomains?: string[];
63+
frameDomains?: string[];
64+
baseUriDomains?: string[];
65+
}): string {
6166
const resourceDomains = csp?.resourceDomains?.join(" ") ?? "";
6267
const connectDomains = csp?.connectDomains?.join(" ") ?? "";
68+
const frameDomains = csp?.frameDomains?.join(" ");
69+
const baseUriDomains = csp?.baseUriDomains?.join(" ");
6370

6471
// Base CSP directives
6572
const directives = [
@@ -69,23 +76,47 @@ function buildCspMetaTag(csp?: { connectDomains?: string[]; resourceDomains?: st
6976
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
7077
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
7178
`connect-src 'self' ${connectDomains}`.trim(),
72-
"frame-src 'none'",
79+
// Use frameDomains if provided, otherwise default to 'none'
80+
frameDomains ? `frame-src ${frameDomains}` : "frame-src 'none'",
7381
"object-src 'none'",
74-
"base-uri 'self'",
82+
// Use baseUriDomains if provided, otherwise default to 'self'
83+
baseUriDomains ? `base-uri ${baseUriDomains}` : "base-uri 'self'",
7584
];
7685

7786
return `<meta http-equiv="Content-Security-Policy" content="${directives.join("; ")}">`;
7887
}
7988

89+
// Build iframe allow attribute from permissions
90+
function buildAllowAttribute(permissions?: {
91+
camera?: boolean;
92+
microphone?: boolean;
93+
geolocation?: boolean;
94+
}): string {
95+
if (!permissions) return "";
96+
97+
const allowList: string[] = [];
98+
if (permissions.camera) allowList.push("camera");
99+
if (permissions.microphone) allowList.push("microphone");
100+
if (permissions.geolocation) allowList.push("geolocation");
101+
102+
return allowList.join("; ");
103+
}
104+
80105
window.addEventListener("message", async (event) => {
81106
if (event.source === window.parent) {
82107
// NOTE: In production you'll also want to validate `event.origin` against
83108
// your Host domain.
84109
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
85-
const { html, sandbox, csp } = event.data.params;
110+
const { html, sandbox, csp, permissions } = event.data.params;
86111
if (typeof sandbox === "string") {
87112
inner.setAttribute("sandbox", sandbox);
88113
}
114+
// Set Permission Policy allow attribute if permissions are requested
115+
const allowAttribute = buildAllowAttribute(permissions);
116+
if (allowAttribute) {
117+
console.log("[Sandbox] Setting allow attribute:", allowAttribute);
118+
inner.setAttribute("allow", allowAttribute);
119+
}
89120
if (typeof html === "string") {
90121
// Inject CSP meta tag at the start of <head> if CSP is provided
91122
console.log("[Sandbox] Received CSP:", csp);

examples/simple-host/sandbox.html

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<!-- CSP is set via HTTP response headers in serve.ts -->
6+
<!-- Note: No CSP meta tag here - CSP is set on the actual remote HTML content -->
7+
<!-- and needs to be super relaxed otherwise nothing loads -->
8+
<title>MCP-UI Proxy</title>
9+
<style>
10+
html,
11+
body {
12+
margin: 0;
13+
height: 100vh;
14+
width: 100vw;
15+
}
16+
body {
17+
display: flex;
18+
flex-direction: column;
19+
}
20+
* {
21+
box-sizing: border-box;
22+
}
23+
iframe {
24+
background-color: transparent;
25+
border: 0px none transparent;
26+
padding: 0px;
27+
overflow: hidden;
28+
flex-grow: 1;
29+
}
30+
</style>
31+
</head>
32+
<body>
33+
<script>
34+
// Security checks
35+
if (window.self === window.top) {
36+
throw new Error("This file is only to be used in an iframe sandbox.");
37+
}
38+
if (!document.referrer) {
39+
throw new Error("No referrer, cannot validate embedding site.");
40+
}
41+
if (!document.referrer.match(/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/)) {
42+
throw new Error(`Embedding domain not allowed in referrer ${document.referrer} (update the validation logic to allow your domain)`);
43+
}
44+
45+
// Try to break out of iframe (security test)
46+
try {
47+
window.top.alert("If you see this, the sandbox is not setup securely.");
48+
throw new Error("Managed to break out of iframe, the sandbox is not setup securely.");
49+
} catch (e) {
50+
// Expected to fail
51+
}
52+
53+
const inner = document.createElement('iframe');
54+
inner.style = 'width:100%; height:100%; border:none;';
55+
// sandbox will be set from postMessage payload; default minimal before html arrives
56+
// Use allow-same-origin for document.write to work
57+
inner.setAttribute('sandbox', 'allow-scripts allow-same-origin');
58+
document.body.appendChild(inner);
59+
60+
window.addEventListener('message', async (event) => {
61+
if (event.source === window.parent) {
62+
if (event.data && event.data.method === 'ui/notifications/sandbox-resource-ready') {
63+
const { html, sandbox } = event.data.params || {};
64+
if (typeof sandbox === 'string') {
65+
// Ensure allow-same-origin is present for document.write to work
66+
let finalSandbox = sandbox;
67+
if (!finalSandbox.includes('allow-same-origin')) {
68+
finalSandbox = finalSandbox + ' allow-same-origin';
69+
}
70+
inner.setAttribute('sandbox', finalSandbox);
71+
}
72+
if (typeof html === 'string') {
73+
// Use document.write instead of srcdoc to avoid CSP base-uri issues
74+
// document.write allows the browser to resolve relative URLs correctly
75+
// Match the pattern from mcp-ui proxy script (https://github.com/MCP-UI-Org/mcp-ui/pull/140)
76+
// This is the main change to support the double iframe exactly the same way as ChatGPT
77+
// The iframe is rendered first (created at page load), then we write when HTML arrives
78+
try {
79+
if (inner.contentDocument) {
80+
inner.contentDocument.open();
81+
inner.contentDocument.write(html);
82+
inner.contentDocument.close();
83+
} else {
84+
// Fallback to srcdoc if contentDocument is not accessible
85+
inner.srcdoc = html;
86+
}
87+
} catch (e) {
88+
console.error('Failed to write HTML to iframe:', e);
89+
// Fallback to srcdoc if document.write fails
90+
inner.srcdoc = html;
91+
}
92+
}
93+
} else {
94+
if (inner && inner.contentWindow) {
95+
inner.contentWindow.postMessage(event.data, '*');
96+
}
97+
}
98+
} else if (event.source === inner.contentWindow) {
99+
// Relay messages from inner to parent
100+
window.parent.postMessage(event.data, '*');
101+
}
102+
});
103+
104+
// Notify parent that proxy is ready to receive HTML
105+
window.parent.postMessage({
106+
jsonrpc: '2.0',
107+
method: 'ui/notifications/sandbox-proxy-ready',
108+
params: {}
109+
}, '*');
110+
</script>
111+
</body>
112+
</html>

specification/draft/apps.mdx

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,53 @@ interface UIResourceMeta {
138138
* ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"]
139139
*/
140140
resourceDomains?: string[],
141+
/**
142+
* Origins for nested iframes
143+
*
144+
* - Empty or omitted = no nested iframes allowed (`frame-src 'none'`)
145+
* - Maps to CSP `frame-src` directive
146+
*
147+
* @example
148+
* ["https://www.youtube.com", "https://player.vimeo.com"]
149+
*/
150+
frameDomains?: string[],
151+
/**
152+
* Allowed base URIs for the document
153+
*
154+
* - Empty or omitted = only same origin allowed (`base-uri 'self'`)
155+
* - Maps to CSP `base-uri` directive
156+
*
157+
* @example
158+
* ["https://cdn.example.com"]
159+
*/
160+
baseUriDomains?: string[],
161+
},
162+
/**
163+
* Sandbox permissions requested by the UI
164+
*
165+
* Servers declare which browser capabilities their UI needs.
166+
* Hosts MAY honor these by setting appropriate iframe `allow` attributes.
167+
* Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback.
168+
*/
169+
permissions?: {
170+
/**
171+
* Request camera access
172+
*
173+
* Maps to Permission Policy `camera` feature
174+
*/
175+
camera?: boolean,
176+
/**
177+
* Request microphone access
178+
*
179+
* Maps to Permission Policy `microphone` feature
180+
*/
181+
microphone?: boolean,
182+
/**
183+
* Request geolocation access
184+
*
185+
* Maps to Permission Policy `geolocation` feature
186+
*/
187+
geolocation?: boolean,
141188
},
142189
/**
143190
* Dedicated origin for widget
@@ -180,6 +227,13 @@ The resource content is returned via `resources/read`:
180227
csp?: {
181228
connectDomains?: string[]; // Origins for fetch/XHR/WebSocket
182229
resourceDomains?: string[]; // Origins for images, scripts, etc
230+
frameDomains?: string[]; // Origins for nested iframes
231+
baseUriDomains?: string[]; // Allowed base URIs
232+
};
233+
permissions?: {
234+
camera?: boolean; // Request camera access
235+
microphone?: boolean; // Request microphone access
236+
geolocation?: boolean; // Request geolocation access
183237
};
184238
domain?: string;
185239
prefersBorder?: boolean;
@@ -362,9 +416,11 @@ If the Host is a web page, it MUST wrap the Guest UI and communicate with it thr
362416
4. Once the Sandbox is ready, the Host MUST send the raw HTML resource to load in a `ui/notifications/sandbox-resource-ready` notification.
363417
5. The Sandbox MUST load the raw HTML of the Guest UI with CSP settings that:
364418
- Enforce the domains declared in `ui.csp` metadata
365-
- Prevent nested iframes (`frame-src 'none'`)
366-
- Block dangerous features (`object-src 'none'`, `base-uri 'self'`)
419+
- If `frameDomains` is provided, allow nested iframes from declared origins; otherwise use `frame-src 'none'`
420+
- If `baseUriDomains` is provided, allow base URIs from declared origins; otherwise use `base-uri 'self'`
421+
- Block dangerous features (`object-src 'none'`)
367422
- Apply restrictive defaults if no CSP metadata is provided
423+
- If `permissions` is declared, the Sandbox MAY set the inner iframe's `allow` attribute accordingly
368424
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.
369425
7. The Sandbox SHOULD NOT create/send any requests to the Host or to the Guest UI (this would require synthesizing new request ids).
370426
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.
@@ -826,12 +882,23 @@ These messages are reserved for web-based hosts that implement the recommended d
826882
method: "ui/notifications/sandbox-resource-ready",
827883
params: {
828884
html: string, // HTML content to load
829-
sandbox: string // Optional override for inner iframe `sandbox` attribute
885+
sandbox?: string, // Optional override for inner iframe `sandbox` attribute
886+
csp?: { // CSP configuration from resource metadata
887+
connectDomains?: string[],
888+
resourceDomains?: string[],
889+
frameDomains?: string[],
890+
baseUriDomains?: string[],
891+
},
892+
permissions?: { // Sandbox permissions from resource metadata
893+
camera?: boolean,
894+
microphone?: boolean,
895+
geolocation?: boolean,
896+
}
830897
}
831898
}
832899
```
833900

834-
These messages facilitate the communication between the outer sandbox proxy iframe and the host, enabling secure loading of untrusted HTML content.
901+
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.
835902

836903
### Lifecycle
837904

@@ -1269,6 +1336,7 @@ Hosts MUST enforce Content Security Policies based on resource metadata.
12691336

12701337
```typescript
12711338
const csp = resource._meta?.ui?.csp;
1339+
const permissions = resource._meta?.ui?.permissions;
12721340

12731341
const cspValue = `
12741342
default-src 'none';
@@ -1278,10 +1346,17 @@ const cspValue = `
12781346
img-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''};
12791347
font-src 'self' ${csp?.resourceDomains?.join(' ') || ''};
12801348
media-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''};
1281-
frame-src 'none';
1349+
frame-src ${csp?.frameDomains?.join(' ') || "'none'"};
12821350
object-src 'none';
1283-
base-uri 'self';
1351+
base-uri ${csp?.baseUriDomains?.join(' ') || "'self'"};
12841352
`;
1353+
1354+
// Permission Policy for iframe allow attribute
1355+
const allowList: string[] = [];
1356+
if (permissions?.camera) allowList.push('camera');
1357+
if (permissions?.microphone) allowList.push('microphone');
1358+
if (permissions?.geolocation) allowList.push('geolocation');
1359+
const allowAttribute = allowList.join('; ');
12851360
```
12861361

12871362
**Security Requirements:**

0 commit comments

Comments
 (0)