Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
20 changes: 14 additions & 6 deletions examples/basic-host/src/implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ interface UiResourceData {
csp?: {
connectDomains?: string[];
resourceDomains?: string[];
frameDomains?: string[];
baseUriDomains?: string[];
};
permissions?: {
camera?: boolean;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's make these objects (as in capabilities) for future extensions?
e.g. what if one day there's fine-grained vers. coarse geolocation, or front vs. back camera permission, etc.

Copy link
Collaborator Author

@idosal idosal Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like CSP, Permissions are coupled to the browser spec (Permissions Policy). I don't think we should diverge at this point. WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are 40+ Permission Policies you can stuff in an iFrame. Consider keeping permissions very simple and flexible, just have it be a string[].

Benefits:

  1. Client can set all permission policies with a single string. Flexible for future extensions.
  2. Enforcing this on the client side is simple. We just stuff the string into an iFrame's allow. This is safe because invalid strings are silently ignored.
  3. Still easily parseable on the server side. Server developer only has to do permissions.contains("camera");.

Copy link
Contributor

@matteo8p matteo8p Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively create some interface and have permission be an array of that interface if type safety and enforcement is of high importance.

interface Permissions {
   camera: "camera", 
   ....
}

permissions?: Permissions[]

microphone?: boolean;
geolocation?: boolean;
};
}

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

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

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

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

return { html, csp };
return { html, csp, permissions };
}


Expand Down Expand Up @@ -172,10 +180,10 @@ export async function initializeApp(
new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!),
);

// Load inner iframe HTML with CSP metadata
const { html, csp } = await appResourcePromise;
log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "");
await appBridge.sendSandboxResourceReady({ html, csp });
// 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 });

