Skip to content

Commit 637f498

Browse files
Add new linkTrustedDomains to McpUiReesourceMeta definition
1 parent fa12744 commit 637f498

6 files changed

Lines changed: 129 additions & 2 deletions

File tree

specification/draft/apps.mdx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,26 @@ 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). Hosts MAY use this list to skip the link
233+
* confirmation prompt for matching destinations.
234+
*
235+
* - Each entry is an origin (scheme + host[:port]); a leading `*.` is a
236+
* subdomain wildcard, matching the rules used for `csp` domain fields.
237+
* - Empty or omitted = every `ui/open-link` is subject to the host's
238+
* default policy (typically a confirmation prompt).
239+
*
240+
* This is a UX hint, NOT an authorization mechanism. Hosts retain full
241+
* authority, MUST still apply their own allowlist/blocklist, and SHOULD NOT
242+
* treat a declared origin as proof that a destination is safe.
243+
*
244+
* @example
245+
* ["https://example.com", "https://*.example.com"]
246+
*/
247+
linkTrustedDomains?: string[],
228248
}
229249
```
230250

@@ -254,6 +274,7 @@ The resource content is returned via `resources/read`:
254274
};
255275
domain?: string;
256276
prefersBorder?: boolean;
277+
linkTrustedDomains?: string[]; // Origins ui/open-link may skip confirmation for.
257278
};
258279
};
259280
}];
@@ -262,7 +283,7 @@ The resource content is returned via `resources/read`:
262283

263284
#### Metadata Location
264285

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

267288
- **`resources/list`:** On the resource entry's `_meta.ui` field. Useful as a static default that hosts can review at connection time.
268289
- **`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 +1060,23 @@ MCP Apps introduces additional JSON-RPC methods for UI-specific functionality:
10391060

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

1063+
By default, hosts SHOULD guard `ui/open-link` against unexpected navigation —
1064+
for example by showing a confirmation prompt — since the URL originates from
1065+
sandboxed UI content.
1066+
1067+
**Trusted destinations (`linkTrustedDomains`).** A server MAY declare origins it
1068+
legitimately links to via the resource's `_meta.ui.linkTrustedDomains` (see
1069+
[UI Resource Format](#ui-resource-format)). For a `ui/open-link` whose URL
1070+
matches one of those origins, the host MAY **skip the confirmation prompt** and
1071+
open the link directly.
1072+
1073+
Matching uses the same origin rules as `csp` domain fields: an entry is an
1074+
origin (scheme + host[:port]) and a leading `*.` is a subdomain wildcard.
1075+
1076+
> **Security:** `linkTrustedDomains` is a UX hint, not an authorization
1077+
> mechanism. Because the value comes from the (untrusted) server, hosts MUST
1078+
> still enforce their own allowlist/blocklist and MAY confirm regardless.
1079+
10421080
`ui/download-file` - Request host to download a file
10431081

10441082
```typescript

src/app-bridge.examples.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ declare const chatManager: {
159159
// Stub for example code - represents a hypothetical URL validator
160160
declare function isAllowedDomain(url: string): boolean;
161161

162+
// Stub for example code - represents the URL Pattern tester
163+
declare function matchesLinkTrustedDomains(
164+
url: string,
165+
linkTrustedDomains: string[] | undefined,
166+
): boolean;
167+
162168
// Stub for example code - represents a hypothetical dialog API
163169
declare function showDialog(options: {
164170
message: string;
@@ -173,14 +179,25 @@ declare const modelContextManager: {
173179
/**
174180
* Example: Handle external link requests from the View.
175181
*/
176-
function AppBridge_onopenlink_handleRequest(bridge: AppBridge) {
182+
function AppBridge_onopenlink_handleRequest(
183+
bridge: AppBridge,
184+
// Origins declared by the resource via `_meta.ui.linkTrustedDomains`.
185+
linkTrustedDomains: string[] | undefined,
186+
) {
177187
//#region AppBridge_onopenlink_handleRequest
178188
bridge.onopenlink = async ({ url }, extra) => {
189+
// The host's own policy always wins, regardless of server-declared trust.
179190
if (!isAllowedDomain(url)) {
180191
console.warn("Blocked external link:", url);
181192
return { isError: true };
182193
}
183194

195+
// Destinations the server declared as trusted skip the confirmation prompt.
196+
if (matchesLinkTrustedDomains(url, linkTrustedDomains)) {
197+
window.open(url, "_blank", "noopener,noreferrer");
198+
return {};
199+
}
200+
184201
const confirmed = await showDialog({
185202
message: `Open external link?\n${url}`,
186203
buttons: ["Open", "Cancel"],

src/app-bridge.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,7 @@ export class AppBridge extends ProtocolWithEvents<
670670
*
671671
* The host MAY:
672672
* - Show a confirmation dialog before opening
673+
* - Consider skipping user confirmation
673674
* - Block URLs based on a security policy or allowlist
674675
* - Log the request for audit purposes
675676
* - Reject the request entirely
@@ -682,11 +683,18 @@ export class AppBridge extends ProtocolWithEvents<
682683
* @example
683684
* ```ts source="./app-bridge.examples.ts#AppBridge_onopenlink_handleRequest"
684685
* bridge.onopenlink = async ({ url }, extra) => {
686+
* // The host's own policy always wins, regardless of server-declared trust.
685687
* if (!isAllowedDomain(url)) {
686688
* console.warn("Blocked external link:", url);
687689
* return { isError: true };
688690
* }
689691
*
692+
* // Destinations the server declared as trusted skip the confirmation prompt.
693+
* if (matchesLinkTrustedDomains(url, linkTrustedDomains)) {
694+
* window.open(url, "_blank", "noopener,noreferrer");
695+
* return {};
696+
* }
697+
*
690698
* const confirmed = await showDialog({
691699
* message: `Open external link?\n${url}`,
692700
* buttons: ["Open", "Cancel"],

src/generated/schema.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/generated/schema.ts

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/spec.types.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,35 @@ export interface McpUiResourceMeta {
728728
* - omitted: host decides border
729729
*/
730730
prefersBorder?: boolean;
731+
/**
732+
* @description Origins the view is expected to open via `ui/open-link`.
733+
*
734+
* Servers declare external destinations the view legitimately links to (for
735+
* example its own marketing site). Hosts MAY use this list to **skip the link
736+
* confirmation prompt** for matching destinations, instead of confirming every
737+
* `ui/open-link`.
738+
*
739+
* Matching follows the same origin rules as {@link McpUiResourceCsp} fields:
740+
* an entry is an origin (scheme + host[:port]) and wildcard subdomains are
741+
* supported (e.g. `https://*.example.com`).
742+
*
743+
* > [!IMPORTANT]
744+
* > This is a **UX hint, not an authorization mechanism.** It only relaxes
745+
* > confirmation for the declared origins; it does not grant the view any
746+
* > capability. Hosts retain full authority and MUST still apply their own
747+
* > global allowlist/blocklist and MAY confirm regardless. Because the value
748+
* > comes from the server, hosts SHOULD NOT treat it as proof that a
749+
* > destination is safe.
750+
*
751+
* - Empty or omitted → every `ui/open-link` is subject to the host's default
752+
* policy (typically a confirmation prompt).
753+
*
754+
* @example
755+
* ```ts
756+
* ["https://example.com", "https://*.example.com"]
757+
* ```
758+
*/
759+
linkTrustedDomains?: string[];
731760
}
732761

733762
/**

0 commit comments

Comments
 (0)