|
1 | | -import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge"; |
| 1 | +import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, matchesLinkTrustedDomains, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge"; |
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,135 @@ 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. |
| 280 | + */ |
| 281 | + linkTrustedDomains?: string[]; |
| 282 | +} |
| 283 | + |
| 284 | +/** |
| 285 | + * Show a simple themed confirmation modal asking the user to approve opening an |
| 286 | + * external link. Used for links that are NOT in the resource's |
| 287 | + * `linkTrustedDomains`. Resolves `true` if the user approves, `false` otherwise. |
| 288 | + * |
| 289 | + * This replaces the native `window.confirm()` so the prompt is styled with the |
| 290 | + * host theme and keeps working in environments where native dialogs are |
| 291 | + * suppressed. |
| 292 | + */ |
| 293 | +function confirmOpenLink(url: string): Promise<boolean> { |
| 294 | + return new Promise((resolve) => { |
| 295 | + const overlay = document.createElement("div"); |
| 296 | + Object.assign(overlay.style, { |
| 297 | + position: "fixed", |
| 298 | + inset: "0", |
| 299 | + display: "flex", |
| 300 | + alignItems: "center", |
| 301 | + justifyContent: "center", |
| 302 | + background: "rgba(0, 0, 0, 0.5)", |
| 303 | + zIndex: "2000", |
| 304 | + }); |
| 305 | + |
| 306 | + const dialog = document.createElement("div"); |
| 307 | + dialog.setAttribute("role", "dialog"); |
| 308 | + dialog.setAttribute("aria-modal", "true"); |
| 309 | + dialog.setAttribute("aria-labelledby", "open-link-title"); |
| 310 | + Object.assign(dialog.style, { |
| 311 | + maxWidth: "420px", |
| 312 | + width: "calc(100% - 2rem)", |
| 313 | + padding: "1.25rem", |
| 314 | + border: "1px solid var(--color-border)", |
| 315 | + borderRadius: "8px", |
| 316 | + background: "var(--color-bg-secondary)", |
| 317 | + color: "var(--color-text)", |
| 318 | + boxShadow: "0 10px 40px rgba(0, 0, 0, 0.35)", |
| 319 | + }); |
| 320 | + |
| 321 | + const title = document.createElement("h2"); |
| 322 | + title.id = "open-link-title"; |
| 323 | + title.textContent = "Open external link?"; |
| 324 | + Object.assign(title.style, { margin: "0 0 0.5rem", fontSize: "1.1rem" }); |
| 325 | + |
| 326 | + const message = document.createElement("p"); |
| 327 | + message.textContent = "This app wants to open:"; |
| 328 | + Object.assign(message.style, { |
| 329 | + margin: "0 0 0.5rem", |
| 330 | + color: "var(--color-text-secondary)", |
| 331 | + }); |
| 332 | + |
| 333 | + const urlEl = document.createElement("code"); |
| 334 | + urlEl.textContent = url; |
| 335 | + Object.assign(urlEl.style, { |
| 336 | + display: "block", |
| 337 | + margin: "0 0 1.25rem", |
| 338 | + padding: "0.5rem", |
| 339 | + borderRadius: "4px", |
| 340 | + background: "var(--color-bg)", |
| 341 | + fontFamily: "monospace", |
| 342 | + fontSize: "0.85rem", |
| 343 | + wordBreak: "break-all", |
| 344 | + }); |
| 345 | + |
| 346 | + const actions = document.createElement("div"); |
| 347 | + Object.assign(actions.style, { |
| 348 | + display: "flex", |
| 349 | + justifyContent: "flex-end", |
| 350 | + gap: "0.5rem", |
| 351 | + }); |
| 352 | + |
| 353 | + const cancelBtn = document.createElement("button"); |
| 354 | + cancelBtn.textContent = "Cancel"; |
| 355 | + Object.assign(cancelBtn.style, { |
| 356 | + padding: "0.5rem 1rem", |
| 357 | + border: "1px solid var(--color-border)", |
| 358 | + borderRadius: "4px", |
| 359 | + background: "var(--color-bg)", |
| 360 | + color: "var(--color-text)", |
| 361 | + font: "inherit", |
| 362 | + cursor: "pointer", |
| 363 | + }); |
| 364 | + |
| 365 | + const openBtn = document.createElement("button"); |
| 366 | + openBtn.textContent = "Open"; |
| 367 | + Object.assign(openBtn.style, { |
| 368 | + padding: "0.5rem 1rem", |
| 369 | + border: "none", |
| 370 | + borderRadius: "4px", |
| 371 | + background: "var(--color-primary)", |
| 372 | + color: "white", |
| 373 | + font: "inherit", |
| 374 | + fontWeight: "600", |
| 375 | + cursor: "pointer", |
| 376 | + }); |
| 377 | + |
| 378 | + let settled = false; |
| 379 | + const close = (result: boolean) => { |
| 380 | + if (settled) return; |
| 381 | + settled = true; |
| 382 | + document.removeEventListener("keydown", onKeyDown); |
| 383 | + overlay.remove(); |
| 384 | + resolve(result); |
| 385 | + }; |
| 386 | + |
| 387 | + const onKeyDown = (event: KeyboardEvent) => { |
| 388 | + if (event.key === "Escape") close(false); |
| 389 | + }; |
| 390 | + |
| 391 | + cancelBtn.addEventListener("click", () => close(false)); |
| 392 | + openBtn.addEventListener("click", () => close(true)); |
| 393 | + overlay.addEventListener("click", (event) => { |
| 394 | + // Dismiss when clicking the backdrop (outside the dialog). |
| 395 | + if (event.target === overlay) close(false); |
| 396 | + }); |
| 397 | + document.addEventListener("keydown", onKeyDown); |
| 398 | + |
| 399 | + actions.append(cancelBtn, openBtn); |
| 400 | + dialog.append(title, message, urlEl, actions); |
| 401 | + overlay.append(dialog); |
| 402 | + document.body.append(overlay); |
| 403 | + openBtn.focus(); |
| 404 | + }); |
274 | 405 | } |
275 | 406 |
|
276 | 407 | export function newAppBridge( |
@@ -340,8 +471,27 @@ export function newAppBridge( |
340 | 471 |
|
341 | 472 | appBridge.onopenlink = async (params, _extra) => { |
342 | 473 | log.info("Open link request:", params); |
343 | | - window.open(params.url, "_blank", "noopener,noreferrer"); |
344 | | - return {}; |
| 474 | + |
| 475 | + // Links to origins the server declared as trusted (via |
| 476 | + // `_meta.ui.linkTrustedDomains`) skip the confirmation prompt. This is a UX |
| 477 | + // hint only — a real host MUST still apply its own allowlist/blocklist |
| 478 | + // before honoring it. |
| 479 | + const trusted = matchesLinkTrustedDomains(params.url, options?.linkTrustedDomains); |
| 480 | + |
| 481 | + if (trusted) { |
| 482 | + log.info("Opening trusted link (no prompt):", params.url); |
| 483 | + window.open(params.url, "_blank", "noopener,noreferrer"); |
| 484 | + return {}; |
| 485 | + } |
| 486 | + |
| 487 | + // Untrusted links require explicit user approval via a confirmation modal. |
| 488 | + if (await confirmOpenLink(params.url)) { |
| 489 | + window.open(params.url, "_blank", "noopener,noreferrer"); |
| 490 | + return {}; |
| 491 | + } |
| 492 | + |
| 493 | + log.info("User declined to open link:", params.url); |
| 494 | + return { isError: true }; |
345 | 495 | }; |
346 | 496 |
|
347 | 497 | appBridge.onloggingmessage = (params) => { |
|
0 commit comments