Skip to content
Draft
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
21 changes: 11 additions & 10 deletions docs/migrate_from_openai_apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,13 @@ The server-side changes involve updating metadata structure and using helper fun

### CSP Field Mapping

| OpenAI | MCP Apps | Notes |
| ------------------ | ----------------- | ---------------------------------------------------------- |
| `resource_domains` | `resourceDomains` | Origins for static assets (images, fonts, styles, scripts) |
| `connect_domains` | `connectDomains` | Origins for fetch/XHR/WebSocket requests |
| `frame_domains` | `frameDomains` | Origins for nested iframes |
| `redirect_domains` | — | OpenAI-only: origins for `openExternal` redirects |
| — | `baseUriDomains` | MCP-only: `base-uri` CSP directive |
| OpenAI | MCP Apps | Notes |
| ------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `resource_domains` | `resourceDomains` | Origins for static assets (images, fonts, styles, scripts) |
| `connect_domains` | `connectDomains` | Origins for fetch/XHR/WebSocket requests |
| `frame_domains` | `frameDomains` | Origins for nested iframes |
| `redirect_domains` | `_meta.ui.linkTrustedDomains` | Origins `ui/open-link` may skip confirmation for. Note: this lives on `_meta.ui`, a sibling of `_meta.ui.csp`, not inside the CSP object. |
| — | `baseUriDomains` | MCP-only: `base-uri` CSP directive |

### Server-Side Migration Example

Expand Down Expand Up @@ -255,9 +255,10 @@ Client-side migration involves replacing the implicit `window.openai` global wit

### External Links

| OpenAI | MCP Apps | Notes |
| -------------------------------------------- | ----------------------------------- | ------------------------------------ |
| `await window.openai.openExternal({ href })` | `await app.openLink({ url: href })` | Different param name: `href` → `url` |
| OpenAI | MCP Apps | Notes |
| -------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------- |
| `await window.openai.openExternal({ href })` | `await app.openLink({ url: href })` | Different param name: `href` → `url` |
| `_meta["openai/widgetCSP"].redirect_domains` | `_meta.ui.linkTrustedDomains` | Origins that skip the host's link confirmation. Declared on the UI resource. |

### Display Mode

Expand Down
158 changes: 154 additions & 4 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, buildAllowAttribute, matchesLinkTrustedDomains, 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 @@ -72,6 +72,7 @@ interface UiResourceData {
html: string;
csp?: McpUiResourceCsp;
permissions?: McpUiResourcePermissions;
linkTrustedDomains?: string[];
}

