Skip to content

Commit c9bdb3d

Browse files
wip
1 parent ca1d298 commit c9bdb3d

10 files changed

Lines changed: 499 additions & 18 deletions

File tree

docs/migrate_from_openai_apps.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,13 @@ The server-side changes involve updating metadata structure and using helper fun
5353

5454
### CSP Field Mapping
5555

56-
| OpenAI | MCP Apps | Notes |
57-
| ------------------ | ----------------- | ---------------------------------------------------------- |
58-
| `resource_domains` | `resourceDomains` | Origins for static assets (images, fonts, styles, scripts) |
59-
| `connect_domains` | `connectDomains` | Origins for fetch/XHR/WebSocket requests |
60-
| `frame_domains` | `frameDomains` | Origins for nested iframes |
61-
| `redirect_domains` | | OpenAI-only: origins for `openExternal` redirects |
62-
|| `baseUriDomains` | MCP-only: `base-uri` CSP directive |
56+
| OpenAI | MCP Apps | Notes |
57+
| ------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58+
| `resource_domains` | `resourceDomains` | Origins for static assets (images, fonts, styles, scripts) |
59+
| `connect_domains` | `connectDomains` | Origins for fetch/XHR/WebSocket requests |
60+
| `frame_domains` | `frameDomains` | Origins for nested iframes |
61+
| `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. |
62+
|| `baseUriDomains` | MCP-only: `base-uri` CSP directive |
6363

6464
### Server-Side Migration Example
6565

@@ -255,9 +255,10 @@ Client-side migration involves replacing the implicit `window.openai` global wit
255255

256256
### External Links
257257

258-
| OpenAI | MCP Apps | Notes |
259-
| -------------------------------------------- | ----------------------------------- | ------------------------------------ |
260-
| `await window.openai.openExternal({ href })` | `await app.openLink({ url: href })` | Different param name: `href``url` |
258+
| OpenAI | MCP Apps | Notes |
259+
| -------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
260+
| `await window.openai.openExternal({ href })` | `await app.openLink({ url: href })` | Different param name: `href``url` |
261+
| `_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. |
261262

262263
### Display Mode
263264

examples/basic-host/src/implementation.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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";
22
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
44
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -72,6 +72,7 @@ interface UiResourceData {
7272
html: string;
7373
csp?: McpUiResourceCsp;
7474
permissions?: McpUiResourcePermissions;
75+
linkTrustedDomains?: string[];
7576
}
7677

7778
export interface ToolCallInfo {
@@ -151,8 +152,9 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiRes
151152
const uiMeta = contentMeta?.ui ?? listingMeta?.ui;
152153
const csp = uiMeta?.csp;
153154
const permissions = uiMeta?.permissions;
155+
const linkTrustedDomains = uiMeta?.linkTrustedDomains;
154156

155-
return { html, csp, permissions };
157+
return { html, csp, permissions, linkTrustedDomains };
156158
}
157159

158160

@@ -271,6 +273,12 @@ export interface AppBridgeCallbacks {
271273
export interface AppBridgeOptions {
272274
containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number };
273275
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[];
274282
}
275283

