diff --git a/docs/migrate_from_openai_apps.md b/docs/migrate_from_openai_apps.md index d91281b41..3b92765d2 100644 --- a/docs/migrate_from_openai_apps.md +++ b/docs/migrate_from_openai_apps.md @@ -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 @@ -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 diff --git a/examples/basic-host/package.json b/examples/basic-host/package.json index 1b45dcf18..483a6b719 100644 --- a/examples/basic-host/package.json +++ b/examples/basic-host/package.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@types/express": "^5.0.0", - "@types/node": "22.10.0", + "@types/node": "24.13.2", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^4.3.4", diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index 31e36983e..4bd796bd4 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -72,6 +72,7 @@ interface UiResourceData { html: string; csp?: McpUiResourceCsp; permissions?: McpUiResourcePermissions; + linkTrustedDomains?: string[]; } export interface ToolCallInfo { @@ -151,8 +152,9 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise { log.info("Open link request:", params); - window.open(params.url, "_blank", "noopener,noreferrer"); - return {}; + + const HOST_OPENLINK_DENYLIST = [new URL("https://malicious.com")]; + if (HOST_OPENLINK_DENYLIST.some(({ origin }) => origin === new URL(params.url).origin)) { + log.info("Blocked link by host denylist:", params.url); + return { isError: true }; + } + + const isTrustedByApp = options?.linkTrustedDomains?.some((trustedDomain) => + new URLPattern(trustedDomain).test(params.url) + ); + const shouldOpen = isTrustedByApp || window.confirm(`Open external link?\n${params.url}`); + if (shouldOpen) { + window.open(params.url, "_blank", "noopener,noreferrer"); + return {}; + } + + log.info("User declined to open link:", params.url); + return { isError: true }; }; appBridge.onloggingmessage = (params) => { diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index 3d488a792..1bf17565a 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -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). @@ -449,6 +449,7 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI // Provide container dimensions - maxHeight for flexible sizing containerDimensions: { maxHeight: 6000 }, displayMode: "inline", + linkTrustedDomains, }); appBridgeRef.current = appBridge; initializeApp(iframe, appBridge, toolCallInfo); diff --git a/package-lock.json b/package-lock.json index 29e905875..96fe9e1ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,7 +76,7 @@ }, "devDependencies": { "@types/express": "^5.0.0", - "@types/node": "22.10.0", + "@types/node": "24.13.2", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^4.3.4", @@ -90,19 +90,19 @@ } }, "examples/basic-host/node_modules/@types/node": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", - "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~7.18.0" } }, "examples/basic-host/node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 8c6ead57b..200f32787 100644 --- a/package.json +++ b/package.json @@ -124,8 +124,7 @@ "overrides": { "seroval": "1.4.1", "seroval-plugins": "1.4.2", - "solid-js": "1.9.10", - "@types/node": "20.19.27" + "solid-js": "1.9.10" }, "dependencies": { "@standard-schema/spec": "^1.1.0" diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index 1a14d3f00..5d4a3ff5f 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -225,6 +225,26 @@ interface UIResourceMeta { * - omitted: host decides border */ prefersBorder?: boolean, + /** + * Origins the view is expecting to open via `ui/open-link` + * + * Servers declare external destinations the view legitimately links to (for + * example its own marketing site). Hosts MAY use this list to skip the link + * confirmation prompt for matching destinations. + * + * - Each entry is an origin (scheme + host[:port]); a leading `*.` is a + * subdomain wildcard, matching the rules used for `csp` domain fields. + * - Empty or omitted = every `ui/open-link` is subject to the host's + * default policy (typically a confirmation prompt). + * + * This is a UX hint, NOT an authorization mechanism. Hosts retain full + * authority, MUST still apply their own allowlist/blocklist, and SHOULD NOT + * treat a declared origin as proof that a destination is safe. + * + * @example + * ["https://example.com", "https://*.example.com"] + */ + linkTrustedDomains?: string[], } ``` @@ -254,6 +274,7 @@ The resource content is returned via `resources/read`: }; domain?: string; prefersBorder?: boolean; + linkTrustedDomains?: string[]; // Origins ui/open-link may skip confirmation for. }; }; }]; @@ -262,7 +283,7 @@ The resource content is returned via `resources/read`: #### Metadata Location -`UIResourceMeta` (CSP, permissions, domain, prefersBorder) may be provided on either or both: +`UIResourceMeta` (CSP, permissions, domain, prefersBorder, linkTrustedDomains) may be provided on either or both: - **`resources/list`:** On the resource entry's `_meta.ui` field. Useful as a static default that hosts can review at connection time. - **`resources/read`:** On each content item's `_meta.ui` field. Useful for per-response overrides or dynamic metadata that is only known at read time. @@ -1039,6 +1060,23 @@ MCP Apps introduces additional JSON-RPC methods for UI-specific functionality: Host SHOULD open the URL in the user's default browser or a new tab. +By default, hosts SHOULD guard `ui/open-link` against unexpected navigation — +for example by showing a confirmation prompt — since the URL originates from +sandboxed UI content. + +**Trusted destinations (`linkTrustedDomains`).** A server MAY declare origins it +legitimately links to via the resource's `_meta.ui.linkTrustedDomains` (see +[UI Resource Format](#ui-resource-format)). For a `ui/open-link` whose URL +matches one of those origins, the host MAY **skip the confirmation prompt** and +open the link directly. + +Matching uses the same origin rules as `csp` domain fields: an entry is an +origin (scheme + host[:port]) and a leading `*.` is a subdomain wildcard. + +> **Security:** `linkTrustedDomains` is a UX hint, not an authorization +> mechanism. Because the value comes from the (untrusted) server, hosts MUST +> still enforce their own allowlist/blocklist and MAY confirm regardless. + `ui/download-file` - Request host to download a file ```typescript diff --git a/src/app-bridge.examples.ts b/src/app-bridge.examples.ts index d5dc5bc74..a46d71eec 100644 --- a/src/app-bridge.examples.ts +++ b/src/app-bridge.examples.ts @@ -159,6 +159,12 @@ declare const chatManager: { // Stub for example code - represents a hypothetical URL validator declare function isAllowedDomain(url: string): boolean; +// Stub for example code - represents the URL Pattern tester +declare function matchesLinkTrustedDomains( + url: string, + linkTrustedDomains: string[] | undefined, +): boolean; + // Stub for example code - represents a hypothetical dialog API declare function showDialog(options: { message: string; @@ -173,14 +179,25 @@ declare const modelContextManager: { /** * Example: Handle external link requests from the View. */ -function AppBridge_onopenlink_handleRequest(bridge: AppBridge) { +function AppBridge_onopenlink_handleRequest( + bridge: AppBridge, + // Origins declared by the resource via `_meta.ui.linkTrustedDomains`. + linkTrustedDomains: string[] | undefined, +) { //#region AppBridge_onopenlink_handleRequest bridge.onopenlink = async ({ url }, extra) => { + // The host's own policy always wins, regardless of server-declared trust. if (!isAllowedDomain(url)) { console.warn("Blocked external link:", url); return { isError: true }; } + // Destinations the server declared as trusted skip the confirmation prompt. + if (matchesLinkTrustedDomains(url, linkTrustedDomains)) { + window.open(url, "_blank", "noopener,noreferrer"); + return {}; + } + const confirmed = await showDialog({ message: `Open external link?\n${url}`, buttons: ["Open", "Cancel"], diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 23383c40f..a032f81de 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -670,6 +670,7 @@ export class AppBridge extends ProtocolWithEvents< * * The host MAY: * - Show a confirmation dialog before opening + * - Consider skipping user confirmation * - Block URLs based on a security policy or allowlist * - Log the request for audit purposes * - Reject the request entirely @@ -682,11 +683,18 @@ export class AppBridge extends ProtocolWithEvents< * @example * ```ts source="./app-bridge.examples.ts#AppBridge_onopenlink_handleRequest" * bridge.onopenlink = async ({ url }, extra) => { + * // The host's own policy always wins, regardless of server-declared trust. * if (!isAllowedDomain(url)) { * console.warn("Blocked external link:", url); * return { isError: true }; * } * + * // Destinations the server declared as trusted skip the confirmation prompt. + * if (matchesLinkTrustedDomains(url, linkTrustedDomains)) { + * window.open(url, "_blank", "noopener,noreferrer"); + * return {}; + * } + * * const confirmed = await showDialog({ * message: `Open external link?\n${url}`, * buttons: ["Open", "Cancel"], diff --git a/src/generated/schema.json b/src/generated/schema.json index 80b4ac60d..ba7a73555 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -4235,6 +4235,12 @@ "prefersBorder": { "description": "Visual boundary preference - true if view prefers a visible border.\n\nBoolean requesting whether a visible border and background is provided by the host. Specifying an explicit value for this is recommended because hosts' defaults may vary.\n\n- `true`: request visible border + background\n- `false`: request no visible border + background\n- omitted: host decides border", "type": "boolean" + }, + "linkTrustedDomains": { + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 43687374e..b9de7f8d8 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -669,6 +669,35 @@ export const McpUiResourceMetaSchema = z.object({ .describe( "Visual boundary preference - true if view prefers a visible border.\n\nBoolean requesting whether a visible border and background is provided by the host. Specifying an explicit value for this is recommended because hosts' defaults may vary.\n\n- `true`: request visible border + background\n- `false`: request no visible border + background\n- omitted: host decides border", ), + /** + * @description Origins the view is expecting to open via `ui/open-link`. + * + * Servers declare external destinations the view legitimately links to (for + * example its own marketing site). Hosts MAY use this list to **skip the link + * confirmation prompt** for matching destinations, instead of confirming every + * `ui/open-link`. + * + * Matching follows the same origin rules as {@link McpUiResourceCsp} fields: + * an entry is an origin (scheme + host[:port]) and wildcard subdomains are + * supported (e.g. `https://*.example.com`). + * + * > [!IMPORTANT] + * > This is a **UX hint, not an authorization mechanism.** It only relaxes + * > confirmation for the declared origins; it does not grant the view any + * > capability. Hosts retain full authority and MUST still apply their own + * > global allowlist/blocklist and MAY confirm regardless. Because the value + * > comes from the server, hosts SHOULD NOT treat it as proof that a + * > destination is safe. + * + * - Empty or omitted → every `ui/open-link` is subject to the host's default + * policy (typically a confirmation prompt). + * + * @example + * ```ts + * ["https://example.com", "https://*.example.com"] + * ``` + */ + linkTrustedDomains: z.array(z.string()).optional(), }); /** diff --git a/src/spec.types.ts b/src/spec.types.ts index 7a8b33761..81daf83e4 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -728,6 +728,35 @@ export interface McpUiResourceMeta { * - omitted: host decides border */ prefersBorder?: boolean; + /** + * @description Origins the view is expecting to open via `ui/open-link`. + * + * Servers declare external destinations the view legitimately links to (for + * example its own marketing site). Hosts MAY use this list to **skip the link + * confirmation prompt** for matching destinations, instead of confirming every + * `ui/open-link`. + * + * Matching follows the same origin rules as {@link McpUiResourceCsp} fields: + * an entry is an origin (scheme + host[:port]) and wildcard subdomains are + * supported (e.g. `https://*.example.com`). + * + * > [!IMPORTANT] + * > This is a **UX hint, not an authorization mechanism.** It only relaxes + * > confirmation for the declared origins; it does not grant the view any + * > capability. Hosts retain full authority and MUST still apply their own + * > global allowlist/blocklist and MAY confirm regardless. Because the value + * > comes from the server, hosts SHOULD NOT treat it as proof that a + * > destination is safe. + * + * - Empty or omitted → every `ui/open-link` is subject to the host's default + * policy (typically a confirmation prompt). + * + * @example + * ```ts + * ["https://example.com", "https://*.example.com"] + * ``` + */ + linkTrustedDomains?: string[]; } /**