// Wait for inner iframe to be ready
log.info("Waiting for MCP App to initialize...");
Expand Down
39 changes: 35 additions & 4 deletions examples/basic-host/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,16 @@ const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
// intercepted here (not relayed) because the Sandbox uses it to configure and
// load the inner iframe with the Guest UI HTML content.
// Build CSP meta tag from domains
function buildCspMetaTag(csp?: { connectDomains?: string[]; resourceDomains?: string[] }): string {
function buildCspMetaTag(csp?: {
connectDomains?: string[];
resourceDomains?: string[];
frameDomains?: string[];
baseUriDomains?: string[];
}): string {
const resourceDomains = csp?.resourceDomains?.join(" ") ?? "";
const connectDomains = csp?.connectDomains?.join(" ") ?? "";
const frameDomains = csp?.frameDomains?.join(" ");
const baseUriDomains = csp?.baseUriDomains?.join(" ");

// Base CSP directives
const directives = [
Expand All @@ -69,23 +76,47 @@ function buildCspMetaTag(csp?: { connectDomains?: string[]; resourceDomains?: st
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
`connect-src 'self' ${connectDomains}`.trim(),
"frame-src 'none'",
// Use frameDomains if provided, otherwise default to 'none'
frameDomains ? `frame-src ${frameDomains}` : "frame-src 'none'",
"object-src 'none'",
"base-uri 'self'",
// Use baseUriDomains if provided, otherwise default to 'self'
baseUriDomains ? `base-uri ${baseUriDomains}` : "base-uri 'self'",
];

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

// Build iframe allow attribute from permissions
function buildAllowAttribute(permissions?: {
camera?: boolean;
microphone?: boolean;
geolocation?: boolean;
}): string {
if (!permissions) return "";

const allowList: string[] = [];
if (permissions.camera) allowList.push("camera");
if (permissions.microphone) allowList.push("microphone");
if (permissions.geolocation) allowList.push("geolocation");

return allowList.join("; ");
}

window.addEventListener("message", async (event) => {
if (event.source === window.parent) {
// NOTE: In production you'll also want to validate `event.origin` against
// your Host domain.
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
const { html, sandbox, csp } = event.data.params;
const { html, sandbox, csp, permissions } = event.data.params;
if (typeof sandbox === "string") {
inner.setAttribute("sandbox", sandbox);
}
// 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);
}
if (typeof html === "string") {
// Inject CSP meta tag at the start of <head> if CSP is provided
console.log("[Sandbox] Received CSP:", csp);
Expand Down
112 changes: 112 additions & 0 deletions examples/simple-host/sandbox.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<!-- CSP is set via HTTP response headers in serve.ts -->
<!-- Note: No CSP meta tag here - CSP is set on the actual remote HTML content -->
<!-- and needs to be super relaxed otherwise nothing loads -->
<title>MCP-UI Proxy</title>
<style>
html,
body {
margin: 0;
height: 100vh;
width: 100vw;
}
body {
display: flex;
flex-direction: column;
}
* {
box-sizing: border-box;
}
iframe {
background-color: transparent;
border: 0px none transparent;
padding: 0px;
overflow: hidden;
flex-grow: 1;
}
</style>
</head>
<body>
<script>
// Security checks
if (window.self === window.top) {
throw new Error("This file is only to be used in an iframe sandbox.");
}
if (!document.referrer) {
throw new Error("No referrer, cannot validate embedding site.");
}
if (!document.referrer.match(/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/)) {
throw new Error(`Embedding domain not allowed in referrer ${document.referrer} (update the validation logic to allow your domain)`);
}

// Try to break out of iframe (security test)
try {
window.top.alert("If you see this, the sandbox is not setup securely.");
throw new Error("Managed to break out of iframe, the sandbox is not setup securely.");
} catch (e) {
// Expected to fail
}

const inner = document.createElement('iframe');
inner.style = 'width:100%; height:100%; border:none;';
// sandbox will be set from postMessage payload; default minimal before html arrives
// Use allow-same-origin for document.write to work
inner.setAttribute('sandbox', 'allow-scripts allow-same-origin');
document.body.appendChild(inner);

window.addEventListener('message', async (event) => {
if (event.source === window.parent) {
if (event.data && event.data.method === 'ui/notifications/sandbox-resource-ready') {
const { html, sandbox } = event.data.params || {};
if (typeof sandbox === 'string') {
// Ensure allow-same-origin is present for document.write to work
let finalSandbox = sandbox;
if (!finalSandbox.includes('allow-same-origin')) {
finalSandbox = finalSandbox + ' allow-same-origin';
}
inner.setAttribute('sandbox', finalSandbox);
}
if (typeof html === 'string') {
// Use document.write instead of srcdoc to avoid CSP base-uri issues
// document.write allows the browser to resolve relative URLs correctly
// Match the pattern from mcp-ui proxy script (https://github.com/MCP-UI-Org/mcp-ui/pull/140)
// This is the main change to support the double iframe exactly the same way as ChatGPT
// The iframe is rendered first (created at page load), then we write when HTML arrives
try {
if (inner.contentDocument) {
inner.contentDocument.open();
inner.contentDocument.write(html);
inner.contentDocument.close();
} else {
// Fallback to srcdoc if contentDocument is not accessible
inner.srcdoc = html;
}
} catch (e) {
console.error('Failed to write HTML to iframe:', e);
// Fallback to srcdoc if document.write fails
inner.srcdoc = html;
}
}
} else {
if (inner && inner.contentWindow) {
inner.contentWindow.postMessage(event.data, '*');
}
}
} else if (event.source === inner.contentWindow) {
// Relay messages from inner to parent
window.parent.postMessage(event.data, '*');
}
});

// Notify parent that proxy is ready to receive HTML
window.parent.postMessage({
jsonrpc: '2.0',
method: 'ui/notifications/sandbox-proxy-ready',
params: {}
}, '*');
</script>
</body>
</html>
87 changes: 81 additions & 6 deletions specification/draft/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,53 @@ interface UIResourceMeta {
* ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"]
*/
resourceDomains?: string[],
/**
* Origins for nested iframes
*
* - Empty or omitted = no nested iframes allowed (`frame-src 'none'`)
* - Maps to CSP `frame-src` directive
*
* @example
* ["https://www.youtube.com", "https://player.vimeo.com"]
*/
frameDomains?: string[],
/**
* Allowed base URIs for the document
*
* - Empty or omitted = only same origin allowed (`base-uri 'self'`)
* - Maps to CSP `base-uri` directive
*
* @example
* ["https://cdn.example.com"]
*/
baseUriDomains?: string[],
},
/**
* Sandbox permissions requested by the UI
*
* Servers declare which browser capabilities their UI needs.
* Hosts MAY honor these by setting appropriate iframe `allow` attributes.
* Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback.
*/
permissions?: {
/**
* Request camera access
*
* Maps to Permission Policy `camera` feature
*/
camera?: boolean,
/**
* Request microphone access
*
* Maps to Permission Policy `microphone` feature
*/
microphone?: boolean,
/**
* Request geolocation access
*
* Maps to Permission Policy `geolocation` feature
*/
geolocation?: boolean,
},
/**
* Dedicated origin for widget
Expand Down Expand Up @@ -180,6 +227,13 @@ The resource content is returned via `resources/read`:
csp?: {
connectDomains?: string[]; // Origins for fetch/XHR/WebSocket
resourceDomains?: string[]; // Origins for images, scripts, etc
frameDomains?: string[]; // Origins for nested iframes
baseUriDomains?: string[]; // Allowed base URIs
};
permissions?: {
camera?: boolean; // Request camera access
microphone?: boolean; // Request microphone access
geolocation?: boolean; // Request geolocation access
};
domain?: string;
prefersBorder?: boolean;
Expand Down Expand Up @@ -362,9 +416,11 @@ If the Host is a web page, it MUST wrap the Guest UI and communicate with it thr
4. Once the Sandbox is ready, the Host MUST send the raw HTML resource to load in a `ui/notifications/sandbox-resource-ready` notification.
5. The Sandbox MUST load the raw HTML of the Guest UI with CSP settings that:
- Enforce the domains declared in `ui.csp` metadata
- Prevent nested iframes (`frame-src 'none'`)
- Block dangerous features (`object-src 'none'`, `base-uri 'self'`)
- If `frameDomains` is provided, allow nested iframes from declared origins; otherwise use `frame-src 'none'`
- 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
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.
7. The Sandbox SHOULD NOT create/send any requests to the Host or to the Guest UI (this would require synthesizing new request ids).
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.
Expand Down Expand Up @@ -826,12 +882,23 @@ These messages are reserved for web-based hosts that implement the recommended d
method: "ui/notifications/sandbox-resource-ready",
params: {
html: string, // HTML content to load
sandbox: string // Optional override for inner iframe `sandbox` attribute
sandbox?: string, // Optional override for inner iframe `sandbox` attribute
csp?: { // CSP configuration from resource metadata
connectDomains?: string[],
resourceDomains?: string[],
frameDomains?: string[],
baseUriDomains?: string[],
},
permissions?: { // Sandbox permissions from resource metadata
camera?: boolean,
microphone?: boolean,
geolocation?: boolean,
}
}
}
```

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

### Lifecycle

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

```typescript
const csp = resource._meta?.ui?.csp;
const permissions = resource._meta?.ui?.permissions;

const cspValue = `
default-src 'none';
Expand All @@ -1278,10 +1346,17 @@ const cspValue = `
img-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''};
font-src 'self' ${csp?.resourceDomains?.join(' ') || ''};
media-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''};
frame-src 'none';
frame-src ${csp?.frameDomains?.join(' ') || "'none'"};
object-src 'none';
base-uri 'self';
base-uri ${csp?.baseUriDomains?.join(' ') || "'self'"};
`;

// Permission Policy for iframe allow attribute
const allowList: string[] = [];
if (permissions?.camera) allowList.push('camera');
if (permissions?.microphone) allowList.push('microphone');
if (permissions?.geolocation) allowList.push('geolocation');
const allowAttribute = allowList.join(' ');
```

**Security Requirements:**
Expand Down
Loading
Loading