276284
export function newAppBridge(
@@ -340,8 +348,29 @@ export function newAppBridge(
340348

341349
appBridge.onopenlink = async (params, _extra) => {
342350
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 };
345374
};
346375

347376
appBridge.onloggingmessage = (params) => {

examples/basic-host/src/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,7 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
434434

435435
// First get CSP and permissions from resource, then load sandbox
436436
// CSP is set via HTTP headers (tamper-proof), permissions via iframe allow attribute
437-
toolCallInfo.appResourcePromise.then(({ csp, permissions }) => {
437+
toolCallInfo.appResourcePromise.then(({ csp, permissions, linkTrustedDomains }) => {
438438
loadSandboxProxy(iframe, csp, permissions).then((firstTime) => {
439439
// The `firstTime` check guards against React Strict Mode's double
440440
// invocation (mount → unmount → remount simulation in development).
@@ -449,6 +449,8 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
449449
// Provide container dimensions - maxHeight for flexible sizing
450450
containerDimensions: { maxHeight: 6000 },
451451
displayMode: "inline",
452+
// Honor server-declared trusted link origins for ui/open-link
453+
linkTrustedDomains,
452454
});
453455
appBridgeRef.current = appBridge;
454456
initializeApp(iframe, appBridge, toolCallInfo);

specification/draft/apps.mdx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,29 @@ interface UIResourceMeta {
225225
* - omitted: host decides border
226226
*/
227227
prefersBorder?: boolean,
228+
/**
229+
* Origins the view is expected to open via `ui/open-link`
230+
*
231+
* Servers declare external destinations the view legitimately links to (for
232+
* example its own marketing site or a checkout flow it controls). Hosts MAY
233+
* use this list to skip the link confirmation prompt for matching
234+
* destinations, and to append a `redirectUrl` query parameter so the
235+
* external site can route the user back into the conversation at the end of
236+
* a flow (e.g. after checkout).
237+
*
238+
* - Each entry is an origin (scheme + host[:port]); a leading `*.` is a
239+
* subdomain wildcard, matching the rules used for `csp` domain fields.
240+
* - Empty or omitted = every `ui/open-link` is subject to the host's
241+
* default policy (typically a confirmation prompt).
242+
*
243+
* This is a UX hint, NOT an authorization mechanism. Hosts retain full
244+
* authority, MUST still apply their own allowlist/blocklist, and SHOULD NOT
245+
* treat a declared origin as proof that a destination is safe.
246+
*
247+
* @example
248+
* ["https://example.com", "https://*.example.com"]
249+
*/
250+
linkTrustedDomains?: string[],
228251
}
229252
```
230253

@@ -254,6 +277,7 @@ The resource content is returned via `resources/read`:
254277
};
255278
domain?: string;
256279
prefersBorder?: boolean;
280+
linkTrustedDomains?: string[]; // Origins ui/open-link may skip confirmation for (and append redirectUrl to).
257281
};
258282
};
259283
}];
@@ -262,7 +286,7 @@ The resource content is returned via `resources/read`:
262286

263287
#### Metadata Location
264288

265-
`UIResourceMeta` (CSP, permissions, domain, prefersBorder) may be provided on either or both:
289+
`UIResourceMeta` (CSP, permissions, domain, prefersBorder, linkTrustedDomains) may be provided on either or both:
266290

267291
- **`resources/list`:** On the resource entry's `_meta.ui` field. Useful as a static default that hosts can review at connection time.
268292
- **`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:
10391063

10401064
Host SHOULD open the URL in the user's default browser or a new tab.
10411065

1066+
By default, hosts SHOULD guard `ui/open-link` against unexpected navigation —
1067+
for example by showing a confirmation prompt — since the URL originates from
1068+
sandboxed UI content.
1069+
1070+
**Trusted destinations (`linkTrustedDomains`).** A server MAY declare origins it
1071+
legitimately links to via the resource's `_meta.ui.linkTrustedDomains` (see
1072+
[UI Resource Format](#ui-resource-format)). For a `ui/open-link` whose URL
1073+
matches one of those origins, the host MAY:
1074+
1075+
1. **Skip the confirmation prompt**, opening the link directly.
1076+
2. **Append a `redirectUrl` query parameter** to the outgoing URL, set to a
1077+
host-controlled URL that returns the user to the conversation. This lets the
1078+
destination route the user back at the end of a flow (e.g. after checkout):
1079+
1080+
```
1081+
https://shop.example.com/checkout?redirectUrl=https%3A%2F%2Fchat.host.com%2Fc%2Fabc123
1082+
```
1083+
1084+
Matching uses the same origin rules as `csp` domain fields: an entry is an
1085+
origin (scheme + host[:port]) and a leading `*.` is a subdomain wildcard.
1086+
1087+
> **Security:** `linkTrustedDomains` is a UX hint, not an authorization
1088+
> mechanism. Because the value comes from the (untrusted) server, hosts MUST
1089+
> still enforce their own allowlist/blocklist and MAY confirm regardless. Hosts
1090+
> MUST only append `redirectUrl` for destinations that matched, never for
1091+
> arbitrary links, to avoid leaking the return URL to unvetted origins.
1092+
10421093
`ui/download-file` - Request host to download a file
10431094

10441095
```typescript

src/app-bridge.examples.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ import {
1818
ReadResourceResultSchema,
1919
ListPromptsResultSchema,
2020
} from "@modelcontextprotocol/sdk/types.js";
21-
import { AppBridge, PostMessageTransport } from "./app-bridge.js";
21+
import {
22+
AppBridge,
23+
PostMessageTransport,
24+
matchesLinkTrustedDomains,
25+
appendRedirectUrl,
26+
} from "./app-bridge.js";
2227
import type { McpUiDisplayMode } from "./types.js";
2328

2429
/**
@@ -173,14 +178,29 @@ declare const modelContextManager: {
173178
/**
174179
* Example: Handle external link requests from the View.
175180
*/
176-
function AppBridge_onopenlink_handleRequest(bridge: AppBridge) {
181+
function AppBridge_onopenlink_handleRequest(
182+
bridge: AppBridge,
183+
// Origins declared by the resource via `_meta.ui.linkTrustedDomains`.
184+
linkTrustedDomains: string[] | undefined,
185+
// This host's own "return to conversation" URL.
186+
hostReturnUrl: string,
187+
) {
177188
//#region AppBridge_onopenlink_handleRequest
178189
bridge.onopenlink = async ({ url }, extra) => {
190+
// The host's own policy always wins, regardless of server-declared trust.
179191
if (!isAllowedDomain(url)) {
180192
console.warn("Blocked external link:", url);
181193
return { isError: true };
182194
}
183195

196+
// Destinations the server declared as trusted skip the confirmation prompt
197+
// and get a `redirectUrl` so they can route the user back afterwards.
198+
if (matchesLinkTrustedDomains(url, linkTrustedDomains)) {
199+
const target = appendRedirectUrl(url, hostReturnUrl);
200+
window.open(target, "_blank", "noopener,noreferrer");
201+
return {};
202+
}
203+
184204
const confirmed = await showDialog({
185205
message: `Open external link?\n${url}`,
186206
buttons: ["Open", "Cancel"],

0 commit comments

Comments
 (0)