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/src/implementation.ts b/examples/basic-host/src/implementation.ts index 31e36983e..1ba06cb28 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, 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 { + 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( @@ -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) => { 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..0078a2bef 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -106,51 +106,51 @@ interface UIResource { */ _meta?: { ui?: UIResourceMeta; - } + }; } interface McpUiResourceCsp { - /** - * Origins for network requests (fetch/XHR/WebSocket) - * - * - Empty or omitted = no external connections (secure default) - * - Maps to CSP `connect-src` directive - * - * @example - * ["https://api.weather.com", "wss://realtime.service.com"] - */ - connectDomains?: string[], - /** - * Origins for static resources (images, scripts, stylesheets, fonts, media) - * - * - Empty or omitted = no external resources (secure default) - * - Wildcard subdomains supported: `https://*.example.com` - * - Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives - * - * @example - * ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"] - */ - resourceDomains?: string[], - /** - * Origins for nested iframes - * - * - Empty or omitted = no nested iframes allowed (`frame-src 'none'`) - * - Maps to CSP `frame-src` directive - * - * @example - * ["https://www.youtube.com", "https://player.vimeo.com"] - */ - frameDomains?: string[], - /** - * Allowed base URIs for the document - * - * - Empty or omitted = only same origin allowed (`base-uri 'self'`) - * - Maps to CSP `base-uri` directive - * - * @example - * ["https://cdn.example.com"] - */ - baseUriDomains?: string[], + /** + * Origins for network requests (fetch/XHR/WebSocket) + * + * - Empty or omitted = no external connections (secure default) + * - Maps to CSP `connect-src` directive + * + * @example + * ["https://api.weather.com", "wss://realtime.service.com"] + */ + connectDomains?: string[]; + /** + * Origins for static resources (images, scripts, stylesheets, fonts, media) + * + * - Empty or omitted = no external resources (secure default) + * - Wildcard subdomains supported: `https://*.example.com` + * - Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives + * + * @example + * ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"] + */ + resourceDomains?: string[]; + /** + * Origins for nested iframes + * + * - Empty or omitted = no nested iframes allowed (`frame-src 'none'`) + * - Maps to CSP `frame-src` directive + * + * @example + * ["https://www.youtube.com", "https://player.vimeo.com"] + */ + frameDomains?: string[]; + /** + * Allowed base URIs for the document + * + * - Empty or omitted = only same origin allowed (`base-uri 'self'`) + * - Maps to CSP `base-uri` directive + * + * @example + * ["https://cdn.example.com"] + */ + baseUriDomains?: string[]; } interface UIResourceMeta { @@ -160,7 +160,7 @@ interface UIResourceMeta { * Servers declare which external origins their UI needs to access. * Hosts use this to enforce appropriate CSP headers. */ - csp?: McpUiResourceCsp, + csp?: McpUiResourceCsp; /** * Sandbox permissions requested by the UI * @@ -174,26 +174,26 @@ interface UIResourceMeta { * * Maps to Permission Policy `camera` feature */ - camera?: {}, + camera?: {}; /** * Request microphone access * * Maps to Permission Policy `microphone` feature */ - microphone?: {}, + microphone?: {}; /** * Request geolocation access * * Maps to Permission Policy `geolocation` feature */ - geolocation?: {}, + geolocation?: {}; /** * Request clipboard write access * * Maps to Permission Policy `clipboard-write` feature */ - clipboardWrite?: {}, - }, + clipboardWrite?: {}; + }; /** * Dedicated origin for view * @@ -213,7 +213,7 @@ interface UIResourceMeta { * @example * "www-example-com.oaiusercontent.com" */ - domain?: string, + domain?: string; /** * Visual boundary preference * @@ -224,7 +224,26 @@ interface UIResourceMeta { * - `false`: Request no visible border + background * - omitted: host decides border */ - prefersBorder?: boolean, + 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 site). Hosts MAY use this list to skip the link confirmation + * prompt for matching destinations. + * + * - Wildcard subdomains supported: `https://*.example.com` + * - 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://app.example.com", "https://*.example.com"] + */ + linkTrustedDomains?: string[]; } ``` @@ -254,6 +273,7 @@ The resource content is returned via `resources/read`: }; domain?: string; prefersBorder?: boolean; + linkTrustedDomains?: string[]; // Origins the view is expecting to ui/open-link for. }; }; }]; @@ -262,7 +282,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. @@ -447,12 +467,12 @@ Note that you don’t need an SDK to “talk MCP” with the host: let nextId = 1; function sendRequest(method: string, params: any) { const id = nextId++; - window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, '*'); + window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*"); return new Promise((resolve, reject) => { - window.addEventListener('message', function listener(event) { + window.addEventListener("message", function listener(event) { const data: JSONRPCMessage = event.data; if (event.data?.id === id) { - window.removeEventListener('message', listener); + window.removeEventListener("message", listener); if (event.data?.result) { resolve(event.data?.result); } else if (event.data?.error) { @@ -465,20 +485,19 @@ function sendRequest(method: string, params: any) { }); } function sendNotification(method: string, params: any) { - window.parent.postMessage({ jsonrpc: "2.0", method, params }, '*'); + window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*"); } function onNotification(method: string, handler: (params: any) => void) { - window.addEventListener('message', function listener(event) { + window.addEventListener("message", function listener(event) { if (event.data?.method === method) { handler(event.data.params); } }); } - const initializeResult = await sendRequest("initialize", { capabilities: {}, - clientInfo: {name: "My UI", version: "1.0.0"}, + clientInfo: { name: "My UI", version: "1.0.0" }, protocolVersion: "2025-06-18", }); ``` @@ -509,6 +528,7 @@ If the Host is a web page, it MUST wrap the View and communicate with it through UI iframes can use the following subset of standard MCP protocol messages. Note that `tools/call` and `tools/list` flow **bidirectionally**: + - **App → Host → Server**: Apps call server tools (requires host `serverTools` capability) - **Host → App**: Host calls app-registered tools (requires app `tools` capability) @@ -571,9 +591,9 @@ interface HostContext { /** Metadata of the tool call that instantiated the View */ toolInfo?: { /** JSON-RPC id of the tools/call request */ - id?: RequestId, + id?: RequestId; /** Contains name, inputSchema, etc… */ - tool: Tool, + tool: Tool; }; /** Current color theme preference */ theme?: "light" | "dark"; @@ -593,12 +613,13 @@ interface HostContext { availableDisplayModes?: string[]; /** Container dimensions for the iframe. Specify either width or maxWidth, and either height or maxHeight. */ containerDimensions?: ( - | { height: number } // If specified, container is fixed at this height - | { maxHeight?: number } // Otherwise, container height is determined by the View's height, up to this maximum height (if defined) - ) & ( - | { width: number } // If specified, container is fixed at this width - | { maxWidth?: number } // Otherwise, container width is determined by the View's width, up to this maximum width (if defined) - ); + | { height: number } // If specified, container is fixed at this height + | { maxHeight?: number } // Otherwise, container height is determined by the View's height, up to this maximum height (if defined) + ) & + ( + | { width: number } // If specified, container is fixed at this width + | { maxWidth?: number } // Otherwise, container width is determined by the View's width, up to this maximum width (if defined) + ); /** User's language/region preference (BCP 47, e.g., "en-US") */ locale?: string; /** User's timezone (IANA, e.g., "America/New_York") */ @@ -611,7 +632,7 @@ interface HostContext { deviceCapabilities?: { touch?: boolean; hover?: boolean; - } + }; /** Safe area boundaries in pixels */ safeAreaInsets?: { top: number; @@ -718,11 +739,11 @@ The `HostContext` provides sizing information via `containerDimensions`: #### Dimension Modes -| Mode | Dimensions Field | Meaning | -|------|-----------------|---------| -| Fixed | `height` or `width` | Host controls the size. View should fill the available space. | -| Flexible | `maxHeight` or `maxWidth` | View controls the size, up to the specified maximum. | -| Unbounded | Field omitted | View controls the size with no limit. | +| Mode | Dimensions Field | Meaning | +| --------- | ------------------------- | ------------------------------------------------------------- | +| Fixed | `height` or `width` | Host controls the size. View should fill the available space. | +| Flexible | `maxHeight` or `maxWidth` | View controls the size, up to the specified maximum. | +| Unbounded | Field omitted | View controls the size with no limit. | These modes can be combined independently. For example, a host might specify a fixed width but flexible height, allowing the View to grow vertically based on content. @@ -739,7 +760,10 @@ if (containerDimensions) { if ("height" in containerDimensions) { // Fixed height: fill the container document.documentElement.style.height = "100vh"; - } else if ("maxHeight" in containerDimensions && containerDimensions.maxHeight) { + } else if ( + "maxHeight" in containerDimensions && + containerDimensions.maxHeight + ) { // Flexible with max: let content determine size, up to max document.documentElement.style.maxHeight = `${containerDimensions.maxHeight}px`; } @@ -749,7 +773,10 @@ if (containerDimensions) { if ("width" in containerDimensions) { // Fixed width: fill the container document.documentElement.style.width = "100vw"; - } else if ("maxWidth" in containerDimensions && containerDimensions.maxWidth) { + } else if ( + "maxWidth" in containerDimensions && + containerDimensions.maxWidth + ) { // Flexible with max: let content determine size, up to max document.documentElement.style.maxWidth = `${containerDimensions.maxWidth}px`; } @@ -822,11 +849,13 @@ Hosts notify views of display mode changes via `ui/notifications/host-context-ch #### Requirements **View behavior:** + - View MUST declare all display modes it supports in `appCapabilities.availableDisplayModes` during initialization. - View MUST check if the requested mode is in `availableDisplayModes` from host context before requesting a mode change. - View MUST handle the response mode differing from the requested mode. **Host behavior:** + - Host MUST NOT switch the View to a display mode that does not appear in its `appCapabilities.availableDisplayModes`, if set. - Host MUST return the resulting mode (whether updated or not) in the response to `ui/request-display-mode`. - If the requested mode is not available, Host SHOULD return the current display mode in the response. @@ -941,6 +970,7 @@ type McpUiStyleVariableKey = #### View Behavior - Views should set default fallback values for the set of these variables that they use, to account for hosts who don't pass some or all style variables. This ensures graceful degradation when hosts omit `styles` or specific variables: + ``` :root { --color-text-primary: light-dark(#171717, #000000); @@ -948,8 +978,9 @@ type McpUiStyleVariableKey = ... } ``` + - Views can use the `applyHostStyleVariables` utility (or `useHostStyleVariables` if they prefer a React hook) to easily populate the host-provided CSS variables into their style sheet -- Views can use the `applyDocumentTheme` utility (or `useDocumentTheme` if they prefer a React hook) to easily respond to Host Context `theme` changes in a way that is compatible with the host's light/dark color variables +- Views can use the `applyDocumentTheme` utility (or `useDocumentTheme` if they prefer a React hook) to easily respond to Host Context `theme` changes in a way that is compatible with the host's light/dark color variables Example usage of standardized CSS variables: @@ -1039,6 +1070,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 @@ -1101,10 +1149,11 @@ MCP Apps run in sandboxed iframes where direct file downloads are blocked (`allo The `contents` array uses standard MCP resource types (`EmbeddedResource` and `ResourceLink`), avoiding custom content formats. For `EmbeddedResource`, content is inline via `text` (UTF-8) or `blob` (base64). For `ResourceLink`, the host can retrieve the content directly from the URI. Host behavior: -* Host SHOULD show a confirmation dialog before initiating the download. -* For `EmbeddedResource`, host SHOULD derive the filename from the last segment of `resource.uri`. -* Host MAY reject the download based on security policy, file size limits, or user preferences. -* Host SHOULD sanitize filenames to prevent path traversal. + +- Host SHOULD show a confirmation dialog before initiating the download. +- For `EmbeddedResource`, host SHOULD derive the filename from the last segment of `resource.uri`. +- Host MAY reject the download based on security policy, file size limits, or user preferences. +- Host SHOULD sanitize filenames to prevent path traversal. `ui/message` - Send message content to the host's chat interface @@ -1140,9 +1189,11 @@ Host behavior: } } ``` + Host behavior: -* Host SHOULD add the message to the conversation context, preserving the specified role. -* Host MAY request user consent. + +- Host SHOULD add the message to the conversation context, preserving the specified role. +- Host MAY request user consent. `ui/request-display-mode` - Request host to change display mode @@ -1205,6 +1256,7 @@ The View MAY send this request to update the Host's model context. This context This event serves a different use case from `notifications/message` (logging) and `ui/message` (which also trigger follow-ups). Host behavior: + - SHOULD provide the context to the model in future turns - MAY overwrite the previous model context with the new update - MAY defer sending the context to the model until the next user message (including `ui/message`) @@ -1254,6 +1306,7 @@ When Apps declare the `tools` capability, the Host can send standard MCP tool re ``` **App Behavior:** + - Apps MUST implement `oncalltool` handler if they declare `tools` capability - Apps SHOULD validate tool names and arguments - Apps MAY use `app.registerTool()` SDK helper for automatic validation @@ -1284,19 +1337,21 @@ When Apps declare the `tools` capability, the Host can send standard MCP tool re ``` **Tool Structure** (identical to [core MCP `Tool`](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool)): + ```typescript interface Tool { - name: string; // Unique tool identifier - title?: string; // Display name - description?: string; // Human-readable description - inputSchema: object; // JSON Schema for arguments - outputSchema?: object; // JSON Schema for structuredContent - annotations?: ToolAnnotations; // MCP tool annotations (e.g., readOnlyHint) + name: string; // Unique tool identifier + title?: string; // Display name + description?: string; // Human-readable description + inputSchema: object; // JSON Schema for arguments + outputSchema?: object; // JSON Schema for structuredContent + annotations?: ToolAnnotations; // MCP tool annotations (e.g., readOnlyHint) _meta?: object; } ``` **App Behavior:** + - Apps MUST implement `onlisttools` handler if they declare `tools` capability - Apps SHOULD return complete tool metadata including schemas - Apps MAY filter tools based on context or permissions @@ -1431,6 +1486,7 @@ The View SHOULD send this notification when rendered content body size changes ( The View MAY send this notification to request that the host tear it down. This enables View-initiated teardown flows (e.g., user clicks a "Done" button in the View). **Host behavior:** + - Host MAY defer or ignore the teardown request. - If the Host accepts the request, it MUST follow the graceful termination process by sending `ui/resource-teardown` to the View. The Host SHOULD wait for a response before tearing down the resource (to prevent data loss). @@ -1719,28 +1775,30 @@ Apps can register their own tools that hosts and agents can call, making apps ** #### Motivation: Semantic Introspection Without tool registration, apps are opaque to the model: + - The host renders the app in a sandboxed iframe and has no guaranteed access to its internals — apps may embed further cross-origin iframes (via `frameDomains`), and even where same-origin, DOM access is not part of the host↔app contract - The model therefore cannot query app state or discover what operations the app supports With tool registration, apps expose a semantic interface: + - The model discovers available operations via `tools/list` - The model queries app state via tools (e.g., `get_board_state`) - The model executes actions via tools (e.g., `make_move`) - Apps return structured data rather than relying on the host to interpret rendered output -This is *pull*-based and complements the *push*-based [`ui/update-model-context`](#uiupdate-model-context) request already defined in this spec (and analogous mechanisms elsewhere, e.g. OAI Apps SDK's [`setWidgetState()`](https://developers.openai.com/apps-sdk/reference/sdk#setwidgetstate)). Push lets an app proactively keep the model's context current; pull lets the agent query and command the app on demand. Apps may use either or both. +This is _pull_-based and complements the _push_-based [`ui/update-model-context`](#uiupdate-model-context) request already defined in this spec (and analogous mechanisms elsewhere, e.g. OAI Apps SDK's [`setWidgetState()`](https://developers.openai.com/apps-sdk/reference/sdk#setwidgetstate)). Push lets an app proactively keep the model's context current; pull lets the agent query and command the app on demand. Apps may use either or both. #### App Tool Registration Apps register tools using the SDK's `registerTool()` method: ```typescript -import { App } from '@modelcontextprotocol/ext-apps'; -import { z } from 'zod'; +import { App } from "@modelcontextprotocol/ext-apps"; +import { z } from "zod"; const app = new App( { name: "TicTacToe", version: "1.0.0" }, - { tools: { listChanged: true } } // Declare tool capability + { tools: { listChanged: true } }, // Declare tool capability ); // Register a tool with schema validation @@ -1750,15 +1808,15 @@ const moveTool = app.registerTool( description: "Make a move in the tic-tac-toe game", inputSchema: z.object({ position: z.number().int().min(0).max(8), - player: z.enum(['X', 'O']) + player: z.enum(["X", "O"]), }), outputSchema: z.object({ board: z.array(z.string()).length(9), - winner: z.enum(['X', 'O', 'draw', null]).nullable() + winner: z.enum(["X", "O", "draw", null]).nullable(), }), annotations: { - readOnlyHint: false // This tool has side effects - } + readOnlyHint: false, // This tool has side effects + }, }, async (params) => { // Validate and execute move @@ -1766,16 +1824,18 @@ const moveTool = app.registerTool( const winner = checkWinner(newBoard); return { - content: [{ - type: "text", - text: `Move made at position ${params.position}` - }], + content: [ + { + type: "text", + text: `Move made at position ${params.position}`, + }, + ], structuredContent: { board: newBoard, - winner - } + winner, + }, }; - } + }, ); await app.connect(new PostMessageTransport(window.parent)); @@ -1783,14 +1843,14 @@ await app.connect(new PostMessageTransport(window.parent)); **Registration Options:** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `name` | string | Yes | Unique tool identifier | -| `description` | string | No | Human-readable description for agent | -| `inputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates arguments; serialized to JSON Schema for `tools/list` | -| `outputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates `structuredContent` | -| `annotations` | ToolAnnotations | No | MCP tool hints (e.g., `readOnlyHint`) | -| `_meta` | object | No | Custom metadata | +| Field | Type | Required | Description | +| -------------- | ---------------------------------------------- | -------- | --------------------------------------------------------------- | +| `name` | string | Yes | Unique tool identifier | +| `description` | string | No | Human-readable description for agent | +| `inputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates arguments; serialized to JSON Schema for `tools/list` | +| `outputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates `structuredContent` | +| `annotations` | ToolAnnotations | No | MCP tool hints (e.g., `readOnlyHint`) | +| `_meta` | object | No | Custom metadata | Apps can also implement tool handling manually without the SDK: @@ -1798,16 +1858,19 @@ Apps can also implement tool handling manually without the SDK: app.oncalltool = async (params, extra) => { if (params.name === "tictactoe_move") { // Manual validation - if (typeof params.arguments?.position !== 'number') { + if (typeof params.arguments?.position !== "number") { throw new Error("Invalid position"); } // Execute tool - const newBoard = makeMove(params.arguments.position, params.arguments.player); + const newBoard = makeMove( + params.arguments.position, + params.arguments.player, + ); return { content: [{ type: "text", text: "Move made" }], - structuredContent: { board: newBoard } + structuredContent: { board: newBoard }, }; } @@ -1824,12 +1887,12 @@ app.onlisttools = async () => { type: "object", properties: { position: { type: "number", minimum: 0, maximum: 8 }, - player: { type: "string", enum: ["X", "O"] } + player: { type: "string", enum: ["X", "O"] }, }, - required: ["position", "player"] - } - } - ] + required: ["position", "player"], + }, + }, + ], }; }; ``` @@ -1858,7 +1921,7 @@ When a tool is disabled/enabled, the app automatically sends `notifications/tool // Update tool description or schema tool.update({ description: "New description", - inputSchema: newSchema + inputSchema: newSchema, }); ``` @@ -1887,14 +1950,14 @@ app.registerTool( { inputSchema: z.object({ query: z.string().min(1).max(100), - limit: z.number().int().positive().default(10) - }) + limit: z.number().int().positive().default(10), + }), }, async (params) => { // params.query is guaranteed to be a string (1-100 chars) // params.limit is guaranteed to be a positive integer (default 10) return performSearch(params.query, params.limit); - } + }, ); ``` @@ -1907,19 +1970,19 @@ app.registerTool( "get_status", { outputSchema: z.object({ - status: z.enum(['ready', 'busy', 'error']), - timestamp: z.string().datetime() - }) + status: z.enum(["ready", "busy", "error"]), + timestamp: z.string().datetime(), + }), }, async () => { return { content: [{ type: "text", text: "Status retrieved" }], structuredContent: { - status: 'ready', - timestamp: new Date().toISOString() - } + status: "ready", + timestamp: new Date().toISOString(), + }, }; - } + }, ); ``` @@ -1930,45 +1993,48 @@ If the callback returns data that doesn't match `outputSchema`, the tool returns This example demonstrates how apps expose semantic interfaces through tools: ```typescript -import { App } from '@modelcontextprotocol/ext-apps'; -import { z } from 'zod'; +import { App } from "@modelcontextprotocol/ext-apps"; +import { z } from "zod"; // Game state -let board: Array<'X' | 'O' | null> = Array(9).fill(null); -let currentPlayer: 'X' | 'O' = 'X'; +let board: Array<"X" | "O" | null> = Array(9).fill(null); +let currentPlayer: "X" | "O" = "X"; let moveHistory: number[] = []; const app = new App( { name: "TicTacToe", version: "1.0.0" }, - { tools: { listChanged: true } } + { tools: { listChanged: true } }, ); // Agent can query semantic state directly app.registerTool( "get_board_state", { - description: "Get current game state including board, current player, and winner", + description: + "Get current game state including board, current player, and winner", outputSchema: z.object({ - board: z.array(z.enum(['X', 'O', null])).length(9), - currentPlayer: z.enum(['X', 'O']), - winner: z.enum(['X', 'O', 'draw', null]).nullable(), - moveHistory: z.array(z.number()) - }) + board: z.array(z.enum(["X", "O", null])).length(9), + currentPlayer: z.enum(["X", "O"]), + winner: z.enum(["X", "O", "draw", null]).nullable(), + moveHistory: z.array(z.number()), + }), }, async () => { return { - content: [{ - type: "text", - text: `Board: ${board.map(c => c || '-').join('')}, Player: ${currentPlayer}` - }], + content: [ + { + type: "text", + text: `Board: ${board.map((c) => c || "-").join("")}, Player: ${currentPlayer}`, + }, + ], structuredContent: { board, currentPlayer, winner: checkWinner(board), - moveHistory - } + moveHistory, + }, }; - } + }, ); // Agent can execute moves @@ -1977,37 +2043,40 @@ app.registerTool( { description: "Place a piece at the specified position", inputSchema: z.object({ - position: z.number().int().min(0).max(8) + position: z.number().int().min(0).max(8), }), - annotations: { readOnlyHint: false } + annotations: { readOnlyHint: false }, }, async ({ position }) => { if (board[position] !== null) { return { content: [{ type: "text", text: "Position already taken" }], - isError: true + isError: true, }; } board[position] = currentPlayer; moveHistory.push(position); const winner = checkWinner(board); - currentPlayer = currentPlayer === 'X' ? 'O' : 'X'; + currentPlayer = currentPlayer === "X" ? "O" : "X"; return { - content: [{ - type: "text", - text: `Player ${board[position]} moved to position ${position}` + - (winner ? `. ${winner} wins!` : '') - }], + content: [ + { + type: "text", + text: + `Player ${board[position]} moved to position ${position}` + + (winner ? `. ${winner} wins!` : ""), + }, + ], structuredContent: { board, currentPlayer, winner, - moveHistory - } + moveHistory, + }, }; - } + }, ); // Agent can reset game @@ -2015,27 +2084,34 @@ app.registerTool( "reset_game", { description: "Reset the game board to initial state", - annotations: { readOnlyHint: false } + annotations: { readOnlyHint: false }, }, async () => { board = Array(9).fill(null); - currentPlayer = 'X'; + currentPlayer = "X"; moveHistory = []; return { content: [{ type: "text", text: "Game reset" }], - structuredContent: { board, currentPlayer, moveHistory } + structuredContent: { board, currentPlayer, moveHistory }, }; - } + }, ); await app.connect(new PostMessageTransport(window.parent)); -function checkWinner(board: Array<'X' | 'O' | null>): 'X' | 'O' | 'draw' | null { +function checkWinner( + board: Array<"X" | "O" | null>, +): "X" | "O" | "draw" | null { const lines = [ - [0, 1, 2], [3, 4, 5], [6, 7, 8], // rows - [0, 3, 6], [1, 4, 7], [2, 5, 8], // columns - [0, 4, 8], [2, 4, 6] // diagonals + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], // rows + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], // columns + [0, 4, 8], + [2, 4, 6], // diagonals ]; for (const [a, b, c] of lines) { @@ -2044,7 +2120,7 @@ function checkWinner(board: Array<'X' | 'O' | null>): 'X' | 'O' | 'draw' | null } } - return board.every(cell => cell !== null) ? 'draw' : null; + return board.every((cell) => cell !== null) ? "draw" : null; } ``` @@ -2058,7 +2134,7 @@ const { tools } = await bridge.sendListTools({}); // 2. Query semantic state (not visual/DOM) const state = await bridge.sendCallTool({ name: "get_board_state", - arguments: {} + arguments: {}, }); // → { board: [null, null, null, ...], currentPlayer: 'X', winner: null } @@ -2066,14 +2142,14 @@ const state = await bridge.sendCallTool({ if (state.structuredContent.board[4] === null) { await bridge.sendCallTool({ name: "make_move", - arguments: { position: 4 } + arguments: { position: 4 }, }); } // 4. Query updated state const newState = await bridge.sendCallTool({ name: "get_board_state", - arguments: {} + arguments: {}, }); // → { board: [null, null, null, null, 'X', null, ...], currentPlayer: 'O', ... } ``` @@ -2101,7 +2177,7 @@ Host/Agent calls app-registered tools: // Host calls app tool const result = await bridge.sendCallTool({ name: "tictactoe_move", - arguments: { position: 4 } + arguments: { position: 4 }, }); ``` @@ -2109,13 +2185,13 @@ Requires app `tools` capability. **Key Distinction:** -| Aspect | Server Tools | App Tools | -|--------|-------------|-----------| -| **Lifetime** | Persistent (server process) | Ephemeral (while app loaded) | -| **Source** | MCP Server | App JavaScript | -| **Trust** | Trusted | Sandboxed (untrusted) | -| **Discovery** | Server `tools/list` | App `tools/list` (when app declares capability) | -| **When Available** | Always | Only while app is loaded | +| Aspect | Server Tools | App Tools | +| ------------------ | --------------------------- | ----------------------------------------------- | +| **Lifetime** | Persistent (server process) | Ephemeral (while app loaded) | +| **Source** | MCP Server | App JavaScript | +| **Trust** | Trusted | Sandboxed (untrusted) | +| **Discovery** | Server `tools/list` | App `tools/list` (when app declares capability) | +| **When Available** | Always | Only while app is loaded | #### Use Cases @@ -2134,6 +2210,7 @@ Requires app `tools` capability. App tools run in **sandboxed iframes** (untrusted). See Security Implications section for detailed mitigations. Key considerations: + - App tools could provide misleading descriptions - Tool namespacing needed to avoid conflicts with server tools - Resource limits (max tools, execution timeouts) @@ -2145,6 +2222,7 @@ Key considerations: This feature is inspired by [WebMCP](https://github.com/webmachinelearning/webmcp) (W3C incubation), which proposes allowing web pages to register JavaScript functions as tools via `navigator.modelContext.registerTool()`. Key differences: + - **WebMCP**: General web pages, browser API, manifest-based discovery - **This spec**: MCP Apps, standard MCP messages, capability-based negotiation @@ -2194,23 +2272,30 @@ Future versions may add additional settings: Servers SHOULD check client capabilities before registering UI-enabled tools. The SDK provides the `getUiCapability` helper for this: ```typescript -import { getUiCapability, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; +import { + getUiCapability, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; const uiCap = getUiCapability(clientCapabilities); if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { // Register tools with UI templates server.registerTool("get_weather", { description: "Get weather with interactive dashboard", - inputSchema: { /* ... */ }, + inputSchema: { + /* ... */ + }, _meta: { - ui: { resourceUri: "ui://weather-server/dashboard" } - } + ui: { resourceUri: "ui://weather-server/dashboard" }, + }, }); } else { // Register text-only version server.registerTool("get_weather", { description: "Get weather as text", - inputSchema: { /* ... */ } + inputSchema: { + /* ... */ + }, // No UI metadata }); } @@ -2517,23 +2602,23 @@ const permissions = uiMeta?.permissions; const cspValue = ` default-src 'none'; - script-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''}; - style-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''}; - connect-src 'self' ${csp?.connectDomains?.join(' ') || ''}; - img-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''}; - font-src 'self' ${csp?.resourceDomains?.join(' ') || ''}; - media-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''}; - frame-src ${csp?.frameDomains?.join(' ') || "'none'"}; + script-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(" ") || ""}; + style-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(" ") || ""}; + connect-src 'self' ${csp?.connectDomains?.join(" ") || ""}; + img-src 'self' data: ${csp?.resourceDomains?.join(" ") || ""}; + font-src 'self' ${csp?.resourceDomains?.join(" ") || ""}; + media-src 'self' data: ${csp?.resourceDomains?.join(" ") || ""}; + frame-src ${csp?.frameDomains?.join(" ") || "'none'"}; object-src 'none'; - base-uri ${csp?.baseUriDomains?.join(' ') || "'self'"}; + base-uri ${csp?.baseUriDomains?.join(" ") || "'self'"}; `; // Permission Policy for iframe allow attribute const allowList: string[] = []; -if (permissions?.camera) allowList.push('camera'); -if (permissions?.microphone) allowList.push('microphone'); -if (permissions?.geolocation) allowList.push('geolocation'); -const allowAttribute = allowList.join(' '); +if (permissions?.camera) allowList.push("camera"); +if (permissions?.microphone) allowList.push("microphone"); +if (permissions?.geolocation) allowList.push("geolocation"); +const allowAttribute = allowList.join(" "); ``` **Security Requirements:** @@ -2604,11 +2689,11 @@ Hosts SHOULD implement the following protections for app-provided tools: Hosts MAY implement different permission levels based on tool annotations: -| Annotation | Recommended Permission | Example | -|---------------------|------------------------|-------------------| -| `readOnlyHint: true`| Auto-approve (with caution) | `get_board_state()` | -| `readOnlyHint: false` | User confirmation required | `make_move()` | -| No annotation | User confirmation required (safe default) | Any tool | +| Annotation | Recommended Permission | Example | +| --------------------- | ----------------------------------------- | ------------------- | +| `readOnlyHint: true` | Auto-approve (with caution) | `get_board_state()` | +| `readOnlyHint: false` | User confirmation required | `make_move()` | +| No annotation | User confirmation required (safe default) | Any tool | **App Tool Lifecycle:** diff --git a/src/app-bridge.examples.ts b/src/app-bridge.examples.ts index d5dc5bc74..20e26efb0 100644 --- a/src/app-bridge.examples.ts +++ b/src/app-bridge.examples.ts @@ -18,7 +18,11 @@ import { ReadResourceResultSchema, ListPromptsResultSchema, } from "@modelcontextprotocol/sdk/types.js"; -import { AppBridge, PostMessageTransport } from "./app-bridge.js"; +import { + AppBridge, + PostMessageTransport, + matchesLinkTrustedDomains, +} from "./app-bridge.js"; import type { McpUiDisplayMode } from "./types.js"; /** @@ -173,14 +177,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.test.ts b/src/app-bridge.test.ts index a4fe1de1e..9a3568d51 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -19,6 +19,7 @@ import { LATEST_PROTOCOL_VERSION } from "./types"; import { AppBridge, buildAllowAttribute, + matchesLinkTrustedDomains, getToolUiResourceUri, isToolVisibilityModelOnly, isToolVisibilityAppOnly, @@ -2838,3 +2839,129 @@ 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); + }); + }); +}); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 23383c40f..9410429fc 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -198,6 +198,86 @@ 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. + * + * 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), + ); +} + /** * Options for configuring {@link AppBridge `AppBridge`} behavior. * @@ -673,6 +753,9 @@ 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`}). * * @param callback - Handler that receives URL params and returns a result * - `params.url` - URL to open in the host's browser @@ -682,11 +765,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..d664c698d 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 expected 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..c89b8a9b4 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 expected 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[]; } /**