Skip to content

Commit 0827b5a

Browse files
feat: add MCP Apps (SEP-1865) support
Adds opt-in 'enableMcpApps' session capability that advertises the 'extensions.io.modelcontextprotocol/ui' extension to MCP servers and exposes 'session.rpc.mcp.apps.*' JSON-RPC methods. Node SDK gains two pure helpers for hosts rendering 'ui://' MCP App bundles in iframes: - buildMcpAppsCspHeader — constructs the Content-Security-Policy header per SEP-1865 §UI Resource Format + §Security Implications, including the restrictive default ('connect-src none') when '_meta.ui.csp' is absent and constructed defaults ('connect-src self', etc.) when it is declared. - buildMcpAppsAllowAttribute — maps '_meta.ui.permissions' to the iframe 'allow' attribute (Permission Policy). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 310cc6c commit 0827b5a

5 files changed

Lines changed: 246 additions & 0 deletions

File tree

nodejs/src/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,7 @@ export class CopilotClient {
811811
requestPermission: true,
812812
requestUserInput: !!config.onUserInputRequest,
813813
requestElicitation: !!config.onElicitationRequest,
814+
requestMcpApps: !!config.enableMcpApps,
814815
requestExitPlanMode: !!config.onExitPlanMode,
815816
requestAutoModeSwitch: !!config.onAutoModeSwitch,
816817
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),
@@ -960,6 +961,7 @@ export class CopilotClient {
960961
config.onPermissionRequest !== defaultJoinSessionPermissionHandler,
961962
requestUserInput: !!config.onUserInputRequest,
962963
requestElicitation: !!config.onElicitationRequest,
964+
requestMcpApps: !!config.enableMcpApps,
963965
requestExitPlanMode: !!config.onExitPlanMode,
964966
requestAutoModeSwitch: !!config.onAutoModeSwitch,
965967
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),

nodejs/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010

