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
1 change: 1 addition & 0 deletions examples/basic-host/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"prettier": "^3.6.2",
"vite": "^6.0.0",
Expand Down
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 @@ -108,15 +115,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 @@ -162,10 +170,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
1 change: 1 addition & 0 deletions examples/basic-server-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
Expand Down
1 change: 1 addition & 0 deletions examples/basic-server-vanillajs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@types/node": "^22.0.0",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
Expand Down
1 change: 1 addition & 0 deletions examples/budget-allocator-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@types/node": "^22.0.0",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
Expand Down
1 change: 1 addition & 0 deletions examples/cohort-heatmap-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
Expand Down
1 change: 1 addition & 0 deletions examples/customer-segmentation-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@types/node": "^22.0.0",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
Expand Down
1 change: 1 addition & 0 deletions examples/scenario-modeler-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
Expand Down
128 changes: 128 additions & 0 deletions examples/simple-host/sandbox.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<!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);

// Build iframe allow attribute from permissions
function buildAllowAttribute(permissions) {
if (!permissions) return '';
const allowList = [];
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) {
if (event.data && event.data.method === 'ui/notifications/sandbox-resource-ready') {
const { html, sandbox, permissions } = event.data.params || {};
// Note: csp is not extracted here - CSP is set via HTTP response headers in serve.ts
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);
}
// Set Permission Policy allow attribute if permissions are provided
const allowAttribute = buildAllowAttribute(permissions);
if (allowAttribute) {
inner.setAttribute('allow', allowAttribute);
}
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>
1 change: 1 addition & 0 deletions examples/system-monitor-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@types/node": "^22.0.0",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
Expand Down
1 change: 1 addition & 0 deletions examples/threejs-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
Expand Down
1 change: 1 addition & 0 deletions examples/wiki-explorer-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@types/node": "^22.0.0",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"force-graph": "^1.49.0",
"typescript": "^5.9.3",
Expand Down
Loading
Loading