Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0827b5a
feat: add MCP Apps (SEP-1865) support
mattdholloway May 19, 2026
0d544c0
Merge branch 'main' into feat/mcp-apps-support
mattdholloway May 19, 2026
f251469
feat: add MCP Apps option to Python, Go, .NET, Rust SDKs
mattdholloway May 20, 2026
d90bb0f
Merge branch 'main' into feat/mcp-apps-support
mattdholloway May 20, 2026
7c62138
chore: prettier format mcpAppsSandbox files
mattdholloway May 20, 2026
b301fe4
fix: sanitize CSP domain inputs in mcpAppsSandbox (SEP-1865)
mattdholloway May 20, 2026
8f8b8cf
docs: note runtime MCP_APPS gate on enableMcpApps across SDKs
mattdholloway May 20, 2026
cba4220
Merge branch 'main' into feat/mcp-apps-support
mattdholloway May 20, 2026
53c1a2b
feat: surface capabilities.ui.mcpApps and warn on silent drop
mattdholloway May 20, 2026
f390934
Merge branch 'main' into feat/mcp-apps-support
mattdholloway May 20, 2026
e29ea92
fix: ruff format + add mcp_apps field to Rust e2e UiCapabilities literal
mattdholloway May 20, 2026
af3ee8a
fix: drop sessionId from MCP Apps warning to silence CodeQL clear-tex…
mattdholloway May 21, 2026
fb8cefd
Merge remote-tracking branch 'origin/main' into feat/mcp-apps-support
mattdholloway May 21, 2026
93fa5fe
Merge remote-tracking branch 'origin/main' into feat/mcp-apps-support
mattdholloway May 22, 2026
c3e1524
feat: add enableMcpApps support to Java SDK
mattdholloway May 22, 2026
8541da5
Merge remote-tracking branch 'origin/main' into feat/mcp-apps-support
mattdholloway May 22, 2026
9ab8617
chore: address sanity-check findings
mattdholloway May 22, 2026
7c06494
style: apply spotless formatting to Java MCP Apps additions
mattdholloway May 22, 2026
cf5ecb3
Omit requestMcpApps from wire payload when disabled
mattdholloway May 22, 2026
82ce21e
Merge branch 'main' into feat/mcp-apps-support
mattdholloway May 22, 2026
3d41f8e
fix(mcp-apps): address high-priority review feedback
mattdholloway May 22, 2026
f61d1fe
docs(mcp-apps): address worth-doing review feedback
mattdholloway May 22, 2026
6c19fa2
Merge branch 'main' into feat/mcp-apps-support
mattdholloway May 22, 2026
6ce0f03
fix(go): route MCP Apps warning through log.Default() instead of os.S…
mattdholloway May 22, 2026
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
2 changes: 2 additions & 0 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,7 @@ export class CopilotClient {
requestPermission: true,
requestUserInput: !!config.onUserInputRequest,
requestElicitation: !!config.onElicitationRequest,
requestMcpApps: !!config.enableMcpApps,
requestExitPlanMode: !!config.onExitPlanMode,
requestAutoModeSwitch: !!config.onAutoModeSwitch,
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),
Expand Down Expand Up @@ -960,6 +961,7 @@ export class CopilotClient {
config.onPermissionRequest !== defaultJoinSessionPermissionHandler,
requestUserInput: !!config.onUserInputRequest,
requestElicitation: !!config.onElicitationRequest,
requestMcpApps: !!config.enableMcpApps,
requestExitPlanMode: !!config.onExitPlanMode,
requestAutoModeSwitch: !!config.onAutoModeSwitch,
hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)),
Expand Down
6 changes: 6 additions & 0 deletions nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@

