Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions docs/migrate_from_openai_apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
37 changes: 33 additions & 4 deletions examples/basic-host/src/implementation.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -72,6 +72,7 @@ interface UiResourceData {
html: string;
csp?: McpUiResourceCsp;
permissions?: McpUiResourcePermissions;
linkTrustedDomains?: string[];
}

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

return { html, csp, permissions };
return { html, csp, permissions, linkTrustedDomains };
}


Expand Down Expand Up @@ -271,6 +273,12 @@ export interface AppBridgeCallbacks {
export interface AppBridgeOptions {
containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number };
displayMode?: "inline" | "fullscreen";
/**
* Origins the resource declared as trusted for `ui/open-link`
* (from `_meta.ui.linkTrustedDomains`). Links matching these skip the
* confirmation prompt and receive a `redirectUrl` back to the host.
*/
linkTrustedDomains?: string[];
}

export function newAppBridge(
Expand Down Expand Up @@ -340,8 +348,29 @@ 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. 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) => {
Expand Down
4 changes: 3 additions & 1 deletion examples/basic-host/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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);
Expand Down
53 changes: 52 additions & 1 deletion specification/draft/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
}
```

Expand Down Expand Up @@ -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).
};
};
}];
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions src/app-bridge.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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"],
Expand Down
Loading
Loading