|
1 | | -import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge"; |
| 1 | +import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, matchesLinkTrustedDomains, appendRedirectUrl, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge"; |
2 | 2 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
3 | 3 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; |
4 | 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
@@ -72,6 +72,7 @@ interface UiResourceData { |
72 | 72 | html: string; |
73 | 73 | csp?: McpUiResourceCsp; |
74 | 74 | permissions?: McpUiResourcePermissions; |
| 75 | + linkTrustedDomains?: string[]; |
75 | 76 | } |
76 | 77 |
|
77 | 78 | export interface ToolCallInfo { |
@@ -151,8 +152,9 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiRes |
151 | 152 | const uiMeta = contentMeta?.ui ?? listingMeta?.ui; |
152 | 153 | const csp = uiMeta?.csp; |
153 | 154 | const permissions = uiMeta?.permissions; |
| 155 | + const linkTrustedDomains = uiMeta?.linkTrustedDomains; |
154 | 156 |
|
155 | | - return { html, csp, permissions }; |
| 157 | + return { html, csp, permissions, linkTrustedDomains }; |
156 | 158 | } |
157 | 159 |
|
158 | 160 |
|
@@ -271,6 +273,12 @@ export interface AppBridgeCallbacks { |
271 | 273 | export interface AppBridgeOptions { |
272 | 274 | containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number }; |
273 | 275 | displayMode?: "inline" | "fullscreen"; |
| 276 | + /** |
| 277 | + * Origins the resource declared as trusted for `ui/open-link` |
| 278 | + * (from `_meta.ui.linkTrustedDomains`). Links matching these skip the |
| 279 | + * confirmation prompt and receive a `redirectUrl` back to the host. |
| 280 | + */ |
| 281 | + linkTrustedDomains?: string[]; |
274 | 282 | } |
275 | 283 |
|
276 | 284 | export function newAppBridge( |
@@ -340,8 +348,29 @@ export function newAppBridge( |
340 | 348 |
|
341 | 349 | appBridge.onopenlink = async (params, _extra) => { |
342 | 350 | log.info("Open link request:", params); |
343 | | - window.open(params.url, "_blank", "noopener,noreferrer"); |
344 | | - return {}; |
| 351 | + |
| 352 | + // Links to origins the server declared as trusted (via |
| 353 | + // `_meta.ui.linkTrustedDomains`) skip the confirmation prompt. For those |
| 354 | + // we also append a `redirectUrl` pointing back to this host so the |
| 355 | + // destination can route the user back at the end of a flow (e.g. checkout). |
| 356 | + // This is a UX hint only — a real host MUST still apply its own |
| 357 | + // allowlist/blocklist before honoring it. |
| 358 | + const trusted = matchesLinkTrustedDomains(params.url, options?.linkTrustedDomains); |
| 359 | + |
| 360 | + if (trusted) { |
| 361 | + const target = appendRedirectUrl(params.url, window.location.href); |
| 362 | + log.info("Opening trusted link (no prompt):", target); |
| 363 | + window.open(target, "_blank", "noopener,noreferrer"); |
| 364 | + return {}; |
| 365 | + } |
| 366 | + |
| 367 | + if (window.confirm(`Open external link?\n${params.url}`)) { |
| 368 | + window.open(params.url, "_blank", "noopener,noreferrer"); |
| 369 | + return {}; |
| 370 | + } |
| 371 | + |
| 372 | + log.info("User declined to open link:", params.url); |
| 373 | + return { isError: true }; |
345 | 374 | }; |
346 | 375 |
|
347 | 376 | appBridge.onloggingmessage = (params) => { |
|
0 commit comments