1111
export { CopilotClient } from "./client.js";
1212
export { CopilotSession, type AssistantMessageEvent } from "./session.js";
13+
export {
14+
buildMcpAppsAllowAttribute,
15+
buildMcpAppsCspHeader,
16+
type McpAppsCspInput,
17+
type McpAppsPermissionsInput,
18+
} from "./mcpAppsSandbox.js";
1319
export {
1420
defineTool,
1521
approveAll,

nodejs/src/mcpAppsSandbox.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* SEP-1865 sandbox primitives: Content-Security-Policy and Permission Policy
3+
* builders for hosts that render MCP App `ui://` bundles in iframes.
4+
*
5+
* These are pure functions — no DOM, no fetch — so they're safe to call in
6+
* Node, the renderer process, or a service worker. The spec mandates two
7+
* different CSP shapes:
8+
*
9+
* 1. **Restrictive default** (when the resource has no `_meta.ui.csp` at
10+
* all): `connect-src 'none'`, no external resource origins.
11+
* See spec §UI Resource Format → "Restrictive Default".
12+
* 2. **Constructed default** (when the resource declares any `csp` block,
13+
* even with empty arrays): `connect-src 'self'` plus declared domains,
14+
* `frame-src 'none'` unless overridden, `base-uri 'self'` unless
15+
* overridden. See spec §Security Implications → "CSP Construction".
16+
*
17+
* The host MUST always set `default-src 'none'` and `object-src 'none'`.
18+
*/
19+
20+
/** Resource-level `_meta.ui.csp` block per SEP-1865. All fields optional. */
21+
export interface McpAppsCspInput {
22+
/** Origins for network requests (fetch/XHR/WebSocket). Maps to `connect-src`. */
23+
connectDomains?: string[];
24+
/**
25+
* Origins for static resources (scripts, images, styles, fonts, media).
26+
* Maps to `script-src`, `style-src`, `img-src`, `font-src`, `media-src`.
27+
*/
28+
resourceDomains?: string[];
29+
/** Origins for nested iframes. Maps to `frame-src`. */
30+
frameDomains?: string[];
31+
/** Allowed base URIs for the document. Maps to `base-uri`. */
32+
baseUriDomains?: string[];
33+
}
34+
35+
/** Resource-level `_meta.ui.permissions` block per SEP-1865. */
36+
export interface McpAppsPermissionsInput {
37+
/** Maps to Permission Policy `camera` feature. */
38+
camera?: Record<string, unknown>;
39+
/** Maps to Permission Policy `microphone` feature. */
40+
microphone?: Record<string, unknown>;
41+
/** Maps to Permission Policy `geolocation` feature. */
42+
geolocation?: Record<string, unknown>;
43+
/** Maps to Permission Policy `clipboard-write` feature. */
44+
clipboardWrite?: Record<string, unknown>;
45+
}
46+
47+
/** Spec-mandated restrictive default applied when `_meta.ui.csp` is entirely absent. */
48+
const RESTRICTIVE_DEFAULT_CSP =
49+
"default-src 'none'; " +
50+
"script-src 'self' 'unsafe-inline'; " +
51+
"style-src 'self' 'unsafe-inline'; " +
52+
"img-src 'self' data:; " +
53+
"media-src 'self' data:; " +
54+
"connect-src 'none'; " +
55+
"frame-src 'none'; " +
56+
"object-src 'none'; " +
57+
"base-uri 'self'";
58+
59+
/**
60+
* Build the `Content-Security-Policy` header value for an MCP App view per
61+
* SEP-1865 §UI Resource Format and §Security Implications.
62+
*
63+
* Pass `_meta.ui.csp` from the resolved `resources/read` content item. If the
64+
* resource omits `_meta.ui.csp` entirely, pass `undefined` to apply the
65+
* restrictive default (`connect-src 'none'`).
66+
*
67+
* The host MAY further restrict the returned policy but MUST NOT add
68+
* undeclared domains (spec §UI Resource Format → "No Loosening").
69+
*
70+
* @example
71+
* ```ts
72+
* const meta = uiResource._meta?.ui;
73+
* res.setHeader("Content-Security-Policy", buildMcpAppsCspHeader(meta?.csp));
74+
* ```
75+
*/
76+
export function buildMcpAppsCspHeader(csp: McpAppsCspInput | undefined): string {
77+
if (!csp) {
78+
return RESTRICTIVE_DEFAULT_CSP;
79+
}
80+
const resourceDomains = (csp.resourceDomains ?? []).join(" ");
81+
const connectDomains = (csp.connectDomains ?? []).join(" ");
82+
const frameDomains = csp.frameDomains?.length ? csp.frameDomains.join(" ") : "'none'";
83+
const baseUriDomains = csp.baseUriDomains?.length ? csp.baseUriDomains.join(" ") : "'self'";
84+
const trail = (extra: string) => (extra ? ` ${extra}` : "");
85+
return [
86+
"default-src 'none'",
87+
`script-src 'self' 'unsafe-inline'${trail(resourceDomains)}`,
88+
`style-src 'self' 'unsafe-inline'${trail(resourceDomains)}`,
89+
`connect-src 'self'${trail(connectDomains)}`,
90+
`img-src 'self' data:${trail(resourceDomains)}`,
91+
`font-src 'self'${trail(resourceDomains)}`,
92+
`media-src 'self' data:${trail(resourceDomains)}`,
93+
`frame-src ${frameDomains}`,
94+
"object-src 'none'",
95+
`base-uri ${baseUriDomains}`,
96+
].join("; ");
97+
}
98+
99+
/**
100+
* Build the value for the iframe `allow` attribute (Permission Policy) from
101+
* an MCP App view's `_meta.ui.permissions` block per SEP-1865.
102+
*
103+
* Note `clipboardWrite` maps to the hyphenated `clipboard-write` Permission
104+
* Policy feature name.
105+
*
106+
* @example
107+
* ```ts
108+
* const allow = buildMcpAppsAllowAttribute(uiResource._meta?.ui?.permissions);
109+
* iframe.setAttribute("allow", allow);
110+
* ```
111+
*/
112+
export function buildMcpAppsAllowAttribute(permissions: McpAppsPermissionsInput | undefined): string {
113+
if (!permissions) return "";
114+
const features: string[] = [];
115+
if (permissions.camera) features.push("camera");
116+
if (permissions.microphone) features.push("microphone");
117+
if (permissions.geolocation) features.push("geolocation");
118+
if (permissions.clipboardWrite) features.push("clipboard-write");
119+
return features.join("; ");
120+
}

nodejs/src/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,6 +1406,24 @@ export interface SessionConfig {
14061406
*/
14071407
onElicitationRequest?: ElicitationHandler;
14081408

1409+
/**
1410+
* Enable MCP Apps (SEP-1865) UI passthrough on this session.
1411+
*
1412+
* When `true`, the runtime adds the `mcp-apps` capability to the session,
1413+
* which causes it to advertise the `extensions.io.modelcontextprotocol/ui`
1414+
* extension to MCP servers (so they expose `_meta.ui.resourceUri` on tools)
1415+
* and to expose the `session.rpc.mcp.apps.{listTools,callTool,readResource,
1416+
* setHostContext,getHostContext}` JSON-RPC methods.
1417+
*
1418+
* SDK consumers MUST set this to `true` only when they have an iframe
1419+
* renderer that can display `ui://` MCP App bundles. Setting it without a
1420+
* renderer will cause MCP servers to register UI-enabled tool variants
1421+
* the consumer cannot display.
1422+
*
1423+
* @default false
1424+
*/
1425+
enableMcpApps?: boolean;
1426+
14091427
/**
14101428
* Handler for exit-plan-mode requests from the agent.
14111429
* When provided, enables `exitPlanMode.request` callbacks.
@@ -1563,6 +1581,7 @@ export type ResumeSessionConfig = Pick<
15631581
| "onPermissionRequest"
15641582
| "onUserInputRequest"
15651583
| "onElicitationRequest"
1584+
| "enableMcpApps"
15661585
| "onExitPlanMode"
15671586
| "onAutoModeSwitch"
15681587
| "hooks"

nodejs/test/mcpAppsSandbox.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildMcpAppsAllowAttribute, buildMcpAppsCspHeader } from "../src/mcpAppsSandbox.js";
3+
4+
/**
5+
* SEP-1865 §UI Resource Format → "Restrictive Default" and §Security
6+
* Implications → "CSP Construction" pin the exact CSP shapes a host MUST emit.
7+
* These tests pin the spec text to the helper output so any regression is
8+
* caught against the pinned spec lines, not against an implementation detail.
9+
*/
10+
describe("buildMcpAppsCspHeader", () => {
11+
it("returns the restrictive default when csp is undefined (spec §UI Resource Format)", () => {
12+
const header = buildMcpAppsCspHeader(undefined);
13+
// Restrictive default MUST set connect-src 'none' (no external network).
14+
expect(header).toContain("default-src 'none'");
15+
expect(header).toContain("script-src 'self' 'unsafe-inline'");
16+
expect(header).toContain("style-src 'self' 'unsafe-inline'");
17+
expect(header).toContain("img-src 'self' data:");
18+
expect(header).toContain("media-src 'self' data:");
19+
expect(header).toContain("connect-src 'none'");
20+
expect(header).toContain("frame-src 'none'");
21+
expect(header).toContain("object-src 'none'");
22+
expect(header).toContain("base-uri 'self'");
23+
});
24+
25+
it("uses connect-src 'self' (not 'none') when csp is declared with empty arrays", () => {
26+
// Per spec §Security Implications, a present `csp` block — even with
27+
// empty arrays — switches to constructed defaults: connect-src 'self'.
28+
const header = buildMcpAppsCspHeader({});
29+
expect(header).toContain("connect-src 'self'");
30+
expect(header).not.toContain("connect-src 'none'");
31+
});
32+
33+
it("appends declared connectDomains to connect-src", () => {
34+
const header = buildMcpAppsCspHeader({
35+
connectDomains: ["https://api.weather.com", "wss://realtime.service.com"],
36+
});
37+
expect(header).toContain("connect-src 'self' https://api.weather.com wss://realtime.service.com");
38+
});
39+
40+
it("appends resourceDomains to script-src, style-src, img-src, font-src, media-src", () => {
41+
const header = buildMcpAppsCspHeader({
42+
resourceDomains: ["https://cdn.jsdelivr.net"],
43+
});
44+
expect(header).toContain("script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net");
45+
expect(header).toContain("style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net");
46+
expect(header).toContain("img-src 'self' data: https://cdn.jsdelivr.net");
47+
expect(header).toContain("font-src 'self' https://cdn.jsdelivr.net");
48+
expect(header).toContain("media-src 'self' data: https://cdn.jsdelivr.net");
49+
});
50+
51+
it("uses declared frameDomains when provided, 'none' otherwise", () => {
52+
expect(buildMcpAppsCspHeader({})).toContain("frame-src 'none'");
53+
const header = buildMcpAppsCspHeader({
54+
frameDomains: ["https://www.youtube.com", "https://player.vimeo.com"],
55+
});
56+
expect(header).toContain("frame-src https://www.youtube.com https://player.vimeo.com");
57+
expect(header).not.toContain("frame-src 'none'");
58+
});
59+
60+
it("uses declared baseUriDomains when provided, 'self' otherwise", () => {
61+
expect(buildMcpAppsCspHeader({})).toContain("base-uri 'self'");
62+
const header = buildMcpAppsCspHeader({ baseUriDomains: ["https://cdn.example.com"] });
63+
expect(header).toContain("base-uri https://cdn.example.com");
64+
expect(header).not.toContain("base-uri 'self'");
65+
});
66+
67+
it("always includes object-src 'none' (host MUST block plugins)", () => {
68+
expect(buildMcpAppsCspHeader(undefined)).toContain("object-src 'none'");
69+
expect(buildMcpAppsCspHeader({})).toContain("object-src 'none'");
70+
expect(buildMcpAppsCspHeader({ resourceDomains: ["x"] })).toContain("object-src 'none'");
71+
});
72+
});
73+
74+
describe("buildMcpAppsAllowAttribute", () => {
75+
it("returns empty string when permissions is undefined", () => {
76+
expect(buildMcpAppsAllowAttribute(undefined)).toBe("");
77+
});
78+
79+
it("returns empty string when no features are requested", () => {
80+
expect(buildMcpAppsAllowAttribute({})).toBe("");
81+
});
82+
83+
it("maps each requested feature to its Permission Policy name", () => {
84+
expect(buildMcpAppsAllowAttribute({ camera: {} })).toBe("camera");
85+
expect(buildMcpAppsAllowAttribute({ microphone: {} })).toBe("microphone");
86+
expect(buildMcpAppsAllowAttribute({ geolocation: {} })).toBe("geolocation");
87+
// The hyphenated form per Permission Policy spec.
88+
expect(buildMcpAppsAllowAttribute({ clipboardWrite: {} })).toBe("clipboard-write");
89+
});
90+
91+
it("joins multiple features with '; '", () => {
92+
const allow = buildMcpAppsAllowAttribute({
93+
camera: {},
94+
microphone: {},
95+
clipboardWrite: {},
96+
});
97+
expect(allow).toBe("camera; microphone; clipboard-write");
98+
});
99+
});

0 commit comments

Comments
 (0)