diff --git a/docs/migrate_from_openai_apps.md b/docs/migrate_from_openai_apps.md index d91281b41..77e24f685 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 (and append `redirectUrl` to). 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 and receive a host-appended `redirectUrl`. Declared on the UI resource. | ### Display Mode diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index 31e36983e..df4633104 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -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, appendRedirectUrl, 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"; @@ -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 {}; + + // Links to origins the server declared as trusted (via + // `_meta.ui.linkTrustedDomains`) skip the confirmation prompt. For those + // we also append a `redirectUrl` pointing back to this host so the + // destination can route the user back at the end of a flow (e.g. checkout). + // 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) { + const target = appendRedirectUrl(params.url, window.location.href); + log.info("Opening trusted link (no prompt):", target); + window.open(target, "_blank", "noopener,noreferrer"); + return {}; + } + + if (window.confirm(`Open external link?\n${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) => { diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index 3d488a792..14a185137 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,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); diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index 1a14d3f00..f3614e3ed 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -225,6 +225,29 @@ interface UIResourceMeta { * - omitted: host decides border */ prefersBorder?: boolean, + /** + * Origins the view is expected to open via `ui/open-link` + * + * Servers declare external destinations the view legitimately links to (for + * example its own marketing site or a checkout flow it controls). Hosts MAY + * use this list to skip the link confirmation prompt for matching + * destinations, and to append a `redirectUrl` query parameter so the + * external site can route the user back into the conversation at the end of + * a flow (e.g. after checkout). + * + * - 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 +277,7 @@ The resource content is returned via `resources/read`: }; domain?: string; prefersBorder?: boolean; + linkTrustedDomains?: string[]; // Origins ui/open-link may skip confirmation for (and append redirectUrl to). }; }; }]; @@ -262,7 +286,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 +1063,33 @@ 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: + +1. **Skip the confirmation prompt**, opening the link directly. +2. **Append a `redirectUrl` query parameter** to the outgoing URL, set to a + host-controlled URL that returns the user to the conversation. This lets the + destination route the user back at the end of a flow (e.g. after checkout): + + ``` + https://shop.example.com/checkout?redirectUrl=https%3A%2F%2Fchat.host.com%2Fc%2Fabc123 + ``` + +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. Hosts +> MUST only append `redirectUrl` for destinations that matched, never for +> arbitrary links, to avoid leaking the return URL to unvetted origins. + `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..359b0f81d 100644 --- a/src/app-bridge.examples.ts +++ b/src/app-bridge.examples.ts @@ -18,7 +18,12 @@ import { ReadResourceResultSchema, ListPromptsResultSchema, } from "@modelcontextprotocol/sdk/types.js"; -import { AppBridge, PostMessageTransport } from "./app-bridge.js"; +import { + AppBridge, + PostMessageTransport, + matchesLinkTrustedDomains, + appendRedirectUrl, +} from "./app-bridge.js"; import type { McpUiDisplayMode } from "./types.js"; /** @@ -173,14 +178,29 @@ 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, + // This host's own "return to conversation" URL. + hostReturnUrl: string, +) { //#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 + // and get a `redirectUrl` so they can route the user back afterwards. + if (matchesLinkTrustedDomains(url, linkTrustedDomains)) { + const target = appendRedirectUrl(url, hostReturnUrl); + window.open(target, "_blank", "noopener,noreferrer"); + return {}; + } + const confirmed = await showDialog({ message: `Open external link?\n${url}`, buttons: ["Open", "Cancel"], diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index a4fe1de1e..c02fcae80 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -19,6 +19,8 @@ import { LATEST_PROTOCOL_VERSION } from "./types"; import { AppBridge, buildAllowAttribute, + matchesLinkTrustedDomains, + appendRedirectUrl, getToolUiResourceUri, isToolVisibilityModelOnly, isToolVisibilityAppOnly, @@ -2838,3 +2840,174 @@ describe("buildAllowAttribute", () => { }); }); }); + +describe("matchesLinkTrustedDomains", () => { + describe("returns false", () => { + it("when the trusted list is undefined", () => { + expect(matchesLinkTrustedDomains("https://example.com", undefined)).toBe( + false, + ); + }); + + it("when the trusted list is empty", () => { + expect(matchesLinkTrustedDomains("https://example.com", [])).toBe(false); + }); + + it("when the url is not a valid absolute URL", () => { + expect( + matchesLinkTrustedDomains("not a url", ["https://example.com"]), + ).toBe(false); + }); + + it("for non-http schemes like mailto:", () => { + expect( + matchesLinkTrustedDomains("mailto:hi@example.com", [ + "https://example.com", + ]), + ).toBe(false); + }); + + it("when the host does not match", () => { + expect( + matchesLinkTrustedDomains("https://evil.com", ["https://example.com"]), + ).toBe(false); + }); + + it("when the scheme does not match", () => { + expect( + matchesLinkTrustedDomains("http://example.com", [ + "https://example.com", + ]), + ).toBe(false); + }); + }); + + describe("exact origin matching", () => { + it("matches an exact origin", () => { + expect( + matchesLinkTrustedDomains("https://example.com/path?q=1", [ + "https://example.com", + ]), + ).toBe(true); + }); + + it("does not match a subdomain without a wildcard", () => { + expect( + matchesLinkTrustedDomains("https://shop.example.com", [ + "https://example.com", + ]), + ).toBe(false); + }); + + it("matches when at least one entry matches", () => { + expect( + matchesLinkTrustedDomains("https://docs.example.com", [ + "https://example.com", + "https://docs.example.com", + ]), + ).toBe(true); + }); + }); + + describe("wildcard subdomain matching", () => { + it("matches a single-level subdomain", () => { + expect( + matchesLinkTrustedDomains("https://shop.example.com/checkout", [ + "https://*.example.com", + ]), + ).toBe(true); + }); + + it("matches a multi-level subdomain", () => { + expect( + matchesLinkTrustedDomains("https://a.b.example.com", [ + "https://*.example.com", + ]), + ).toBe(true); + }); + + it("does not match the apex domain", () => { + expect( + matchesLinkTrustedDomains("https://example.com", [ + "https://*.example.com", + ]), + ).toBe(false); + }); + + it("does not match a different suffix", () => { + expect( + matchesLinkTrustedDomains("https://shop.evil.com", [ + "https://*.example.com", + ]), + ).toBe(false); + }); + }); + + describe("port matching", () => { + it("requires the port to match when pinned in the pattern", () => { + expect( + matchesLinkTrustedDomains("https://example.com:8443", [ + "https://example.com:8443", + ]), + ).toBe(true); + expect( + matchesLinkTrustedDomains("https://example.com:9000", [ + "https://example.com:8443", + ]), + ).toBe(false); + }); + + it("allows any port when the pattern omits one", () => { + expect( + matchesLinkTrustedDomains("https://example.com:8443", [ + "https://example.com", + ]), + ).toBe(true); + }); + }); +}); + +describe("appendRedirectUrl", () => { + it("appends a redirectUrl query param", () => { + const result = appendRedirectUrl( + "https://shop.example.com/checkout", + "https://chat.host.com/c/abc123", + ); + const parsed = new URL(result); + expect(parsed.searchParams.get("redirectUrl")).toBe( + "https://chat.host.com/c/abc123", + ); + expect(parsed.origin + parsed.pathname).toBe( + "https://shop.example.com/checkout", + ); + }); + + it("overwrites an existing redirectUrl param", () => { + const result = appendRedirectUrl( + "https://shop.example.com/checkout?redirectUrl=old", + "https://chat.host.com/return", + ); + const parsed = new URL(result); + expect(parsed.searchParams.getAll("redirectUrl")).toEqual([ + "https://chat.host.com/return", + ]); + }); + + it("preserves other query params", () => { + const result = appendRedirectUrl( + "https://shop.example.com/checkout?item=42", + "https://chat.host.com/return", + ); + const parsed = new URL(result); + expect(parsed.searchParams.get("item")).toBe("42"); + expect(parsed.searchParams.get("redirectUrl")).toBe( + "https://chat.host.com/return", + ); + }); + + it("returns the original string for non-absolute URLs", () => { + expect(appendRedirectUrl("/relative/path", "https://host.com")).toBe( + "/relative/path", + ); + }); +}); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 23383c40f..f34664272 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -198,6 +198,124 @@ export function buildAllowAttribute( return allowList.join("; "); } +/** + * Check whether a single origin pattern matches a parsed URL. + * + * The pattern is an origin (scheme + host[:port]). A leading `*.` on the host + * acts as a subdomain wildcard, mirroring the matching rules used for + * {@link McpUiResourceCsp `McpUiResourceCsp`} `resourceDomains`. Following CSP + * semantics, `https://*.example.com` matches `https://a.example.com` and + * `https://a.b.example.com` but **not** the apex `https://example.com`. + * + * @internal + */ +function matchesOriginPattern(target: URL, pattern: string): boolean { + const isWildcard = pattern.includes("://*."); + let patternUrl: URL; + try { + // `*` is not a valid host character for the URL parser, so swap in a + // placeholder label we can strip back off after parsing. + patternUrl = new URL( + isWildcard ? pattern.replace("://*.", "://wildcard.") : pattern, + ); + } catch { + return false; + } + + if (patternUrl.protocol !== target.protocol) return false; + // If the pattern pins a port, it must match; otherwise any port is allowed. + if (patternUrl.port && patternUrl.port !== target.port) return false; + + if (isWildcard) { + const suffix = patternUrl.hostname.replace(/^wildcard\./, ""); + return target.hostname.endsWith(`.${suffix}`); + } + return target.hostname === patternUrl.hostname; +} + +/** + * Check whether a URL matches a resource's declared `linkTrustedDomains`. + * + * Hosts use this when handling `ui/open-link` requests to decide whether a + * destination was declared as trusted by the server (see + * {@link McpUiResourceMeta.linkTrustedDomains `McpUiResourceMeta.linkTrustedDomains`}). + * A match means the host MAY skip its confirmation prompt and/or append a + * `redirectUrl` (see {@link appendRedirectUrl `appendRedirectUrl`}). + * + * Each entry is an origin (scheme + host[:port]); a leading `*.` is a subdomain + * wildcard. Matching is purely syntactic — it is **not** an authorization check. + * Hosts MUST still enforce their own global allowlist/blocklist regardless of + * the result. + * + * @param url - The URL the view asked to open + * @param linkTrustedDomains - Origins declared on the UI resource metadata + * @returns `true` if `url` matches at least one declared origin + * + * @example + * ```typescript + * matchesLinkTrustedDomains("https://shop.example.com/checkout", [ + * "https://*.example.com", + * ]); + * // Returns: true + * ``` + */ +export function matchesLinkTrustedDomains( + url: string, + linkTrustedDomains: string[] | undefined, +): boolean { + if (!linkTrustedDomains || linkTrustedDomains.length === 0) return false; + + let target: URL; + try { + target = new URL(url); + } catch { + // Non-absolute or non-URL targets (e.g. `mailto:`, relative paths) are + // never considered trusted. + return false; + } + + return linkTrustedDomains.some((pattern) => + matchesOriginPattern(target, pattern), + ); +} + +/** + * Append a host-supplied `redirectUrl` query parameter to a link. + * + * When a `ui/open-link` destination matches the resource's + * {@link McpUiResourceMeta.linkTrustedDomains `linkTrustedDomains`}, the host MAY + * append a `redirectUrl` so the external site can route the user back into the + * conversation at the end of a flow (e.g. after checkout). The value is the + * host's own "return to conversation" URL — it is supplied by the host, not the + * server. + * + * Only call this for trusted destinations: appending a return URL to an + * unvetted destination would leak it. Non-absolute / non-http URLs are returned + * unchanged. + * + * @param url - The (trusted) URL to open + * @param redirectUrl - The host's return URL to attach + * @returns The URL with `redirectUrl` set, or the original on parse failure + * + * @example + * ```typescript + * appendRedirectUrl( + * "https://shop.example.com/checkout", + * "https://chat.host.com/c/abc123", + * ); + * // Returns: "https://shop.example.com/checkout?redirectUrl=https%3A%2F%2Fchat.host.com%2Fc%2Fabc123" + * ``` + */ +export function appendRedirectUrl(url: string, redirectUrl: string): string { + try { + const u = new URL(url); + u.searchParams.set("redirectUrl", redirectUrl); + return u.toString(); + } catch { + return url; + } +} + /** * Options for configuring {@link AppBridge `AppBridge`} behavior. * @@ -673,6 +791,10 @@ export class AppBridge extends ProtocolWithEvents< * - Block URLs based on a security policy or allowlist * - Log the request for audit purposes * - Reject the request entirely + * - Skip confirmation for origins the resource declared as trusted via + * `_meta.ui.linkTrustedDomains` (see {@link matchesLinkTrustedDomains + * `matchesLinkTrustedDomains`}), and append a return URL for those + * destinations (see {@link appendRedirectUrl `appendRedirectUrl`}). * * @param callback - Handler that receives URL params and returns a result * - `params.url` - URL to open in the host's browser @@ -682,11 +804,20 @@ 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 + * // and get a `redirectUrl` so they can route the user back afterwards. + * if (matchesLinkTrustedDomains(url, linkTrustedDomains)) { + * const target = appendRedirectUrl(url, hostReturnUrl); + * window.open(target, "_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..1aab97ccc 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -669,6 +669,40 @@ 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 expected to open via `ui/open-link`. + * + * Servers declare external destinations the view legitimately links to (for + * example its own marketing site or a checkout flow it controls). Hosts MAY + * use this list to: + * + * 1. **Skip the link confirmation prompt** for matching destinations, instead + * of confirming every `ui/open-link`. + * 2. **Append a `redirectUrl` query parameter** to matching destinations so the + * external site can route the user back into the conversation at the end of + * a flow (e.g. after checkout). The value is supplied by the host. + * + * 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..98c2c12b9 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -728,6 +728,40 @@ export interface McpUiResourceMeta { * - omitted: host decides border */ prefersBorder?: boolean; + /** + * @description Origins the view is expected to open via `ui/open-link`. + * + * Servers declare external destinations the view legitimately links to (for + * example its own marketing site or a checkout flow it controls). Hosts MAY + * use this list to: + * + * 1. **Skip the link confirmation prompt** for matching destinations, instead + * of confirming every `ui/open-link`. + * 2. **Append a `redirectUrl` query parameter** to matching destinations so the + * external site can route the user back into the conversation at the end of + * a flow (e.g. after checkout). The value is supplied by the host. + * + * 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[]; } /**