Skip to content

Commit a3af1c7

Browse files
wip
1 parent ca1d298 commit a3af1c7

10 files changed

Lines changed: 752 additions & 218 deletions

File tree

docs/migrate_from_openai_apps.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,13 @@ The server-side changes involve updating metadata structure and using helper fun
5353

5454
### CSP Field Mapping
5555

56-
| OpenAI | MCP Apps | Notes |
57-
| ------------------ | ----------------- | ---------------------------------------------------------- |
58-
| `resource_domains` | `resourceDomains` | Origins for static assets (images, fonts, styles, scripts) |
59-
| `connect_domains` | `connectDomains` | Origins for fetch/XHR/WebSocket requests |
60-
| `frame_domains` | `frameDomains` | Origins for nested iframes |
61-
| `redirect_domains` | | OpenAI-only: origins for `openExternal` redirects |
62-
|| `baseUriDomains` | MCP-only: `base-uri` CSP directive |
56+
| OpenAI | MCP Apps | Notes |
57+
| ------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
58+
| `resource_domains` | `resourceDomains` | Origins for static assets (images, fonts, styles, scripts) |
59+
| `connect_domains` | `connectDomains` | Origins for fetch/XHR/WebSocket requests |
60+
| `frame_domains` | `frameDomains` | Origins for nested iframes |
61+
| `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. |
62+
|| `baseUriDomains` | MCP-only: `base-uri` CSP directive |
6363

6464
### Server-Side Migration Example
6565

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

256256
### External Links
257257

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

262263
### Display Mode
263264

examples/basic-host/src/implementation.ts

Lines changed: 154 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge";
1+
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";
22
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
44
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -72,6 +72,7 @@ interface UiResourceData {
7272
html: string;
7373
csp?: McpUiResourceCsp;
7474
permissions?: McpUiResourcePermissions;
75+
linkTrustedDomains?: string[];
7576
}
7677

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

155-
return { html, csp, permissions };
157+
return { html, csp, permissions, linkTrustedDomains };
156158
}
157159

158160

@@ -271,6 +273,135 @@ export interface AppBridgeCallbacks {
271273
export interface AppBridgeOptions {
272274
containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number };
273275
displayMode?: "inline" | "fullscreen";
276+
/**
277+
* Origins the resource declared as trusted for `ui/open-link`
278+
* (from `_meta.ui.linkTrustedDomains`). Links matching these skip the
279+
* confirmation prompt.
280+
*/
281+
linkTrustedDomains?: string[];
282+
}
283+
284+
/**
285+
* Show a simple themed confirmation modal asking the user to approve opening an
286+
* external link. Used for links that are NOT in the resource's
287+
* `linkTrustedDomains`. Resolves `true` if the user approves, `false` otherwise.
288+
*
289+
* This replaces the native `window.confirm()` so the prompt is styled with the
290+
* host theme and keeps working in environments where native dialogs are
291+
* suppressed.
292+
*/
293+
function confirmOpenLink(url: string): Promise<boolean> {
294+
return new Promise((resolve) => {
295+
const overlay = document.createElement("div");
296+
Object.assign(overlay.style, {
297+
position: "fixed",
298+
inset: "0",
299+
display: "flex",
300+
alignItems: "center",
301+
justifyContent: "center",
302+
background: "rgba(0, 0, 0, 0.5)",
303+
zIndex: "2000",
304+
});
305+
306+
const dialog = document.createElement("div");
307+
dialog.setAttribute("role", "dialog");
308+
dialog.setAttribute("aria-modal", "true");
309+
dialog.setAttribute("aria-labelledby", "open-link-title");
310+
Object.assign(dialog.style, {
311+
maxWidth: "420px",
312+
width: "calc(100% - 2rem)",
313+
padding: "1.25rem",
314+
border: "1px solid var(--color-border)",
315+
borderRadius: "8px",
316+
background: "var(--color-bg-secondary)",
317+
color: "var(--color-text)",
318+
boxShadow: "0 10px 40px rgba(0, 0, 0, 0.35)",
319+
});
320+
321+
const title = document.createElement("h2");
322+
title.id = "open-link-title";
323+
title.textContent = "Open external link?";
324+
Object.assign(title.style, { margin: "0 0 0.5rem", fontSize: "1.1rem" });
325+
326+
const message = document.createElement("p");
327+
message.textContent = "This app wants to open:";
328+
Object.assign(message.style, {
329+
margin: "0 0 0.5rem",
330+
color: "var(--color-text-secondary)",
331+
});
332+
333+
const urlEl = document.createElement("code");
334+
urlEl.textContent = url;
335+
Object.assign(urlEl.style, {
336+
display: "block",
337+
margin: "0 0 1.25rem",
338+
padding: "0.5rem",
339+
borderRadius: "4px",
340+
background: "var(--color-bg)",
341+
fontFamily: "monospace",
342+
fontSize: "0.85rem",
343+
wordBreak: "break-all",
344+
});
345+
346+
const actions = document.createElement("div");
347+
Object.assign(actions.style, {
348+
display: "flex",
349+
justifyContent: "flex-end",
350+
gap: "0.5rem",
351+
});
352+
353+
const cancelBtn = document.createElement("button");
354+
cancelBtn.textContent = "Cancel";
355+
Object.assign(cancelBtn.style, {
356+
padding: "0.5rem 1rem",
357+
border: "1px solid var(--color-border)",
358+
borderRadius: "4px",
359+
background: "var(--color-bg)",
360+
color: "var(--color-text)",
361+
font: "inherit",
362+
cursor: "pointer",
363+
});
364+
365+
const openBtn = document.createElement("button");
366+
openBtn.textContent = "Open";
367+
Object.assign(openBtn.style, {
368+
padding: "0.5rem 1rem",
369+
border: "none",
370+
borderRadius: "4px",
371+
background: "var(--color-primary)",
372+
color: "white",
373+
font: "inherit",
374+
fontWeight: "600",
375+
cursor: "pointer",
376+
});
377+
378+
let settled = false;
379+
const close = (result: boolean) => {
380+
if (settled) return;
381+
settled = true;
382+
document.removeEventListener("keydown", onKeyDown);
383+
overlay.remove();
384+
resolve(result);
385+
};
386+
387+
const onKeyDown = (event: KeyboardEvent) => {
388+
if (event.key === "Escape") close(false);
389+
};
390+
391+
cancelBtn.addEventListener("click", () => close(false));
392+
openBtn.addEventListener("click", () => close(true));
393+
overlay.addEventListener("click", (event) => {
394+
// Dismiss when clicking the backdrop (outside the dialog).
395+
if (event.target === overlay) close(false);
396+
});
397+
document.addEventListener("keydown", onKeyDown);
398+
399+
actions.append(cancelBtn, openBtn);
400+
dialog.append(title, message, urlEl, actions);
401+
overlay.append(dialog);
402+
document.body.append(overlay);
403+
openBtn.focus();
404+
});
274405
}
275406

276407
export function newAppBridge(
@@ -340,8 +471,27 @@ export function newAppBridge(
340471

341472
appBridge.onopenlink = async (params, _extra) => {
342473
log.info("Open link request:", params);
343-
window.open(params.url, "_blank", "noopener,noreferrer");
344-
return {};
474+
475+
// Links to origins the server declared as trusted (via
476+
// `_meta.ui.linkTrustedDomains`) skip the confirmation prompt. This is a UX
477+
// hint only — a real host MUST still apply its own allowlist/blocklist
478+
// before honoring it.
479+
const trusted = matchesLinkTrustedDomains(params.url, options?.linkTrustedDomains);
480+
481+
if (trusted) {
482+
log.info("Opening trusted link (no prompt):", params.url);
483+
window.open(params.url, "_blank", "noopener,noreferrer");
484+
return {};
485+
}
486+
487+
// Untrusted links require explicit user approval via a confirmation modal.
488+
if (await confirmOpenLink(params.url)) {
489+
window.open(params.url, "_blank", "noopener,noreferrer");
490+
return {};
491+
}
492+
493+
log.info("User declined to open link:", params.url);
494+
return { isError: true };
345495
};
346496

347497
appBridge.onloggingmessage = (params) => {

examples/basic-host/src/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,7 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
434434

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

0 commit comments

Comments
 (0)