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
18 changes: 11 additions & 7 deletions examples/basic-host/src/implementation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge";
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, type McpUiResourceSandbox, buildAllowAttribute, buildSandboxAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
Expand Down Expand Up @@ -66,6 +66,7 @@ interface UiResourceData {
html: string;
csp?: McpUiResourceCsp;
permissions?: McpUiResourcePermissions;
sandbox?: McpUiResourceSandbox;
}

export interface ToolCallInfo {
Expand Down Expand Up @@ -136,20 +137,23 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiRes
const contentMeta = (content as any)._meta || (content as any).meta;
const csp = contentMeta?.ui?.csp;
const permissions = contentMeta?.ui?.permissions;
const sandbox = contentMeta?.ui?.sandbox;

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


export function loadSandboxProxy(
iframe: HTMLIFrameElement,
csp?: McpUiResourceCsp,
permissions?: McpUiResourcePermissions,
sandbox?: McpUiResourceSandbox,
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

The parameter type for sandbox should be McpUiResourceSandbox | string (union type) to match the specification and the type of McpUiSandboxResourceReadyNotification['params']['sandbox']. The spec allows both structured flags objects and raw string overrides. While buildSandboxAttribute() will still work correctly since it only accepts McpUiResourceSandbox | undefined, the function signature should align with the spec to properly accept string values that apps might provide.

Copilot uses AI. Check for mistakes.
): Promise<boolean> {
// Prevent reload
if (iframe.src) return Promise.resolve(false);

iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
// Set sandbox attribute on outer iframe (must match inner iframe capabilities)

Choose a reason for hiding this comment

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

Suggested change
// Set sandbox attribute on outer iframe (must match inner iframe capabilities)
// Set sandbox attribute on outer iframe. In practice, these will match the inner iframe sandbox capabilities, but at a minimum they must be a superset of the inner iframe's sandbox allowances.

Let me know if you think this is helpful documentation... I suggest it in case some host wants to reserve the ability to have conditionally more restrictive Views (inner iframes) depending on some factors, so this documentation might help them understand the platform requirements / limits here.

iframe.setAttribute("sandbox", buildSandboxAttribute(sandbox));

// Set Permission Policy allow attribute based on requested permissions
const allowAttribute = buildAllowAttribute(permissions);
Expand Down Expand Up @@ -199,10 +203,10 @@ export async function initializeApp(
new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!),
);

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

// Wait for inner iframe to be ready
log.info("Waiting for MCP App to initialize...");
Expand Down
6 changes: 3 additions & 3 deletions examples/basic-host/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -432,10 +432,10 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
useEffect(() => {
const iframe = iframeRef.current!;

// First get CSP and permissions from resource, then load sandbox
// First get CSP, permissions, and sandbox from resource, then load sandbox
// CSP is set via HTTP headers (tamper-proof), permissions via iframe allow attribute
toolCallInfo.appResourcePromise.then(({ csp, permissions }) => {
loadSandboxProxy(iframe, csp, permissions).then((firstTime) => {
toolCallInfo.appResourcePromise.then(({ csp, permissions, sandbox }) => {
loadSandboxProxy(iframe, csp, permissions, sandbox).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
Expand Down
13 changes: 8 additions & 5 deletions examples/basic-host/src/sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { McpUiSandboxProxyReadyNotification, McpUiSandboxResourceReadyNotification } from "../../../dist/src/types";
import { buildAllowAttribute } from "../../../dist/src/app-bridge";
import { buildAllowAttribute, buildSandboxAttribute } from "../../../dist/src/app-bridge";

const ALLOWED_REFERRER_PATTERN = /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/;

Expand Down Expand Up @@ -43,7 +43,7 @@ try {
// origins.
const inner = document.createElement("iframe");
inner.style = "width:100%; height:100%; border:none;";
inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
inner.setAttribute("sandbox", "allow-scripts allow-same-origin");
// Note: allow attribute is set later when receiving sandbox-resource-ready notification
// based on the permissions requested by the app
document.body.appendChild(inner);
Expand Down Expand Up @@ -85,9 +85,12 @@ window.addEventListener("message", async (event) => {

if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
const { html, sandbox, permissions } = event.data.params;
if (typeof sandbox === "string") {
inner.setAttribute("sandbox", sandbox);
}
// sandbox can be a string (raw override) or object (structured flags)
const sandboxAttr = typeof sandbox === "string"
? sandbox
: buildSandboxAttribute(sandbox);
console.log("[Sandbox] Setting sandbox attribute:", sandboxAttr);
inner.setAttribute("sandbox", sandboxAttr);

Choose a reason for hiding this comment

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

This concerns me because sandbox attribute mutations only take effect after iframe navigations, so I'm not sure this would have any effect, since the iframe navigates after insertion, but not after doc.open();write();close() below.

See #158 (comment).

// Set Permission Policy allow attribute if permissions are requested
const allowAttribute = buildAllowAttribute(permissions);
if (allowAttribute) {
Expand Down
50 changes: 49 additions & 1 deletion specification/draft/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,40 @@ interface UIResourceMeta {
*/
clipboardWrite?: {},
},
/**
* Sandbox flags requested by the UI
*
* Servers declare which sandbox capabilities their UI needs beyond baseline (allow-scripts, allow-same-origin).
* Hosts MAY honor these by adding flags to the iframe sandbox attribute.
* Apps SHOULD NOT assume flags are granted; use feature detection as fallback.
*
*/
sandbox?: {
/**
* Allow form submission
*
* Maps to sandbox `allow-forms` flag
*/
forms?: {},
/**
* Allow window.open popups
*
* Maps to sandbox `allow-popups` flag
*/
popups?: {},
/**
* Allow alert/confirm/prompt/print dialogs
*
* Maps to sandbox `allow-modals` flag
*/
modals?: {},
/**
* Allow file downloads
*
* Maps to sandbox `allow-downloads` flag
*/
downloads?: {},
},
/**
* Dedicated origin for view
*
Expand Down Expand Up @@ -480,6 +514,7 @@ If the Host is a web page, it MUST wrap the View and communicate with it through
- 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
- If `sandbox` is declared (as object or string), the Sandbox MAY set the inner iframe's `sandbox` attribute accordingly (baseline: `allow-scripts allow-same-origin`)
6. The Sandbox MUST forward messages sent by the Host to the View, 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 View. The Host MUST NOT send any request or notification to the View before it receives an `initialized` notification.
7. The Sandbox SHOULD NOT create/send any requests to the Host or to the View (this would require synthesizing new request ids).
8. The Host MAY forward any message from the View (coming via the Sandbox) to the MCP Apps server, for any method that doesn't start with `ui/`. While the Host SHOULD ensure the View's MCP connection is spec-compliant, it MAY decide to block some messages or subject them to further user approval.
Expand Down Expand Up @@ -660,6 +695,13 @@ interface HostCapabilities {
/** Approved base URIs for the document (base-uri directive). */
baseUriDomains?: string[];
};
/** Sandbox flags granted by the host. */
flags?: {
forms?: {};
popups?: {};
modals?: {};
downloads?: {};
};
};
}
```
Expand Down Expand Up @@ -1260,7 +1302,13 @@ These messages are reserved for web-based hosts that implement the recommended d
microphone?: {},
geolocation?: {},
clipboardWrite?: {},
}
},
sandbox?: { // Sandbox flags from resource metadata (or raw string override)
forms?: {}, // Allow form submission (allow-forms)
popups?: {}, // Allow window.open popups (allow-popups)
modals?: {}, // Allow alert/confirm/prompt/print (allow-modals)
downloads?: {}, // Allow file downloads (allow-downloads)
} | string // Raw sandbox attribute override
}
}
```
Expand Down
87 changes: 87 additions & 0 deletions src/app-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { App } from "./app";
import {
AppBridge,
getToolUiResourceUri,
buildSandboxAttribute,
type McpUiHostCapabilities,
} from "./app-bridge";

Expand Down Expand Up @@ -921,3 +922,89 @@ describe("getToolUiResourceUri", () => {
});
});
});

describe("buildSandboxAttribute", () => {
const BASELINE = "allow-scripts allow-same-origin";

describe("baseline handling", () => {
it("returns baseline for undefined", () => {
expect(buildSandboxAttribute(undefined)).toBe(BASELINE);
});

it("returns baseline for empty object", () => {
expect(buildSandboxAttribute({})).toBe(BASELINE);
});
});

describe("single flags", () => {
it("adds forms flag", () => {
expect(buildSandboxAttribute({ forms: {} })).toBe(
`${BASELINE} allow-forms`,
);
});

it("adds popups flag", () => {
expect(buildSandboxAttribute({ popups: {} })).toBe(
`${BASELINE} allow-popups`,
);
});

it("adds modals flag", () => {
expect(buildSandboxAttribute({ modals: {} })).toBe(
`${BASELINE} allow-modals`,
);
});

it("adds downloads flag", () => {
expect(buildSandboxAttribute({ downloads: {} })).toBe(
`${BASELINE} allow-downloads`,
);
});
});

describe("multiple flags", () => {
it("adds multiple flags", () => {
const result = buildSandboxAttribute({
forms: {},
popups: {},
modals: {},
downloads: {},
});
expect(result).toContain("allow-forms");
expect(result).toContain("allow-popups");
expect(result).toContain("allow-modals");
expect(result).toContain("allow-downloads");
expect(result.startsWith(BASELINE)).toBe(true);
});

it("adds forms and popups", () => {
const result = buildSandboxAttribute({ forms: {}, popups: {} });
expect(result).toContain("allow-forms");
expect(result).toContain("allow-popups");
expect(result).not.toContain("allow-modals");
expect(result).not.toContain("allow-downloads");
});
});

describe("undefined values in object", () => {
it("ignores undefined values", () => {
const result = buildSandboxAttribute({
forms: {},
popups: undefined,
modals: undefined,
downloads: undefined,
});
expect(result).toBe(`${BASELINE} allow-forms`);
});

it("returns baseline when all values are undefined", () => {
const result = buildSandboxAttribute({
forms: undefined,
popups: undefined,
modals: undefined,
downloads: undefined,
});
expect(result).toBe(BASELINE);
});
});
});
48 changes: 48 additions & 0 deletions src/app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
McpUiRequestDisplayModeRequestSchema,
McpUiRequestDisplayModeResult,
McpUiResourcePermissions,
McpUiResourceSandbox,
} from "./types";
export * from "./types";
export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./app";
Expand Down Expand Up @@ -156,6 +157,53 @@ export function buildAllowAttribute(
return allowList.join("; ");
}

/**
* Mapping of McpUiResourceSandbox keys to sandbox attribute values.
* @internal
*/
const SANDBOX_FLAG_MAP: Record<keyof McpUiResourceSandbox, string> = {
forms: "allow-forms",
popups: "allow-popups",
modals: "allow-modals",
downloads: "allow-downloads",
};

/**
* Baseline sandbox flags always included - required for SDK operation.
* @internal
*/
const BASELINE_SANDBOX = "allow-scripts allow-same-origin";

/**
* Build iframe `sandbox` attribute string from sandbox configuration.
*
* Maps McpUiResourceSandbox to sandbox attribute format, always including
* baseline flags (allow-scripts allow-same-origin).
*
* @param sandbox - Sandbox flags requested by the UI resource
* @returns Space-separated sandbox flags including baseline
*
* @example
* ```typescript
* const sandbox = buildSandboxAttribute({ forms: {}, popups: {} });
* // Returns: "allow-scripts allow-same-origin allow-forms allow-popups"
* iframe.setAttribute("sandbox", sandbox);
* ```
*/
export function buildSandboxAttribute(
sandbox: McpUiResourceSandbox | undefined,
): string {
if (!sandbox) return BASELINE_SANDBOX;

const additional = Object.entries(sandbox)
.filter(([_, v]) => v !== undefined)
.map(([k]) => SANDBOX_FLAG_MAP[k as keyof McpUiResourceSandbox])
.filter(Boolean);

if (additional.length === 0) return BASELINE_SANDBOX;
return [BASELINE_SANDBOX, ...additional].join(" ");
}

/**
* Options for configuring {@link AppBridge `AppBridge`} behavior.
*
Expand Down
Loading
Loading