Skip to content
Open
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. 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. Declared on the UI resource. |

### Display Mode

Expand Down
2 changes: 1 addition & 1 deletion examples/basic-host/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "22.10.0",
"@types/node": "24.13.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.3.4",
Expand Down
25 changes: 22 additions & 3 deletions examples/basic-host/src/implementation.ts
Original file line number Diff line number Diff line change
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,7 @@ export interface AppBridgeCallbacks {
export interface AppBridgeOptions {
containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number };
displayMode?: "inline" | "fullscreen";
linkTrustedDomains?: string[];
}

export function newAppBridge(
Expand Down Expand Up @@ -340,8 +343,24 @@ export function newAppBridge(

appBridge.onopenlink = async (params, _extra) => {
log.info("Open link request:", params);
window.open(params.url, "_blank", "noopener,noreferrer");
return {};

const HOST_OPENLINK_DENYLIST = [new URL("https://malicious.com")];
if (HOST_OPENLINK_DENYLIST.some(({ origin }) => origin === new URL(params.url).origin)) {
log.info("Blocked link by host denylist:", params.url);
return { isError: true };
}

const isTrustedByApp = options?.linkTrustedDomains?.some((trustedDomain) =>
new URLPattern(trustedDomain).test(params.url)
);
const shouldOpen = isTrustedByApp || window.confirm(`Open external link?\n${params.url}`);
if (shouldOpen) {
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
3 changes: 2 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,7 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
// Provide container dimensions - maxHeight for flexible sizing
containerDimensions: { maxHeight: 6000 },
displayMode: "inline",
linkTrustedDomains,
});
appBridgeRef.current = appBridge;
initializeApp(iframe, appBridge, toolCallInfo);
Expand Down
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,7 @@
"overrides": {
"seroval": "1.4.1",
"seroval-plugins": "1.4.2",
"solid-js": "1.9.10",
"@types/node": "20.19.27"
"solid-js": "1.9.10"
},
"dependencies": {
"@standard-schema/spec": "^1.1.0"
Expand Down
40 changes: 39 additions & 1 deletion specification/draft/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,26 @@ interface UIResourceMeta {
* - omitted: host decides border
*/
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 marketing site). Hosts MAY use this list to skip the link
* confirmation prompt for matching destinations.
*
* - 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.
Comment on lines +240 to +242

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@domfarolino, you paid special attention in the corresponding issue on making sure hosts understand this feature is in no way a trust mechanism. Could you review this last part and confirm wether you deem this explanation sufficient to ensure nobody uses linkTrustedDomains as safe?

*
* @example
* ["https://example.com", "https://*.example.com"]
*/
linkTrustedDomains?: string[],
}
```

Expand Down Expand Up @@ -254,6 +274,7 @@ The resource content is returned via `resources/read`:
};
domain?: string;
prefersBorder?: boolean;
linkTrustedDomains?: string[]; // Origins ui/open-link may skip confirmation for.
};
};
}];
Expand All @@ -262,7 +283,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 +1060,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
Expand Down
19 changes: 18 additions & 1 deletion src/app-bridge.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ declare const chatManager: {
// Stub for example code - represents a hypothetical URL validator
declare function isAllowedDomain(url: string): boolean;

// Stub for example code - represents the URL Pattern tester
declare function matchesLinkTrustedDomains(
url: string,
linkTrustedDomains: string[] | undefined,
): boolean;

// Stub for example code - represents a hypothetical dialog API
declare function showDialog(options: {
message: string;
Expand All @@ -173,14 +179,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"],
Expand Down
8 changes: 8 additions & 0 deletions src/app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ export class AppBridge extends ProtocolWithEvents<
*
* The host MAY:
* - Show a confirmation dialog before opening
* - Consider skipping user confirmation
* - Block URLs based on a security policy or allowlist
* - Log the request for audit purposes
* - Reject the request entirely
Expand All @@ -682,11 +683,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"],
Expand Down
6 changes: 6 additions & 0 deletions src/generated/schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions src/generated/schema.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions src/spec.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,35 @@ export interface McpUiResourceMeta {
* - omitted: host decides border
*/
prefersBorder?: boolean;
/**
* @description Origins the view is expecting 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[];
}

/**
Expand Down
Loading