export { CopilotClient } from "./client.js";
export { CopilotSession, type AssistantMessageEvent } from "./session.js";
export {
buildMcpAppsAllowAttribute,
buildMcpAppsCspHeader,
type McpAppsCspInput,
type McpAppsPermissionsInput,
} from "./mcpAppsSandbox.js";
export {
defineTool,
approveAll,
Expand Down
120 changes: 120 additions & 0 deletions nodejs/src/mcpAppsSandbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* SEP-1865 sandbox primitives: Content-Security-Policy and Permission Policy
* builders for hosts that render MCP App `ui://` bundles in iframes.
*
* These are pure functions — no DOM, no fetch — so they're safe to call in
* Node, the renderer process, or a service worker. The spec mandates two
* different CSP shapes:
*
* 1. **Restrictive default** (when the resource has no `_meta.ui.csp` at
* all): `connect-src 'none'`, no external resource origins.
* See spec §UI Resource Format → "Restrictive Default".
* 2. **Constructed default** (when the resource declares any `csp` block,
* even with empty arrays): `connect-src 'self'` plus declared domains,
* `frame-src 'none'` unless overridden, `base-uri 'self'` unless
* overridden. See spec §Security Implications → "CSP Construction".
*
* The host MUST always set `default-src 'none'` and `object-src 'none'`.
*/

/** Resource-level `_meta.ui.csp` block per SEP-1865. All fields optional. */
export interface McpAppsCspInput {
/** Origins for network requests (fetch/XHR/WebSocket). Maps to `connect-src`. */
connectDomains?: string[];
/**
* Origins for static resources (scripts, images, styles, fonts, media).
* Maps to `script-src`, `style-src`, `img-src`, `font-src`, `media-src`.
*/
resourceDomains?: string[];
/** Origins for nested iframes. Maps to `frame-src`. */
frameDomains?: string[];
/** Allowed base URIs for the document. Maps to `base-uri`. */
baseUriDomains?: string[];
}

/** Resource-level `_meta.ui.permissions` block per SEP-1865. */
export interface McpAppsPermissionsInput {
/** Maps to Permission Policy `camera` feature. */
camera?: Record<string, unknown>;
/** Maps to Permission Policy `microphone` feature. */
microphone?: Record<string, unknown>;
/** Maps to Permission Policy `geolocation` feature. */
geolocation?: Record<string, unknown>;
/** Maps to Permission Policy `clipboard-write` feature. */
clipboardWrite?: Record<string, unknown>;
}

/** Spec-mandated restrictive default applied when `_meta.ui.csp` is entirely absent. */
const RESTRICTIVE_DEFAULT_CSP =
"default-src 'none'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; " +
"media-src 'self' data:; " +
"connect-src 'none'; " +
"frame-src 'none'; " +
"object-src 'none'; " +
"base-uri 'self'";

/**
* Build the `Content-Security-Policy` header value for an MCP App view per
* SEP-1865 §UI Resource Format and §Security Implications.
*
* Pass `_meta.ui.csp` from the resolved `resources/read` content item. If the
* resource omits `_meta.ui.csp` entirely, pass `undefined` to apply the
* restrictive default (`connect-src 'none'`).
*
* The host MAY further restrict the returned policy but MUST NOT add
* undeclared domains (spec §UI Resource Format → "No Loosening").
*
* @example
* ```ts
* const meta = uiResource._meta?.ui;
* res.setHeader("Content-Security-Policy", buildMcpAppsCspHeader(meta?.csp));
* ```
*/
export function buildMcpAppsCspHeader(csp: McpAppsCspInput | undefined): string {
if (!csp) {
return RESTRICTIVE_DEFAULT_CSP;
}
const resourceDomains = (csp.resourceDomains ?? []).join(" ");
const connectDomains = (csp.connectDomains ?? []).join(" ");
const frameDomains = csp.frameDomains?.length ? csp.frameDomains.join(" ") : "'none'";
const baseUriDomains = csp.baseUriDomains?.length ? csp.baseUriDomains.join(" ") : "'self'";
const trail = (extra: string) => (extra ? ` ${extra}` : "");
return [
"default-src 'none'",
`script-src 'self' 'unsafe-inline'${trail(resourceDomains)}`,
`style-src 'self' 'unsafe-inline'${trail(resourceDomains)}`,
`connect-src 'self'${trail(connectDomains)}`,
`img-src 'self' data:${trail(resourceDomains)}`,
`font-src 'self'${trail(resourceDomains)}`,
`media-src 'self' data:${trail(resourceDomains)}`,
`frame-src ${frameDomains}`,
"object-src 'none'",
`base-uri ${baseUriDomains}`,
].join("; ");
}

/**
* Build the value for the iframe `allow` attribute (Permission Policy) from
* an MCP App view's `_meta.ui.permissions` block per SEP-1865.
*
* Note `clipboardWrite` maps to the hyphenated `clipboard-write` Permission
* Policy feature name.
*
* @example
* ```ts
* const allow = buildMcpAppsAllowAttribute(uiResource._meta?.ui?.permissions);
* iframe.setAttribute("allow", allow);
* ```
*/
export function buildMcpAppsAllowAttribute(permissions: McpAppsPermissionsInput | undefined): string {
if (!permissions) return "";
const features: string[] = [];
if (permissions.camera) features.push("camera");
if (permissions.microphone) features.push("microphone");
if (permissions.geolocation) features.push("geolocation");
if (permissions.clipboardWrite) features.push("clipboard-write");
return features.join("; ");
}
19 changes: 19 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1406,6 +1406,24 @@ export interface SessionConfig {
*/
onElicitationRequest?: ElicitationHandler;

/**
* Enable MCP Apps (SEP-1865) UI passthrough on this session.
*
* When `true`, the runtime adds the `mcp-apps` capability to the session,
* which causes it to advertise the `extensions.io.modelcontextprotocol/ui`
* extension to MCP servers (so they expose `_meta.ui.resourceUri` on tools)
* and to expose the `session.rpc.mcp.apps.{listTools,callTool,readResource,
* setHostContext,getHostContext}` JSON-RPC methods.
*
* SDK consumers MUST set this to `true` only when they have an iframe
* renderer that can display `ui://` MCP App bundles. Setting it without a
* renderer will cause MCP servers to register UI-enabled tool variants
* the consumer cannot display.
*
* @default false
*/
enableMcpApps?: boolean;
Comment thread
mattdholloway marked this conversation as resolved.

/**
* Handler for exit-plan-mode requests from the agent.
* When provided, enables `exitPlanMode.request` callbacks.
Expand Down Expand Up @@ -1563,6 +1581,7 @@ export type ResumeSessionConfig = Pick<
| "onPermissionRequest"
| "onUserInputRequest"
| "onElicitationRequest"
| "enableMcpApps"
| "onExitPlanMode"
| "onAutoModeSwitch"
| "hooks"
Expand Down
99 changes: 99 additions & 0 deletions nodejs/test/mcpAppsSandbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { buildMcpAppsAllowAttribute, buildMcpAppsCspHeader } from "../src/mcpAppsSandbox.js";

/**
* SEP-1865 §UI Resource Format → "Restrictive Default" and §Security
* Implications → "CSP Construction" pin the exact CSP shapes a host MUST emit.
* These tests pin the spec text to the helper output so any regression is
* caught against the pinned spec lines, not against an implementation detail.
*/
describe("buildMcpAppsCspHeader", () => {
it("returns the restrictive default when csp is undefined (spec §UI Resource Format)", () => {
const header = buildMcpAppsCspHeader(undefined);
// Restrictive default MUST set connect-src 'none' (no external network).
expect(header).toContain("default-src 'none'");
expect(header).toContain("script-src 'self' 'unsafe-inline'");
expect(header).toContain("style-src 'self' 'unsafe-inline'");
expect(header).toContain("img-src 'self' data:");
expect(header).toContain("media-src 'self' data:");
expect(header).toContain("connect-src 'none'");
expect(header).toContain("frame-src 'none'");
expect(header).toContain("object-src 'none'");
expect(header).toContain("base-uri 'self'");
});

it("uses connect-src 'self' (not 'none') when csp is declared with empty arrays", () => {
// Per spec §Security Implications, a present `csp` block — even with
// empty arrays — switches to constructed defaults: connect-src 'self'.
const header = buildMcpAppsCspHeader({});
expect(header).toContain("connect-src 'self'");
expect(header).not.toContain("connect-src 'none'");
});

it("appends declared connectDomains to connect-src", () => {
const header = buildMcpAppsCspHeader({
connectDomains: ["https://api.weather.com", "wss://realtime.service.com"],
});
expect(header).toContain("connect-src 'self' https://api.weather.com wss://realtime.service.com");
});

it("appends resourceDomains to script-src, style-src, img-src, font-src, media-src", () => {
const header = buildMcpAppsCspHeader({
resourceDomains: ["https://cdn.jsdelivr.net"],
});
expect(header).toContain("script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net");
expect(header).toContain("style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net");
expect(header).toContain("img-src 'self' data: https://cdn.jsdelivr.net");
expect(header).toContain("font-src 'self' https://cdn.jsdelivr.net");
expect(header).toContain("media-src 'self' data: https://cdn.jsdelivr.net");
});

it("uses declared frameDomains when provided, 'none' otherwise", () => {
expect(buildMcpAppsCspHeader({})).toContain("frame-src 'none'");
const header = buildMcpAppsCspHeader({
frameDomains: ["https://www.youtube.com", "https://player.vimeo.com"],
});
expect(header).toContain("frame-src https://www.youtube.com https://player.vimeo.com");
expect(header).not.toContain("frame-src 'none'");
});

it("uses declared baseUriDomains when provided, 'self' otherwise", () => {
expect(buildMcpAppsCspHeader({})).toContain("base-uri 'self'");
const header = buildMcpAppsCspHeader({ baseUriDomains: ["https://cdn.example.com"] });
expect(header).toContain("base-uri https://cdn.example.com");
expect(header).not.toContain("base-uri 'self'");
});

it("always includes object-src 'none' (host MUST block plugins)", () => {
expect(buildMcpAppsCspHeader(undefined)).toContain("object-src 'none'");
expect(buildMcpAppsCspHeader({})).toContain("object-src 'none'");
expect(buildMcpAppsCspHeader({ resourceDomains: ["x"] })).toContain("object-src 'none'");
});
});

describe("buildMcpAppsAllowAttribute", () => {
it("returns empty string when permissions is undefined", () => {
expect(buildMcpAppsAllowAttribute(undefined)).toBe("");
});

it("returns empty string when no features are requested", () => {
expect(buildMcpAppsAllowAttribute({})).toBe("");
});

it("maps each requested feature to its Permission Policy name", () => {
expect(buildMcpAppsAllowAttribute({ camera: {} })).toBe("camera");
expect(buildMcpAppsAllowAttribute({ microphone: {} })).toBe("microphone");
expect(buildMcpAppsAllowAttribute({ geolocation: {} })).toBe("geolocation");
// The hyphenated form per Permission Policy spec.
expect(buildMcpAppsAllowAttribute({ clipboardWrite: {} })).toBe("clipboard-write");
});

it("joins multiple features with '; '", () => {
const allow = buildMcpAppsAllowAttribute({
camera: {},
microphone: {},
clipboardWrite: {},
});
expect(allow).toBe("camera; microphone; clipboard-write");
});
});
Loading