export interface ToolCallInfo {
Expand Down Expand Up @@ -151,8 +152,9 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiRes
const uiMeta = contentMeta?.ui ?? listingMeta?.ui;
const csp = uiMeta?.csp;
const permissions = uiMeta?.permissions;
const linkTrustedDomains = uiMeta?.linkTrustedDomains;

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


Expand Down Expand Up @@ -271,6 +273,135 @@ export interface AppBridgeCallbacks {
export interface AppBridgeOptions {
containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number };
displayMode?: "inline" | "fullscreen";
/**
* Origins the resource declared as trusted for `ui/open-link`
* (from `_meta.ui.linkTrustedDomains`). Links matching these skip the
* confirmation prompt.
*/
linkTrustedDomains?: string[];
}

/**
* Show a simple themed confirmation modal asking the user to approve opening an
* external link. Used for links that are NOT in the resource's
* `linkTrustedDomains`. Resolves `true` if the user approves, `false` otherwise.
*
* This replaces the native `window.confirm()` so the prompt is styled with the
* host theme and keeps working in environments where native dialogs are
* suppressed.
*/
function confirmOpenLink(url: string): Promise<boolean> {
return new Promise((resolve) => {
const overlay = document.createElement("div");
Object.assign(overlay.style, {
position: "fixed",
inset: "0",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0, 0, 0, 0.5)",
zIndex: "2000",
});

const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-labelledby", "open-link-title");
Object.assign(dialog.style, {
maxWidth: "420px",
width: "calc(100% - 2rem)",
padding: "1.25rem",
border: "1px solid var(--color-border)",
borderRadius: "8px",
background: "var(--color-bg-secondary)",
color: "var(--color-text)",
boxShadow: "0 10px 40px rgba(0, 0, 0, 0.35)",
});

const title = document.createElement("h2");
title.id = "open-link-title";
title.textContent = "Open external link?";
Object.assign(title.style, { margin: "0 0 0.5rem", fontSize: "1.1rem" });

const message = document.createElement("p");
message.textContent = "This app wants to open:";
Object.assign(message.style, {
margin: "0 0 0.5rem",
color: "var(--color-text-secondary)",
});

const urlEl = document.createElement("code");
urlEl.textContent = url;
Object.assign(urlEl.style, {
display: "block",
margin: "0 0 1.25rem",
padding: "0.5rem",
borderRadius: "4px",
background: "var(--color-bg)",
fontFamily: "monospace",
fontSize: "0.85rem",
wordBreak: "break-all",
});

const actions = document.createElement("div");
Object.assign(actions.style, {
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
});

const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
Object.assign(cancelBtn.style, {
padding: "0.5rem 1rem",
border: "1px solid var(--color-border)",
borderRadius: "4px",
background: "var(--color-bg)",
color: "var(--color-text)",
font: "inherit",
cursor: "pointer",
});

const openBtn = document.createElement("button");
openBtn.textContent = "Open";
Object.assign(openBtn.style, {
padding: "0.5rem 1rem",
border: "none",
borderRadius: "4px",
background: "var(--color-primary)",
color: "white",
font: "inherit",
fontWeight: "600",
cursor: "pointer",
});

let settled = false;
const close = (result: boolean) => {
if (settled) return;
settled = true;
document.removeEventListener("keydown", onKeyDown);
overlay.remove();
resolve(result);
};

const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close(false);
};

cancelBtn.addEventListener("click", () => close(false));
openBtn.addEventListener("click", () => close(true));
overlay.addEventListener("click", (event) => {
// Dismiss when clicking the backdrop (outside the dialog).
if (event.target === overlay) close(false);
});
document.addEventListener("keydown", onKeyDown);

actions.append(cancelBtn, openBtn);
dialog.append(title, message, urlEl, actions);
overlay.append(dialog);
document.body.append(overlay);
openBtn.focus();
});
}

export function newAppBridge(
Expand Down Expand Up @@ -340,8 +471,27 @@ export function newAppBridge(

appBridge.onopenlink = async (params, _extra) => {
log.info("Open link request:", params);
window.open(params.url, "_blank", "noopener,noreferrer");
return {};

// Links to origins the server declared as trusted (via
// `_meta.ui.linkTrustedDomains`) skip the confirmation prompt. This is a UX
// hint only — a real host MUST still apply its own allowlist/blocklist
// before honoring it.
const trusted = matchesLinkTrustedDomains(params.url, options?.linkTrustedDomains);

if (trusted) {
log.info("Opening trusted link (no prompt):", params.url);
window.open(params.url, "_blank", "noopener,noreferrer");
return {};
}

// Untrusted links require explicit user approval via a confirmation modal.
if (await confirmOpenLink(params.url)) {
window.open(params.url, "_blank", "noopener,noreferrer");
return {};
}

log.info("User declined to open link:", params.url);
return { isError: true };
};

appBridge.onloggingmessage = (params) => {
Expand Down
4 changes: 3 additions & 1 deletion examples/basic-host/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI

// First get CSP and permissions from resource, then load sandbox
// CSP is set via HTTP headers (tamper-proof), permissions via iframe allow attribute
toolCallInfo.appResourcePromise.then(({ csp, permissions }) => {
toolCallInfo.appResourcePromise.then(({ csp, permissions, linkTrustedDomains }) => {
loadSandboxProxy(iframe, csp, permissions).then((firstTime) => {
// The `firstTime` check guards against React Strict Mode's double
// invocation (mount → unmount → remount simulation in development).
Expand All @@ -449,6 +449,8 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
// Provide container dimensions - maxHeight for flexible sizing
containerDimensions: { maxHeight: 6000 },
displayMode: "inline",
// Honor server-declared trusted link origins for ui/open-link
linkTrustedDomains,
});
appBridgeRef.current = appBridge;
initializeApp(iframe, appBridge, toolCallInfo);
Expand Down
Loading
Loading