Skip to content
Merged
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
4 changes: 2 additions & 2 deletions examples/basic-host/sandbox.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
<head>
<meta charset="utf-8" />
<meta name="color-scheme" content="light dark">
<!-- CSP is set by serve.ts HTTP header - no meta tag needed here
The inner iframe's CSP is dynamically injected based on resource metadata -->
<!-- CSP is set via HTTP headers by serve.ts (based on ?csp= query param).
The inner iframe inherits this CSP since we use document.write(). -->
<title>MCP-UI Proxy</title>
<style>
html,
Expand Down
86 changes: 69 additions & 17 deletions examples/basic-host/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@
/**
* HTTP servers for the MCP UI example:
* - Host server (port 8080): serves host HTML files (React and Vanilla examples)
* - Sandbox server (port 8081): serves sandbox.html with permissive CSP
* - Sandbox server (port 8081): serves sandbox.html with CSP headers
*
* Running on separate ports ensures proper origin isolation for security.
*
* Security: CSP is set via HTTP headers based on ?csp= query param.
* This ensures content cannot tamper with CSP (unlike meta tags).
*/

import express from "express";
import cors from "cors";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -50,26 +54,74 @@ hostApp.get("/", (_req, res) => {
const sandboxApp = express();
sandboxApp.use(cors());

// Permissive CSP for sandbox content
sandboxApp.use((_req, res, next) => {
const csp = [
"default-src 'self'",
"img-src * data: blob: 'unsafe-inline'",
"style-src * blob: data: 'unsafe-inline'",
"script-src * blob: data: 'unsafe-inline' 'unsafe-eval'",
"connect-src *",
"font-src * blob: data:",
"media-src * blob: data:",
"frame-src * blob: data:",
].join("; ");
res.setHeader("Content-Security-Policy", csp);
// Validate CSP domain entries to prevent injection attacks.
// Rejects entries containing characters that could:
// - `;` or newlines: break out to new CSP directive
// - quotes: inject CSP keywords like 'unsafe-eval'
// - space: inject multiple sources in one entry
function sanitizeCspDomains(domains?: string[]): string[] {
if (!domains) return [];
return domains.filter((d) => typeof d === "string" && !/[;\r\n'" ]/.test(d));
}

function buildCspHeader(csp?: McpUiResourceCsp): string {
const resourceDomains = sanitizeCspDomains(csp?.resourceDomains).join(" ");
const connectDomains = sanitizeCspDomains(csp?.connectDomains).join(" ");
const frameDomains = sanitizeCspDomains(csp?.frameDomains).join(" ") || null;
const baseUriDomains =
sanitizeCspDomains(csp?.baseUriDomains).join(" ") || null;

const directives = [
// Default: allow same-origin + inline styles/scripts (needed for bundled apps)
"default-src 'self' 'unsafe-inline'",
// Scripts: same-origin + inline + eval (some libs need eval) + blob (workers) + specified domains
`script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(),
// Styles: same-origin + inline + specified domains
`style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(),
// Images: same-origin + data/blob URIs + specified domains
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
// Fonts: same-origin + data/blob URIs + specified domains
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
Copy link
Collaborator

Choose a reason for hiding this comment

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

@claude do I understand correctly that we have a risk of csp injection here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Pushed a fix, ptal @antonpk1

// Network requests: same-origin + specified API/tile domains
`connect-src 'self' ${connectDomains}`.trim(),
// Workers: same-origin + blob (dynamic workers) + specified domains
// This is critical for WebGL apps (CesiumJS, Three.js) that use workers for:
// - Tile decoding and terrain processing
// - Image processing and texture loading
// - Physics and geometry calculations
`worker-src 'self' blob: ${resourceDomains}`.trim(),
// Nested iframes: use frameDomains if provided, otherwise block all
frameDomains ? `frame-src ${frameDomains}` : "frame-src 'none'",
// Plugins: always blocked (defense in depth)
"object-src 'none'",
// Base URI: use baseUriDomains if provided, otherwise block all
baseUriDomains ? `base-uri ${baseUriDomains}` : "base-uri 'none'",
];

return directives.join("; ");
}

// Serve sandbox.html with CSP from query params
sandboxApp.get(["/", "/sandbox.html"], (req, res) => {
// Parse CSP config from query param: ?csp=<url-encoded-json>
let cspConfig: McpUiResourceCsp | undefined;
if (typeof req.query.csp === "string") {
try {
cspConfig = JSON.parse(req.query.csp);
} catch (e) {
console.warn("[Sandbox] Invalid CSP query param:", e);
}
}

// Set CSP via HTTP header - tamper-proof unlike meta tags
const cspHeader = buildCspHeader(cspConfig);
res.setHeader("Content-Security-Policy", cspHeader);

// Prevent caching to ensure fresh CSP on each load
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
next();
});

sandboxApp.get(["/", "/sandbox.html"], (_req, res) => {
res.sendFile(join(DIRECTORY, "sandbox.html"));
});

Expand Down
24 changes: 15 additions & 9 deletions examples/basic-host/src/implementation.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";


const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html");
const SANDBOX_PROXY_BASE_URL = "http://localhost:8081/sandbox.html";
const IMPLEMENTATION = { name: "MCP Apps Host", version: "1.0.0" };


Expand Down Expand Up @@ -42,10 +42,7 @@ export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {

interface UiResourceData {
html: string;
csp?: {
connectDomains?: string[];
resourceDomains?: string[];
};
csp?: McpUiResourceCsp;
}

export interface ToolCallInfo {
Expand Down Expand Up @@ -120,7 +117,10 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiRes
}


export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> {
export function loadSandboxProxy(
iframe: HTMLIFrameElement,
csp?: McpUiResourceCsp,
): Promise<boolean> {
// Prevent reload
if (iframe.src) return Promise.resolve(false);

Expand All @@ -140,8 +140,14 @@ export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> {
window.addEventListener("message", listener);
});

log.info("Loading sandbox proxy...");
iframe.src = SANDBOX_PROXY_URL.href;
// Build sandbox URL with CSP query param for HTTP header-based CSP
const sandboxUrl = new URL(SANDBOX_PROXY_BASE_URL);
if (csp) {
sandboxUrl.searchParams.set("csp", JSON.stringify(csp));
}

log.info("Loading sandbox proxy...", csp ? `(CSP: ${JSON.stringify(csp)})` : "");
iframe.src = sandboxUrl.href;

return readyPromise;
}
Expand Down
25 changes: 15 additions & 10 deletions examples/basic-host/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,16 +263,21 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI

useEffect(() => {
const iframe = iframeRef.current!;
loadSandboxProxy(iframe).then((firstTime) => {
// The `firstTime` check guards against React Strict Mode's double
// invocation (mount → unmount → remount simulation in development).
// Outside of Strict Mode, this `useEffect` runs only once per
// `toolCallInfo`.
if (firstTime) {
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe);
appBridgeRef.current = appBridge;
initializeApp(iframe, appBridge, toolCallInfo);
}

// First get CSP from resource, then load sandbox with CSP in query param
// This ensures CSP is set via HTTP headers (tamper-proof)
toolCallInfo.appResourcePromise.then(({ csp }) => {
loadSandboxProxy(iframe, csp).then((firstTime) => {
// The `firstTime` check guards against React Strict Mode's double
// invocation (mount → unmount → remount simulation in development).
// Outside of Strict Mode, this `useEffect` runs only once per
// `toolCallInfo`.
if (firstTime) {
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe);
appBridgeRef.current = appBridge;
initializeApp(iframe, appBridge, toolCallInfo);
}
});
});
}, [toolCallInfo]);

Expand Down
54 changes: 17 additions & 37 deletions examples/basic-host/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,26 +62,9 @@ const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
// 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 {
const resourceDomains = csp?.resourceDomains?.join(" ") ?? "";
const connectDomains = csp?.connectDomains?.join(" ") ?? "";

// Base CSP directives
const directives = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(),
`style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(),
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
`connect-src 'self' ${connectDomains}`.trim(),
"frame-src 'none'",
"object-src 'none'",
"base-uri 'self'",
];

return `<meta http-equiv="Content-Security-Policy" content="${directives.join("; ")}">`;
}
//
// Security: CSP is enforced via HTTP headers on sandbox.html (set by serve.ts
// based on ?csp= query param). This is tamper-proof unlike meta tags.

window.addEventListener("message", async (event) => {
if (event.source === window.parent) {
Expand All @@ -98,29 +81,26 @@ window.addEventListener("message", async (event) => {
}

if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
const { html, sandbox, csp } = event.data.params;
const { html, sandbox } = event.data.params;
if (typeof sandbox === "string") {
inner.setAttribute("sandbox", sandbox);
}
if (typeof html === "string") {
// Inject CSP meta tag at the start of <head> if CSP is provided
console.log("[Sandbox] Received CSP:", csp);
let modifiedHtml = html;
if (csp) {
const cspMetaTag = buildCspMetaTag(csp);
console.log("[Sandbox] Injecting CSP meta tag:", cspMetaTag);
// Insert after <head> tag if present, otherwise prepend
if (modifiedHtml.includes("<head>")) {
modifiedHtml = modifiedHtml.replace("<head>", `<head>\n${cspMetaTag}`);
} else if (modifiedHtml.includes("<head ")) {
modifiedHtml = modifiedHtml.replace(/<head[^>]*>/, `$&\n${cspMetaTag}`);
} else {
modifiedHtml = cspMetaTag + modifiedHtml;
}
// Use document.write instead of srcdoc for WebGL compatibility.
// srcdoc creates an opaque origin which prevents WebGL canvas updates

Choose a reason for hiding this comment

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

I'm confused by this bit. srcdoc iframes are not all opaque-origined. For example, navigate to https://example.com and run this in DevTools:

const iframe = document.createElement('iframe');
iframe.srcdoc = `<p id=log></p><script>log.textContent = self.origin</script>`;
document.body.append(iframe);

By default, srcdoc iframes inherit their navigation initiator's origin (i.e., the origin of the document that set the srcdoc attribute), unless their sandbox attribute overrides this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops sorry good catch, will send update

Real reason: was to work around browser canvas tainting checks that seemed to treat about:srcdoc URLs as tainted, even when the iframe is same-origin with its parent. Will confirm and update the comment. in a followup

// from being displayed properly. document.write preserves the sandbox
// origin, allowing WebGL to work correctly.
// CSP is enforced via HTTP headers on this page (sandbox.html).
const doc = inner.contentDocument || inner.contentWindow?.document;
if (doc) {
doc.open();
doc.write(html);
doc.close();
} else {
console.log("[Sandbox] No CSP provided, using default");
// Fallback to srcdoc if document is not accessible
console.warn("[Sandbox] document.write not available, falling back to srcdoc");
inner.srcdoc = html;
}
inner.srcdoc = modifiedHtml;
}
} else {
if (inner && inner.contentWindow) {
Expand Down
Loading
Loading