From 0827b5af123e6cc9e3cb21755d1fbe0e18ebdb4d Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 19 May 2026 17:51:42 +0100 Subject: [PATCH 01/15] feat: add MCP Apps (SEP-1865) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- nodejs/src/client.ts | 2 + nodejs/src/index.ts | 6 ++ nodejs/src/mcpAppsSandbox.ts | 120 +++++++++++++++++++++++++++++ nodejs/src/types.ts | 19 +++++ nodejs/test/mcpAppsSandbox.test.ts | 99 ++++++++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 nodejs/src/mcpAppsSandbox.ts create mode 100644 nodejs/test/mcpAppsSandbox.test.ts diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 42d838ad2..b71c3dcde 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -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)), @@ -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)), diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index ced1a4352..424c0557f 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -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, diff --git a/nodejs/src/mcpAppsSandbox.ts b/nodejs/src/mcpAppsSandbox.ts new file mode 100644 index 000000000..e6cce268f --- /dev/null +++ b/nodejs/src/mcpAppsSandbox.ts @@ -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; + /** Maps to Permission Policy `microphone` feature. */ + microphone?: Record; + /** Maps to Permission Policy `geolocation` feature. */ + geolocation?: Record; + /** Maps to Permission Policy `clipboard-write` feature. */ + clipboardWrite?: Record; +} + +/** 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("; "); +} diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index f18e18ac1..fa60d9cff 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -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; + /** * Handler for exit-plan-mode requests from the agent. * When provided, enables `exitPlanMode.request` callbacks. @@ -1563,6 +1581,7 @@ export type ResumeSessionConfig = Pick< | "onPermissionRequest" | "onUserInputRequest" | "onElicitationRequest" + | "enableMcpApps" | "onExitPlanMode" | "onAutoModeSwitch" | "hooks" diff --git a/nodejs/test/mcpAppsSandbox.test.ts b/nodejs/test/mcpAppsSandbox.test.ts new file mode 100644 index 000000000..0c887f99e --- /dev/null +++ b/nodejs/test/mcpAppsSandbox.test.ts @@ -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"); + }); +}); From f2514694ccd3370ca386f489b6cf8131f595b710 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 20 May 2026 11:05:22 +0100 Subject: [PATCH 02/15] feat: add MCP Apps option to Python, Go, .NET, Rust SDKs Mirror nodejs enableMcpApps across the other four SDKs so hosts using them can opt into MCP Apps (SEP-1865) UI passthrough by sending requestMcpApps on session.create / session.resume. - python: enable_mcp_apps kwarg on create_session / resume_session - go: EnableMcpApps field on SessionConfig / ResumeSessionConfig - dotnet: EnableMcpApps property on SessionConfig / ResumeSessionConfig - rust: request_mcp_apps field + with_request_mcp_apps builder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 4 ++++ dotnet/src/Types.cs | 25 ++++++++++++++++++++++++ go/client.go | 6 ++++++ go/types.go | 18 ++++++++++++++++++ python/copilot/client.py | 4 ++++ rust/src/types.rs | 41 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 98 insertions(+) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 3522ad60b..140cbd693 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -634,6 +634,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.InfiniteSessions, Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description)).ToList(), RequestElicitation: config.OnElicitationRequest != null, + RequestMcpApps: config.EnableMcpApps ? true : null, Traceparent: traceparent, Tracestate: tracestate, ModelCapabilities: config.ModelCapabilities, @@ -793,6 +794,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.InfiniteSessions, Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description)).ToList(), RequestElicitation: config.OnElicitationRequest != null, + RequestMcpApps: config.EnableMcpApps ? true : null, Traceparent: traceparent, Tracestate: tracestate, ModelCapabilities: config.ModelCapabilities, @@ -2025,6 +2027,7 @@ internal record CreateSessionRequest( InfiniteSessionConfig? InfiniteSessions, IList? Commands = null, bool? RequestElicitation = null, + bool? RequestMcpApps = null, string? Traceparent = null, string? Tracestate = null, ModelCapabilitiesOverride? ModelCapabilities = null, @@ -2087,6 +2090,7 @@ internal record ResumeSessionRequest( InfiniteSessionConfig? InfiniteSessions, IList? Commands = null, bool? RequestElicitation = null, + bool? RequestMcpApps = null, string? Traceparent = null, string? Tracestate = null, ModelCapabilitiesOverride? ModelCapabilities = null, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 42747bcb1..1995bca51 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2058,6 +2058,7 @@ protected SessionConfig(SessionConfig? other) Agent = other.Agent; DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null; EnableConfigDiscovery = other.EnableConfigDiscovery; + EnableMcpApps = other.EnableMcpApps; ExcludedTools = other.ExcludedTools is not null ? [.. other.ExcludedTools] : null; Hooks = other.Hooks; InfiniteSessions = other.InfiniteSessions; @@ -2210,6 +2211,23 @@ protected SessionConfig(SessionConfig? other) /// public AutoModeSwitchHandler? OnAutoModeSwitch { get; set; } + /// + /// 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. + /// + /// + public bool EnableMcpApps { get; set; } + /// /// Hook handlers for session lifecycle events. /// @@ -2370,6 +2388,7 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) EnableConfigDiscovery = other.EnableConfigDiscovery; ContinuePendingWork = other.ContinuePendingWork; ExcludedTools = other.ExcludedTools is not null ? [.. other.ExcludedTools] : null; + EnableMcpApps = other.EnableMcpApps; Hooks = other.Hooks; InfiniteSessions = other.InfiniteSessions; McpServers = other.McpServers is not null @@ -2500,6 +2519,12 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// public AutoModeSwitchHandler? OnAutoModeSwitch { get; set; } + /// + /// Enable MCP Apps (SEP-1865) UI passthrough on the resumed session. + /// See . + /// + public bool EnableMcpApps { get; set; } + /// /// Hook handlers for session lifecycle events. /// diff --git a/go/client.go b/go/client.go index 392ccd595..33baf694e 100644 --- a/go/client.go +++ b/go/client.go @@ -663,6 +663,9 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.OnAutoModeSwitch != nil { req.RequestAutoModeSwitch = Bool(true) } + if config.EnableMcpApps { + req.RequestMcpApps = Bool(true) + } if config.Streaming { req.Streaming = Bool(true) @@ -866,6 +869,9 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.OnAutoModeSwitch != nil { req.RequestAutoModeSwitch = Bool(true) } + if config.EnableMcpApps { + req.RequestMcpApps = Bool(true) + } traceparent, tracestate := getTraceContext(ctx) req.Traceparent = traceparent diff --git a/go/types.go b/go/types.go index cc30ff9d7..416a6063a 100644 --- a/go/types.go +++ b/go/types.go @@ -688,6 +688,19 @@ type SessionConfig struct { // OnAutoModeSwitch is a handler for auto-mode-switch requests from the server. // When provided, enables autoModeSwitch.request callbacks for the session. OnAutoModeSwitch AutoModeSwitchHandler + // EnableMcpApps enables 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. + EnableMcpApps bool // GitHubToken is an optional per-session GitHub token used for authentication. // When provided, the session authenticates as the token's owner instead of // using the global client-level auth. @@ -947,6 +960,9 @@ type ResumeSessionConfig struct { // OnAutoModeSwitch is a handler for auto-mode-switch requests from the server. // See SessionConfig.OnAutoModeSwitch. OnAutoModeSwitch AutoModeSwitchHandler + // EnableMcpApps enables MCP Apps (SEP-1865) UI passthrough on resume. + // See SessionConfig.EnableMcpApps. + EnableMcpApps bool } type ProviderConfig struct { // Type is the provider type: "openai", "azure", or "anthropic". Defaults to "openai". @@ -1168,6 +1184,7 @@ type createSessionRequest struct { InfiniteSessions *InfiniteSessionConfig `json:"infiniteSessions,omitempty"` Commands []wireCommand `json:"commands,omitempty"` RequestElicitation *bool `json:"requestElicitation,omitempty"` + RequestMcpApps *bool `json:"requestMcpApps,omitempty"` GitHubToken string `json:"gitHubToken,omitempty"` RemoteSession rpc.RemoteSessionMode `json:"remoteSession,omitempty"` Cloud *CloudSessionOptions `json:"cloud,omitempty"` @@ -1224,6 +1241,7 @@ type resumeSessionRequest struct { InfiniteSessions *InfiniteSessionConfig `json:"infiniteSessions,omitempty"` Commands []wireCommand `json:"commands,omitempty"` RequestElicitation *bool `json:"requestElicitation,omitempty"` + RequestMcpApps *bool `json:"requestMcpApps,omitempty"` GitHubToken string `json:"gitHubToken,omitempty"` RemoteSession rpc.RemoteSessionMode `json:"remoteSession,omitempty"` Traceparent string `json:"traceparent,omitempty"` diff --git a/python/copilot/client.py b/python/copilot/client.py index 16cef6dde..b74d9c834 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1354,6 +1354,7 @@ async def create_session( on_elicitation_request: ElicitationHandler | None = None, on_exit_plan_mode: ExitPlanModeHandler | None = None, on_auto_mode_switch: AutoModeSwitchHandler | None = None, + enable_mcp_apps: bool = False, create_session_fs_handler: CreateSessionFsHandler | None = None, github_token: str | None = None, remote_session: RemoteSessionMode | None = None, @@ -1497,6 +1498,7 @@ async def create_session( payload["requestElicitation"] = bool(on_elicitation_request) payload["requestExitPlanMode"] = bool(on_exit_plan_mode) payload["requestAutoModeSwitch"] = bool(on_auto_mode_switch) + payload["requestMcpApps"] = bool(enable_mcp_apps) # Serialize commands (name + description only) into payload if commands: @@ -1725,6 +1727,7 @@ async def resume_session( on_elicitation_request: ElicitationHandler | None = None, on_exit_plan_mode: ExitPlanModeHandler | None = None, on_auto_mode_switch: AutoModeSwitchHandler | None = None, + enable_mcp_apps: bool = False, create_session_fs_handler: CreateSessionFsHandler | None = None, github_token: str | None = None, remote_session: RemoteSessionMode | None = None, @@ -1883,6 +1886,7 @@ async def resume_session( payload["requestElicitation"] = bool(on_elicitation_request) payload["requestExitPlanMode"] = bool(on_exit_plan_mode) payload["requestAutoModeSwitch"] = bool(on_auto_mode_switch) + payload["requestMcpApps"] = bool(enable_mcp_apps) # Serialize commands (name + description only) into payload if commands: diff --git a/rust/src/types.rs b/rust/src/types.rs index 0f242445e..4cafdeaff 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1078,6 +1078,23 @@ pub struct SessionConfig { /// Defaults to `Some(true)` via [`SessionConfig::default`]. #[serde(skip_serializing_if = "Option::is_none")] pub request_elicitation: Option, + /// Enable MCP Apps (SEP-1865) UI passthrough on this session. + /// + /// When `Some(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 `Some(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. + /// + /// Defaults to `None` (disabled). + #[serde(skip_serializing_if = "Option::is_none")] + pub request_mcp_apps: Option, /// Skill directory paths passed through to the GitHub Copilot CLI. #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, @@ -1208,6 +1225,7 @@ impl std::fmt::Debug for SessionConfig { .field("request_exit_plan_mode", &self.request_exit_plan_mode) .field("request_auto_mode_switch", &self.request_auto_mode_switch) .field("request_elicitation", &self.request_elicitation) + .field("request_mcp_apps", &self.request_mcp_apps) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) .field("disabled_skills", &self.disabled_skills) @@ -1271,6 +1289,7 @@ impl Default for SessionConfig { request_exit_plan_mode: Some(true), request_auto_mode_switch: Some(true), request_elicitation: Some(true), + request_mcp_apps: None, skill_directories: None, instruction_directories: None, disabled_skills: None, @@ -1491,6 +1510,13 @@ impl SessionConfig { self } + /// Enable MCP Apps (SEP-1865) UI passthrough on this session. Defaults + /// to `None` (disabled). See [`SessionConfig::request_mcp_apps`]. + pub fn with_request_mcp_apps(mut self, enable: bool) -> Self { + self.request_mcp_apps = Some(enable); + self + } + /// Set skill directory paths passed through to the CLI. pub fn with_skill_directories(mut self, paths: I) -> Self where @@ -1680,6 +1706,10 @@ pub struct ResumeSessionConfig { /// Advertise elicitation provider capability on resume. #[serde(skip_serializing_if = "Option::is_none")] pub request_elicitation: Option, + /// Enable MCP Apps (SEP-1865) UI passthrough on resume. See + /// [`SessionConfig::request_mcp_apps`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_mcp_apps: Option, /// Skill directory paths passed through to the GitHub Copilot CLI on resume. #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, @@ -1790,6 +1820,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("request_exit_plan_mode", &self.request_exit_plan_mode) .field("request_auto_mode_switch", &self.request_auto_mode_switch) .field("request_elicitation", &self.request_elicitation) + .field("request_mcp_apps", &self.request_mcp_apps) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) .field("disabled_skills", &self.disabled_skills) @@ -1852,6 +1883,7 @@ impl ResumeSessionConfig { request_exit_plan_mode: Some(true), request_auto_mode_switch: Some(true), request_elicitation: Some(true), + request_mcp_apps: None, skill_directories: None, instruction_directories: None, disabled_skills: None, @@ -2043,6 +2075,13 @@ impl ResumeSessionConfig { self } + /// Enable MCP Apps (SEP-1865) UI passthrough on resume. Defaults to + /// `None` (disabled). See [`SessionConfig::request_mcp_apps`]. + pub fn with_request_mcp_apps(mut self, enable: bool) -> Self { + self.request_mcp_apps = Some(enable); + self + } + /// Set skill directory paths passed through to the CLI on resume. pub fn with_skill_directories(mut self, paths: I) -> Self where @@ -3366,6 +3405,7 @@ mod tests { assert_eq!(cfg.request_elicitation, Some(true)); assert_eq!(cfg.request_exit_plan_mode, Some(true)); assert_eq!(cfg.request_auto_mode_switch, Some(true)); + assert_eq!(cfg.request_mcp_apps, None); } #[test] @@ -3376,6 +3416,7 @@ mod tests { assert_eq!(cfg.request_elicitation, Some(true)); assert_eq!(cfg.request_exit_plan_mode, Some(true)); assert_eq!(cfg.request_auto_mode_switch, Some(true)); + assert_eq!(cfg.request_mcp_apps, None); } #[test] From 7c621384a3b5d2cc4036b3a42a59ea0b37556911 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 20 May 2026 11:09:09 +0100 Subject: [PATCH 03/15] chore: prettier format mcpAppsSandbox files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/mcpAppsSandbox.ts | 4 +++- nodejs/test/mcpAppsSandbox.test.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/nodejs/src/mcpAppsSandbox.ts b/nodejs/src/mcpAppsSandbox.ts index e6cce268f..2ccc7ea49 100644 --- a/nodejs/src/mcpAppsSandbox.ts +++ b/nodejs/src/mcpAppsSandbox.ts @@ -109,7 +109,9 @@ export function buildMcpAppsCspHeader(csp: McpAppsCspInput | undefined): string * iframe.setAttribute("allow", allow); * ``` */ -export function buildMcpAppsAllowAttribute(permissions: McpAppsPermissionsInput | undefined): string { +export function buildMcpAppsAllowAttribute( + permissions: McpAppsPermissionsInput | undefined +): string { if (!permissions) return ""; const features: string[] = []; if (permissions.camera) features.push("camera"); diff --git a/nodejs/test/mcpAppsSandbox.test.ts b/nodejs/test/mcpAppsSandbox.test.ts index 0c887f99e..f9511db60 100644 --- a/nodejs/test/mcpAppsSandbox.test.ts +++ b/nodejs/test/mcpAppsSandbox.test.ts @@ -34,7 +34,9 @@ describe("buildMcpAppsCspHeader", () => { 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"); + 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", () => { From b301fe48fffca1988e63dcca0b401729a5e605b3 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 20 May 2026 14:55:25 +0100 Subject: [PATCH 04/15] fix: sanitize CSP domain inputs in mcpAppsSandbox (SEP-1865) Port the CSP directive injection defense from copilot-agent-runtime PR #7605 into the SDK. Without sanitization, an MCP server returning `frameDomains: ['evil.com; form-action *']` could break out of one CSP directive and inject sibling directives (CSP first-occurrence rule then lets an earlier injected `script-src *` win). Each server-supplied entry is now: - rejected if it contains CSP metacharacters ([;,\\s'"\\\\]) - accepted verbatim for the bare-scheme allowlist (data:, blob:, mediastream:, filesystem:) - otherwise parsed via URL and canonicalized to its origin; opaque origins (where `URL.origin` is the literal string 'null') are dropped Adds 10 sanitization tests mirroring runtime PR coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/mcpAppsSandbox.ts | 79 +++++++++++++++++++- nodejs/test/mcpAppsSandbox.test.ts | 114 +++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 4 deletions(-) diff --git a/nodejs/src/mcpAppsSandbox.ts b/nodejs/src/mcpAppsSandbox.ts index 2ccc7ea49..ee10c3a0c 100644 --- a/nodejs/src/mcpAppsSandbox.ts +++ b/nodejs/src/mcpAppsSandbox.ts @@ -44,6 +44,68 @@ export interface McpAppsPermissionsInput { clipboardWrite?: Record; } +/** + * Well-known CSP scheme sources that are accepted as bare-scheme entries in + * server-supplied domain lists (e.g. `data:`, `blob:`). All other entries must + * parse via `URL` and yield a non-opaque origin. + */ +const CSP_SCHEME_SOURCES = new Set(["data:", "blob:", "mediastream:", "filesystem:"]); + +/** + * Strict matcher for the bare-scheme sources above. We require an exact match + * (no prefix shenanigans) since these strings are interpolated verbatim into + * the CSP header. + */ +function isBareSchemeSource(d: string): boolean { + return CSP_SCHEME_SOURCES.has(d); +} + +/** + * Sanitize a single server-supplied CSP domain entry. + * + * MCP servers populate `_meta.ui.csp.{resource,connect,frame,baseUri}Domains` + * with arbitrary strings. CSP directives are `;`-separated and source lists are + * whitespace-delimited, so an unsanitized entry like + * `evil.com; form-action *` can break out of one directive and inject new + * ones; CSP's first-occurrence rule then lets an injected `script-src *` + * placed earlier than the template win, weakening the sandbox this helper + * exists to provide. + * + * Returns the canonicalized origin (`scheme://host[:port]`) for valid URL + * entries, the entry itself for well-known bare-scheme sources, or + * `undefined` for anything containing CSP metacharacters or failing to parse + * as a URL with a non-opaque origin. + */ +function sanitizeCspDomain(domain: unknown): string | undefined { + if (typeof domain !== "string" || domain.length === 0) return undefined; + // Reject CSP metacharacters that could break out of the source list or + // inject sibling directives. This also rejects CSP keywords like 'self' + // and 'none' — those are owned by this helper's templates, not by + // server-supplied input. + if (/[;,\s'"\\]/.test(domain)) return undefined; + if (isBareSchemeSource(domain)) return domain; + try { + const url = new URL(domain); + // Reject opaque origins (e.g. `data:text/plain,foo` parses but its + // origin is the literal string "null"); we only allow opaque schemes + // via the bare-scheme allowlist above. + if (url.origin && url.origin !== "null") return url.origin; + } catch { + // fall through + } + return undefined; +} + +function sanitizeCspDomainList(domains: string[] | undefined): string[] { + if (!domains?.length) return []; + const out: string[] = []; + for (const d of domains) { + const safe = sanitizeCspDomain(d); + if (safe !== undefined) out.push(safe); + } + return out; +} + /** Spec-mandated restrictive default applied when `_meta.ui.csp` is entirely absent. */ const RESTRICTIVE_DEFAULT_CSP = "default-src 'none'; " + @@ -67,6 +129,10 @@ const RESTRICTIVE_DEFAULT_CSP = * The host MAY further restrict the returned policy but MUST NOT add * undeclared domains (spec §UI Resource Format → "No Loosening"). * + * Every server-supplied domain entry is sanitized via {@link sanitizeCspDomain} + * before interpolation, defending against CSP directive injection from + * malicious or sloppy MCP servers. + * * @example * ```ts * const meta = uiResource._meta?.ui; @@ -77,10 +143,15 @@ 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'"; + // Sanitize every server-supplied domain entry before interpolation. Entries + // that contain CSP metacharacters or fail to parse as a URL with a + // non-opaque origin are dropped. See `sanitizeCspDomain` above. + const resourceDomains = sanitizeCspDomainList(csp.resourceDomains).join(" "); + const connectDomains = sanitizeCspDomainList(csp.connectDomains).join(" "); + const safeFrameDomains = sanitizeCspDomainList(csp.frameDomains); + const safeBaseUriDomains = sanitizeCspDomainList(csp.baseUriDomains); + const frameDomains = safeFrameDomains.length ? safeFrameDomains.join(" ") : "'none'"; + const baseUriDomains = safeBaseUriDomains.length ? safeBaseUriDomains.join(" ") : "'self'"; const trail = (extra: string) => (extra ? ` ${extra}` : ""); return [ "default-src 'none'", diff --git a/nodejs/test/mcpAppsSandbox.test.ts b/nodejs/test/mcpAppsSandbox.test.ts index f9511db60..dcd6494d7 100644 --- a/nodejs/test/mcpAppsSandbox.test.ts +++ b/nodejs/test/mcpAppsSandbox.test.ts @@ -71,6 +71,120 @@ describe("buildMcpAppsCspHeader", () => { expect(buildMcpAppsCspHeader({})).toContain("object-src 'none'"); expect(buildMcpAppsCspHeader({ resourceDomains: ["x"] })).toContain("object-src 'none'"); }); + + // ------------------------------------------------------------------ + // Domain-input sanitization (defends against CSP directive injection + // from malicious or sloppy MCP servers — see review feedback). + // ------------------------------------------------------------------ + + it("drops entries containing CSP metacharacters that would inject a sibling directive", () => { + const header = buildMcpAppsCspHeader({ + frameDomains: ["evil.com; form-action *"], + }); + // The literal injected substring MUST NOT appear in the emitted header. + expect(header).not.toContain("form-action"); + expect(header).not.toContain(";; "); + expect(header).not.toContain("evil.com; form-action"); + // With no surviving frameDomains, the directive falls back to 'none'. + expect(header).toContain("frame-src 'none'"); + }); + + it("drops entries containing whitespace, commas, quotes, or backslashes", () => { + const header = buildMcpAppsCspHeader({ + resourceDomains: [ + "https://ok.example", + "https://has space.example", + "https://has,comma.example", + 'https://has"quote.example', + "https://has\\backslash.example", + "'self'", + ], + }); + expect(header).toContain("https://ok.example"); + expect(header).not.toContain("has space"); + expect(header).not.toContain("has,comma"); + expect(header).not.toContain('has"quote'); + expect(header).not.toContain("has\\backslash"); + // Server-supplied CSP keywords are dropped — keywords are owned by the + // helper's hardcoded template, not by remote input. + expect(header).not.toMatch(/script-src 'self' 'unsafe-inline' 'self'/); + }); + + it("canonicalizes URL entries to their origin (strips path, query, fragment)", () => { + const header = buildMcpAppsCspHeader({ + connectDomains: ["https://api.example.com/some/path?x=1#frag"], + }); + expect(header).toContain("connect-src 'self' https://api.example.com"); + expect(header).not.toContain("/some/path"); + expect(header).not.toContain("?x=1"); + expect(header).not.toContain("#frag"); + }); + + it("accepts well-known bare-scheme sources (data:, blob:, mediastream:, filesystem:)", () => { + const header = buildMcpAppsCspHeader({ + resourceDomains: ["data:", "blob:", "mediastream:", "filesystem:"], + }); + expect(header).toContain( + "script-src 'self' 'unsafe-inline' data: blob: mediastream: filesystem:" + ); + }); + + it("drops opaque-origin URLs that parse but have no real origin", () => { + const header = buildMcpAppsCspHeader({ + resourceDomains: ["data:text/plain,injected", "javascript:alert(1)"], + }); + // Opaque schemes are only allowed via the bare-scheme allowlist; the + // data:-with-payload form parses but `URL.origin` is the literal + // string "null", so it MUST be dropped. + expect(header).not.toContain("data:text/plain"); + expect(header).not.toContain("javascript:"); + expect(header).not.toContain("alert(1)"); + }); + + it("drops unparseable garbage entries", () => { + const header = buildMcpAppsCspHeader({ + connectDomains: ["not-a-url", "://no-scheme", "https://valid.example"], + }); + expect(header).toContain("connect-src 'self' https://valid.example"); + expect(header).not.toContain("not-a-url"); + expect(header).not.toContain("://no-scheme"); + }); + + it("filters mixed valid/invalid entries, keeping only the safe ones", () => { + const header = buildMcpAppsCspHeader({ + connectDomains: [ + "https://api.example.com", + "evil.com; script-src *", + "wss://realtime.example", + ], + }); + expect(header).toContain( + "connect-src 'self' https://api.example.com wss://realtime.example" + ); + expect(header).not.toContain("evil.com"); + expect(header).not.toContain("script-src *"); + // The directive boundary count MUST remain stable — no injected ';'. + const directives = header.split(";").map((d) => d.trim()); + expect(directives).toContain( + "connect-src 'self' https://api.example.com wss://realtime.example" + ); + }); + + it("treats a frameDomains list of only invalid entries as if it were empty (falls back to 'none')", () => { + const header = buildMcpAppsCspHeader({ + frameDomains: ["evil; x", "also evil"], + }); + expect(header).toContain("frame-src 'none'"); + expect(header).not.toContain("evil"); + }); + + it("treats a baseUriDomains list of only invalid entries as if it were empty (falls back to 'self')", () => { + const header = buildMcpAppsCspHeader({ + baseUriDomains: ["bad; injected"], + }); + expect(header).toContain("base-uri 'self'"); + expect(header).not.toContain("injected"); + }); }); describe("buildMcpAppsAllowAttribute", () => { From 8f8b8cfd5e8022a3a33894514deb75f0092e2050 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 20 May 2026 14:56:14 +0100 Subject: [PATCH 05/15] docs: note runtime MCP_APPS gate on enableMcpApps across SDKs Reflect the runtime-side gate added in copilot-agent-runtime PR #7605: requestMcpApps is now honored server-side only when the MCP_APPS feature flag or COPILOT_MCP_APPS=true env override is set; otherwise the opt-in is silently dropped (the runtime logs a warning, but the SDK consumer sees nothing). Update the JSDoc / docstrings on Node, Go, .NET, and Rust to document this and to point at capabilities.ui.mcpApps on the create/resume response as the way to detect the silent drop. Also adds the diagnose method to the enumerated mcp.apps.* RPCs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Types.cs | 15 +++++++++++---- go/types.go | 17 ++++++++++++----- nodejs/src/types.ts | 15 +++++++++++---- rust/src/types.rs | 12 ++++++++++-- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index a4dfac946..c97eef300 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2221,13 +2221,20 @@ protected SessionConfig(SessionConfig? other) /// /// 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} + /// When true and the runtime has MCP Apps enabled (via the + /// MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override), 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,diagnose} /// JSON-RPC methods. /// /// + /// If the runtime gate is off, the opt-in is silently dropped server-side (the runtime logs a + /// warning); the session is created normally but the MCP Apps surface is unavailable. Inspect + /// the runtime's capabilities.ui.mcpApps on the create/resume response to detect this. + /// + /// /// 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. diff --git a/go/types.go b/go/types.go index 289414f4b..663e20558 100644 --- a/go/types.go +++ b/go/types.go @@ -698,11 +698,18 @@ type SessionConfig struct { OnAutoModeSwitch AutoModeSwitchHandler // EnableMcpApps enables 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. + // When true AND the runtime has MCP Apps enabled (via the MCP_APPS feature + // flag or COPILOT_MCP_APPS=true environment override), 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,diagnose} JSON-RPC methods. + // + // If the runtime gate is off, the opt-in is silently dropped server-side + // (the runtime logs a warning); the session is created normally but the + // MCP Apps surface is unavailable. Inspect the runtime's + // capabilities.ui.mcpApps on the create/resume response to detect this. // // 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 diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 4b1c45b8e..71e34a3ab 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1412,11 +1412,18 @@ export interface SessionConfig { /** * Enable MCP Apps (SEP-1865) UI passthrough on this session. * - * When `true`, the runtime adds the `mcp-apps` capability to the session, + * When `true` **and** the runtime has MCP Apps enabled (via the + * `MCP_APPS` feature flag or `COPILOT_MCP_APPS=true` environment + * override), 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. + * 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,diagnose}` JSON-RPC methods. + * + * If the runtime gate is off, the opt-in is silently dropped server-side + * (the runtime logs a warning); the session is created normally but the + * MCP Apps surface is unavailable. Inspect the runtime's + * `capabilities.ui.mcpApps` on the create/resume response to detect this. * * 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 diff --git a/rust/src/types.rs b/rust/src/types.rs index 937b75850..8e39e30f6 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1081,12 +1081,20 @@ pub struct SessionConfig { pub request_elicitation: Option, /// Enable MCP Apps (SEP-1865) UI passthrough on this session. /// - /// When `Some(true)`, the runtime adds the `mcp-apps` capability to the + /// When `Some(true)` **and** the runtime has MCP Apps enabled (via the + /// `MCP_APPS` feature flag or `COPILOT_MCP_APPS=true` environment + /// override), 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. + /// getHostContext,diagnose}` JSON-RPC methods. + /// + /// If the runtime gate is off, the opt-in is silently dropped + /// server-side (the runtime logs a warning); the session is created + /// normally but the MCP Apps surface is unavailable. Inspect the + /// runtime's `capabilities.ui.mcpApps` on the create/resume response to + /// detect this. /// /// SDK consumers MUST set this to `Some(true)` only when they have an /// iframe renderer that can display `ui://` MCP App bundles. Setting it From 53c1a2b6823629e43d35b23ee96c393ba2edd21c Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 20 May 2026 16:30:59 +0100 Subject: [PATCH 06/15] feat: surface capabilities.ui.mcpApps and warn on silent drop Expose the runtime's response capability so consumers can detect when their enableMcpApps opt-in was silently dropped by the runtime gate (MCP_APPS feature flag / COPILOT_MCP_APPS env override unset). For each SDK: - Add mcpApps?: bool to the SessionUiCapabilities type - After session.create / session.resume, if the consumer requested the opt-in but capabilities.ui.mcpApps is not true on the response, log a warning (console.warn / logger.warning / slog / tracing::warn / fmt.Fprintf(os.Stderr, ...)) so the silent drop is discoverable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 19 +++++++++++++++++++ dotnet/src/Types.cs | 10 ++++++++++ go/client.go | 21 +++++++++++++++++++++ go/types.go | 6 ++++++ nodejs/src/client.ts | 26 ++++++++++++++++++++++++-- nodejs/src/types.ts | 8 ++++++++ python/copilot/client.py | 28 ++++++++++++++++++++++++++++ python/copilot/session.py | 6 ++++++ rust/src/session.rs | 38 ++++++++++++++++++++++++++++++++++++++ rust/src/types.rs | 9 +++++++++ 10 files changed, 169 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index ba99af960..02b417c46 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -653,6 +653,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); + WarnIfMcpAppsDropped(config.EnableMcpApps, response.Capabilities, sessionId); } catch (Exception ex) { @@ -813,6 +814,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); + WarnIfMcpAppsDropped(config.EnableMcpApps, response.Capabilities, sessionId); } catch (Exception ex) { @@ -1758,6 +1760,23 @@ private void RemoveSession(string sessionId) _sessions.TryRemove(sessionId, out _); } + /// + /// Emit a warning log when the consumer set EnableMcpApps=true on create/resume + /// but the runtime did not advertise capabilities.ui.mcpApps in the response. + /// The runtime silently drops the opt-in when its MCP_APPS feature flag (or + /// COPILOT_MCP_APPS=true env override) is unset, so without this warning a + /// consumer trying to use MCP Apps would see no error -- just tools that never expose + /// _meta.ui.resourceUri. + /// + private void WarnIfMcpAppsDropped(bool requested, SessionCapabilities? capabilities, string sessionId) + { + if (!requested) return; + if (capabilities?.Ui?.McpApps == true) return; + _logger?.LogWarning( + "Session {SessionId}: EnableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. The runtime's MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is likely unset; the MCP Apps surface is unavailable for this session.", + sessionId); + } + /// /// Disposes the synchronously. /// diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 9dd50e24f..919bff44f 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1066,6 +1066,16 @@ public class SessionUiCapabilities /// Whether the host supports interactive elicitation dialogs. /// public bool? Elicitation { get; set; } + + /// + /// Whether the runtime has accepted the session's MCP Apps (SEP-1865) opt-in. + /// true when the consumer set + /// (or ) to true on + /// create/resume and the runtime's MCP_APPS feature flag (or + /// COPILOT_MCP_APPS=true env override) is on. Otherwise absent or + /// false, indicating the runtime silently dropped the opt-in. + /// + public bool? McpApps { get; set; } } // ============================================================================ diff --git a/go/client.go b/go/client.go index 8073560f0..5f33fbdcc 100644 --- a/go/client.go +++ b/go/client.go @@ -54,6 +54,25 @@ import ( const noResultPermissionV2Error = "permission handlers cannot return 'no-result' when connected to a protocol v2 server" +// warnIfMcpAppsDropped emits a stderr warning when the consumer set +// EnableMcpApps=true on create/resume but the runtime did not advertise +// capabilities.ui.mcpApps in the response. The runtime silently drops the +// opt-in when its MCP_APPS feature flag (or COPILOT_MCP_APPS=true env +// override) is unset, so without this warning a consumer trying to use MCP +// Apps would see no error -- just tools that never expose _meta.ui.resourceUri. +func warnIfMcpAppsDropped(requested bool, capabilities *SessionCapabilities, sessionID string) { + if !requested { + return + } + if capabilities != nil && capabilities.UI != nil && capabilities.UI.McpApps { + return + } + fmt.Fprintf(os.Stderr, + "[copilot-sdk] Session %s: EnableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. The runtime's MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is likely unset; the MCP Apps surface is unavailable for this session.\n", + sessionID, + ) +} + func validateSessionFsConfig(config *SessionFsConfig) error { if config == nil { return nil @@ -777,6 +796,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses session.workspacePath = response.WorkspacePath session.setCapabilities(response.Capabilities) + warnIfMcpAppsDropped(config.EnableMcpApps, response.Capabilities, sessionID) return session, nil } @@ -965,6 +985,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, session.workspacePath = response.WorkspacePath session.setCapabilities(response.Capabilities) + warnIfMcpAppsDropped(config.EnableMcpApps, response.Capabilities, sessionID) return session, nil } diff --git a/go/types.go b/go/types.go index f297db2bb..a51130be9 100644 --- a/go/types.go +++ b/go/types.go @@ -804,6 +804,12 @@ type SessionCapabilities struct { type UICapabilities struct { // Elicitation indicates whether the host supports interactive elicitation dialogs. Elicitation bool `json:"elicitation,omitempty"` + // McpApps indicates whether the runtime has accepted the session's MCP Apps + // (SEP-1865) opt-in. True when the consumer set EnableMcpApps=true on + // create/resume AND the runtime's MCP_APPS feature flag (or + // COPILOT_MCP_APPS=true env override) is on. Otherwise false, indicating + // the runtime silently dropped the opt-in. + McpApps bool `json:"mcpApps,omitempty"` } // ElicitationResult is the user's response to an elicitation dialog. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 61446aa6d..4614ffcb4 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -86,6 +86,26 @@ function toWireProviderConfig(provider: ProviderConfig): Record */ const MIN_PROTOCOL_VERSION = 2; +/** + * Emit a `console.warn` when the consumer set `enableMcpApps: true` on + * create/resume but the runtime did not advertise `capabilities.ui.mcpApps` + * in the response. The runtime silently drops the opt-in when its `MCP_APPS` + * feature flag (or `COPILOT_MCP_APPS=true` env override) is unset, so without + * this warning a consumer trying to use MCP Apps would see no error — just + * tools that never expose `_meta.ui.resourceUri`. + */ +function warnIfMcpAppsDropped( + requested: boolean | undefined, + capabilities: { ui?: { mcpApps?: boolean } } | undefined, + sessionId: string +): void { + if (requested && !capabilities?.ui?.mcpApps) { + console.warn( + `[copilot-sdk] Session ${sessionId}: enableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. The runtime's MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is likely unset; the MCP Apps surface is unavailable for this session.` + ); + } +} + /** * Check if value is a Zod schema (has toJSONSchema method) */ @@ -849,10 +869,11 @@ export class CopilotClient { const { workspacePath, capabilities } = response as { sessionId: string; workspacePath?: string; - capabilities?: { ui?: { elicitation?: boolean } }; + capabilities?: { ui?: { elicitation?: boolean; mcpApps?: boolean } }; }; session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); + warnIfMcpAppsDropped(config.enableMcpApps, capabilities, sessionId); } catch (e) { this.sessions.delete(sessionId); throw e; @@ -990,10 +1011,11 @@ export class CopilotClient { const { workspacePath, capabilities } = response as { sessionId: string; workspacePath?: string; - capabilities?: { ui?: { elicitation?: boolean } }; + capabilities?: { ui?: { elicitation?: boolean; mcpApps?: boolean } }; }; session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); + warnIfMcpAppsDropped(config.enableMcpApps, capabilities, sessionId); } catch (e) { this.sessions.delete(sessionId); throw e; diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 71e34a3ab..d7994638e 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -483,6 +483,14 @@ export interface SessionCapabilities { ui?: { /** Whether the host supports interactive elicitation dialogs. */ elicitation?: boolean; + /** + * Whether the runtime has accepted the session's MCP Apps (SEP-1865) + * opt-in. `true` when the consumer set `enableMcpApps: true` on + * create/resume **and** the runtime's `MCP_APPS` feature flag (or + * `COPILOT_MCP_APPS=true` env override) is on. Otherwise absent or + * `false`, indicating the runtime silently dropped the opt-in. + */ + mcpApps?: boolean; }; } diff --git a/python/copilot/client.py b/python/copilot/client.py index 29989ec54..a2c543280 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -75,6 +75,32 @@ logger = logging.getLogger(__name__) + +def _warn_if_mcp_apps_dropped( + requested: bool, + capabilities: dict | None, + session_id: str, +) -> None: + """Log a warning when ``enable_mcp_apps=True`` was requested but the runtime + did not advertise ``capabilities.ui.mcpApps`` in the response. + + The runtime silently drops the opt-in when its ``MCP_APPS`` feature flag + (or ``COPILOT_MCP_APPS=true`` env override) is unset, so without this + warning a consumer trying to use MCP Apps would see no error -- just tools + that never expose ``_meta.ui.resourceUri``. + """ + if not requested: + return + ui = (capabilities or {}).get("ui") or {} + if not ui.get("mcpApps"): + logger.warning( + "Session %s: enable_mcp_apps was requested but the runtime did " + "not advertise capabilities.ui.mcpApps. The runtime's MCP_APPS " + "feature flag or COPILOT_MCP_APPS=true environment override is " + "likely unset; the MCP Apps surface is unavailable for this session.", + session_id, + ) + # ============================================================================ # Connection Types # ============================================================================ @@ -1684,6 +1710,7 @@ async def create_session( session._workspace_path = response.get("workspacePath") capabilities = response.get("capabilities") session._set_capabilities(capabilities) + _warn_if_mcp_apps_dropped(enable_mcp_apps, capabilities, actual_session_id) except BaseException as exc: with self._sessions_lock: self._sessions.pop(actual_session_id, None) @@ -2039,6 +2066,7 @@ async def resume_session( session._workspace_path = response.get("workspacePath") capabilities = response.get("capabilities") session._set_capabilities(capabilities) + _warn_if_mcp_apps_dropped(enable_mcp_apps, capabilities, session_id) except BaseException as exc: with self._sessions_lock: self._sessions.pop(session_id, None) diff --git a/python/copilot/session.py b/python/copilot/session.py index 4789724fb..a10034b4b 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -373,6 +373,12 @@ class SessionUiCapabilities(TypedDict, total=False): elicitation: bool """Whether the host supports interactive elicitation dialogs.""" + mcpApps: bool + """Whether the runtime has accepted the session's MCP Apps (SEP-1865) opt-in. + ``True`` when the consumer set ``enable_mcp_apps=True`` on create/resume and + the runtime's ``MCP_APPS`` feature flag (or ``COPILOT_MCP_APPS=true`` env + override) is on. Otherwise absent or ``False``, indicating the runtime + silently dropped the opt-in.""" class SessionCapabilities(TypedDict, total=False): diff --git a/rust/src/session.rs b/rust/src/session.rs index d533dbc44..96c53c509 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -855,6 +855,11 @@ impl Client { })); } *capabilities.write() = create_result.capabilities.unwrap_or_default(); + warn_if_mcp_apps_dropped( + config.request_mcp_apps.unwrap_or(false), + &capabilities.read(), + &session_id, + ); tracing::debug!( elapsed_ms = total_start.elapsed().as_millis(), @@ -1020,6 +1025,11 @@ impl Client { } *capabilities.write() = resume_capabilities.unwrap_or_default(); + warn_if_mcp_apps_dropped( + config.request_mcp_apps.unwrap_or(false), + &capabilities.read(), + &session_id, + ); tracing::debug!( elapsed_ms = total_start.elapsed().as_millis(), @@ -1044,6 +1054,34 @@ impl Client { type CommandHandlerMap = HashMap>; +/// Emit a `tracing::warn!` when the consumer set `request_mcp_apps: Some(true)` +/// on create/resume but the runtime did not advertise `capabilities.ui.mcp_apps` +/// in the response. The runtime silently drops the opt-in when its `MCP_APPS` +/// feature flag (or `COPILOT_MCP_APPS=true` env override) is unset, so without +/// this warning a consumer trying to use MCP Apps would see no error — just +/// tools that never expose `_meta.ui.resourceUri`. +fn warn_if_mcp_apps_dropped( + requested: bool, + capabilities: &SessionCapabilities, + session_id: &SessionId, +) { + if !requested { + return; + } + let advertised = capabilities + .ui + .as_ref() + .and_then(|ui| ui.mcp_apps) + .unwrap_or(false); + if advertised { + return; + } + tracing::warn!( + session_id = %session_id, + "request_mcp_apps was set but the runtime did not advertise capabilities.ui.mcpApps; the MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is likely unset and the MCP Apps surface is unavailable for this session" + ); +} + fn build_command_handler_map(commands: Option<&[CommandDefinition]>) -> Arc { let map = match commands { Some(commands) => commands diff --git a/rust/src/types.rs b/rust/src/types.rs index d8ffe2592..8f29b8695 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -3138,6 +3138,15 @@ pub struct UiCapabilities { /// Whether the host supports interactive elicitation dialogs. #[serde(skip_serializing_if = "Option::is_none")] pub elicitation: Option, + /// Whether the runtime has accepted the session's MCP Apps (SEP-1865) + /// opt-in. `Some(true)` when the consumer set + /// [`SessionConfig::request_mcp_apps`] / [`ResumeSessionConfig::request_mcp_apps`] + /// to `Some(true)` on create/resume **and** the runtime's `MCP_APPS` + /// feature flag (or `COPILOT_MCP_APPS=true` env override) is on. Otherwise + /// absent or `Some(false)`, indicating the runtime silently dropped the + /// opt-in. + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_apps: Option, } /// Options for the [`SessionUi::input`](crate::session::SessionUi::input) convenience method. From e29ea92f5f5bc1e25ba6e18b9bbc8fc6bd877df2 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 20 May 2026 17:20:06 +0100 Subject: [PATCH 07/15] fix: ruff format + add mcp_apps field to Rust e2e UiCapabilities literal - python: ruff format reflowed the new _warn_if_mcp_apps_dropped helper - rust: tests/e2e/elicitation.rs constructs UiCapabilities as a struct literal; the new mcp_apps field made it non-exhaustive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/copilot/client.py | 1 + rust/tests/e2e/elicitation.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/python/copilot/client.py b/python/copilot/client.py index a2c543280..a2aa71bcf 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -101,6 +101,7 @@ def _warn_if_mcp_apps_dropped( session_id, ) + # ============================================================================ # Connection Types # ============================================================================ diff --git a/rust/tests/e2e/elicitation.rs b/rust/tests/e2e/elicitation.rs index 13b928bf7..ebcf2dec5 100644 --- a/rust/tests/e2e/elicitation.rs +++ b/rust/tests/e2e/elicitation.rs @@ -396,6 +396,7 @@ async fn session_capabilities_types_are_properly_structured() { let capabilities = github_copilot_sdk::SessionCapabilities { ui: Some(UiCapabilities { elicitation: Some(true), + mcp_apps: None, }), }; From af3ee8a091b63b264e749d1866141d216e586311 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 21 May 2026 11:44:07 +0100 Subject: [PATCH 08/15] fix: drop sessionId from MCP Apps warning to silence CodeQL clear-text-logging CodeQL flags any value flowing from process.env as sensitive via taint analysis (joinSession() reads process.env.SESSION_ID which propagates to resumeSession's sessionId argument). The session ID is a UUID and not actually sensitive, but the alert noise is not worth it -- the warning is per-call so the consumer already knows which session triggered it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 4614ffcb4..a90c6c80b 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -96,12 +96,11 @@ const MIN_PROTOCOL_VERSION = 2; */ function warnIfMcpAppsDropped( requested: boolean | undefined, - capabilities: { ui?: { mcpApps?: boolean } } | undefined, - sessionId: string + capabilities: { ui?: { mcpApps?: boolean } } | undefined ): void { if (requested && !capabilities?.ui?.mcpApps) { console.warn( - `[copilot-sdk] Session ${sessionId}: enableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. The runtime's MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is likely unset; the MCP Apps surface is unavailable for this session.` + "[copilot-sdk] enableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. The runtime's MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is likely unset; the MCP Apps surface is unavailable for this session." ); } } @@ -873,7 +872,7 @@ export class CopilotClient { }; session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); - warnIfMcpAppsDropped(config.enableMcpApps, capabilities, sessionId); + warnIfMcpAppsDropped(config.enableMcpApps, capabilities); } catch (e) { this.sessions.delete(sessionId); throw e; @@ -1015,7 +1014,7 @@ export class CopilotClient { }; session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); - warnIfMcpAppsDropped(config.enableMcpApps, capabilities, sessionId); + warnIfMcpAppsDropped(config.enableMcpApps, capabilities); } catch (e) { this.sessions.delete(sessionId); throw e; From c3e1524224e5c125506df34604b5294682fe9772 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 22 May 2026 10:34:55 +0100 Subject: [PATCH 09/15] feat: add enableMcpApps support to Java SDK Mirrors the MCP Apps (SEP-1865) opt-in already wired into Node, Python, Go, .NET, and Rust: - SessionConfig / ResumeSessionConfig: enableMcpApps field with isEnableMcpApps / setEnableMcpApps accessors and copy() inclusion - CreateSessionRequest / ResumeSessionRequest: requestMcpApps wire field with getter/setter/clearer (Boolean nullable, matches requestElicitation) - SessionUiCapabilities: mcpApps response field with getter/setter/clearer - SessionRequestBuilder: wires config.isEnableMcpApps() -> requestMcpApps on both create and resume paths - CopilotClient: warnIfMcpAppsDropped helper logs when the consumer requested the opt-in but the runtime did not advertise it back (runtime silently drops the opt-in when its MCP_APPS feature flag / COPILOT_MCP_APPS env override is unset) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- java/mvnw | 0 .../com/github/copilot/sdk/CopilotClient.java | 28 +++++++++++ .../copilot/sdk/SessionRequestBuilder.java | 6 +++ .../sdk/json/CreateSessionRequest.java | 18 ++++++++ .../copilot/sdk/json/ResumeSessionConfig.java | 27 +++++++++++ .../sdk/json/ResumeSessionRequest.java | 18 ++++++++ .../copilot/sdk/json/SessionConfig.java | 46 +++++++++++++++++++ .../sdk/json/SessionUiCapabilities.java | 40 ++++++++++++++++ 8 files changed, 183 insertions(+) mode change 100644 => 100755 java/mvnw diff --git a/java/mvnw b/java/mvnw old mode 100644 new mode 100755 diff --git a/java/src/main/java/com/github/copilot/sdk/CopilotClient.java b/java/src/main/java/com/github/copilot/sdk/CopilotClient.java index 4d0770319..c0f282228 100644 --- a/java/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -34,6 +34,7 @@ import com.github.copilot.sdk.json.PingResponse; import com.github.copilot.sdk.json.ResumeSessionConfig; import com.github.copilot.sdk.json.ResumeSessionResponse; +import com.github.copilot.sdk.json.SessionCapabilities; import com.github.copilot.sdk.json.SessionConfig; import com.github.copilot.sdk.json.SessionLifecycleHandler; import com.github.copilot.sdk.json.SessionListFilter; @@ -290,6 +291,31 @@ private static boolean isUnsupportedConnectMethod(JsonRpcException ex) { return ex.getCode() == METHOD_NOT_FOUND_ERROR_CODE || "Unhandled method connect".equals(ex.getMessage()); } + /** + * Logs a warning when the consumer set {@code enableMcpApps=true} on + * create/resume but the runtime did not advertise + * {@code capabilities.ui.mcpApps} in the response. The runtime silently + * drops the opt-in when its {@code MCP_APPS} feature flag (or + * {@code COPILOT_MCP_APPS=true} env override) is unset, so without this + * warning a consumer trying to use MCP Apps would see no error -- just + * tools that never expose {@code _meta.ui.resourceUri}. + */ + private static void warnIfMcpAppsDropped(boolean requested, SessionCapabilities capabilities) { + if (!requested) { + return; + } + boolean advertised = capabilities != null + && capabilities.getUi() != null + && capabilities.getUi().getMcpApps().orElse(false); + if (advertised) { + return; + } + LOG.warning( + "enableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. " + + "The runtime's MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is " + + "likely unset; the MCP Apps surface is unavailable for this session."); + } + /** * Disconnects from the Copilot server and closes all active sessions. *

@@ -467,6 +493,7 @@ public CompletableFuture createSession(SessionConfig config) { rpcNanos); session.setWorkspacePath(response.workspacePath()); session.setCapabilities(response.capabilities()); + warnIfMcpAppsDropped(config.isEnableMcpApps(), response.capabilities()); // If the server returned a different sessionId (e.g. a v2 CLI that ignores // the client-supplied ID), re-key the sessions map. String returnedId = response.sessionId(); @@ -552,6 +579,7 @@ public CompletableFuture resumeSession(String sessionId, ResumeS rpcNanos); session.setWorkspacePath(response.workspacePath()); session.setCapabilities(response.capabilities()); + warnIfMcpAppsDropped(config.isEnableMcpApps(), response.capabilities()); // If the server returned a different sessionId than what was requested, re-key. String returnedId = response.sessionId(); if (returnedId != null && !returnedId.equals(sessionId)) { diff --git a/java/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java index 0cdc4f942..7a8be688a 100644 --- a/java/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java @@ -144,6 +144,9 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess if (config.getOnElicitationRequest() != null) { request.setRequestElicitation(true); } + if (config.isEnableMcpApps()) { + request.setRequestMcpApps(true); + } if (config.getOnExitPlanMode() != null) { request.setRequestExitPlanMode(true); } @@ -238,6 +241,9 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo if (config.getOnElicitationRequest() != null) { request.setRequestElicitation(true); } + if (config.isEnableMcpApps()) { + request.setRequestMcpApps(true); + } if (config.getOnExitPlanMode() != null) { request.setRequestExitPlanMode(true); } diff --git a/java/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java index 881840a73..52e131173 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java @@ -112,6 +112,9 @@ public final class CreateSessionRequest { @JsonProperty("requestElicitation") private Boolean requestElicitation; + @JsonProperty("requestMcpApps") + private Boolean requestMcpApps; + @JsonProperty("requestExitPlanMode") private Boolean requestExitPlanMode; @@ -488,6 +491,21 @@ public void clearRequestElicitation() { this.requestElicitation = null; } + /** Gets the requestMcpApps flag. @return the flag */ + public Boolean getRequestMcpApps() { + return requestMcpApps; + } + + /** Sets the requestMcpApps flag. @param requestMcpApps the flag */ + public void setRequestMcpApps(boolean requestMcpApps) { + this.requestMcpApps = requestMcpApps; + } + + /** Clears the requestMcpApps setting, reverting to the default behavior. */ + public void clearRequestMcpApps() { + this.requestMcpApps = null; + } + /** Gets the requestExitPlanMode flag. @return the flag */ public Boolean getRequestExitPlanMode() { return requestExitPlanMode; diff --git a/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java index 72c9f6f47..2414344e0 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java @@ -70,6 +70,7 @@ public class ResumeSessionConfig { private ElicitationHandler onElicitationRequest; private ExitPlanModeHandler onExitPlanMode; private AutoModeSwitchHandler onAutoModeSwitch; + private boolean enableMcpApps; private String gitHubToken; private String remoteSession; @@ -806,6 +807,31 @@ public ResumeSessionConfig setOnElicitationRequest(ElicitationHandler onElicitat return this; } + /** + * Returns whether MCP Apps (SEP-1865) UI passthrough is enabled on resume. + * + * @return {@code true} if the consumer has opted into MCP Apps, otherwise + * {@code false} + * @see #setEnableMcpApps(boolean) + */ + public boolean isEnableMcpApps() { + return enableMcpApps; + } + + /** + * Enables MCP Apps (SEP-1865) UI passthrough on the resumed session. See + * {@link SessionConfig#setEnableMcpApps(boolean)} for full semantics + * (runtime gate, capability inspection, renderer requirement). + * + * @param enableMcpApps + * {@code true} to opt into MCP Apps support on resume + * @return this config for method chaining + */ + public ResumeSessionConfig setEnableMcpApps(boolean enableMcpApps) { + this.enableMcpApps = enableMcpApps; + return this; + } + /** * Gets the exit-plan-mode request handler. * @@ -963,6 +989,7 @@ public ResumeSessionConfig clone() { copy.onElicitationRequest = this.onElicitationRequest; copy.onExitPlanMode = this.onExitPlanMode; copy.onAutoModeSwitch = this.onAutoModeSwitch; + copy.enableMcpApps = this.enableMcpApps; copy.gitHubToken = this.gitHubToken; copy.remoteSession = this.remoteSession; return copy; diff --git a/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java index 8aca77b7d..13d6c48d2 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java @@ -116,6 +116,9 @@ public final class ResumeSessionRequest { @JsonProperty("requestElicitation") private Boolean requestElicitation; + @JsonProperty("requestMcpApps") + private Boolean requestMcpApps; + @JsonProperty("requestExitPlanMode") private Boolean requestExitPlanMode; @@ -512,6 +515,21 @@ public void clearRequestElicitation() { this.requestElicitation = null; } + /** Gets the requestMcpApps flag. @return the flag */ + public Boolean getRequestMcpApps() { + return requestMcpApps; + } + + /** Sets the requestMcpApps flag. @param requestMcpApps the flag */ + public void setRequestMcpApps(boolean requestMcpApps) { + this.requestMcpApps = requestMcpApps; + } + + /** Clears the requestMcpApps setting, reverting to the default behavior. */ + public void clearRequestMcpApps() { + this.requestMcpApps = null; + } + /** Gets the requestExitPlanMode flag. @return the flag */ public Boolean getRequestExitPlanMode() { return requestExitPlanMode; diff --git a/java/src/main/java/com/github/copilot/sdk/json/SessionConfig.java b/java/src/main/java/com/github/copilot/sdk/json/SessionConfig.java index ddf06cca7..5810bf5b3 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/sdk/json/SessionConfig.java @@ -70,6 +70,7 @@ public class SessionConfig { private ElicitationHandler onElicitationRequest; private ExitPlanModeHandler onExitPlanMode; private AutoModeSwitchHandler onAutoModeSwitch; + private boolean enableMcpApps; private String gitHubToken; private String remoteSession; private CloudSessionOptions cloud; @@ -860,6 +861,50 @@ public SessionConfig setOnElicitationRequest(ElicitationHandler onElicitationReq return this; } + /** + * Returns whether MCP Apps (SEP-1865) UI passthrough is enabled on this + * session. + * + * @return {@code true} if the consumer has opted into MCP Apps, otherwise + * {@code false} + * @see #setEnableMcpApps(boolean) + */ + public boolean isEnableMcpApps() { + return enableMcpApps; + } + + /** + * Enables MCP Apps (SEP-1865) UI passthrough on this session. + *

+ * When {@code true} and the runtime has MCP Apps enabled (via the + * {@code MCP_APPS} feature flag or {@code COPILOT_MCP_APPS=true} environment + * override), the runtime adds the {@code mcp-apps} capability to the session, + * which causes it to advertise the + * {@code extensions.io.modelcontextprotocol/ui} extension to MCP servers (so + * they expose {@code _meta.ui.resourceUri} on tools) and to expose the + * {@code session.rpc.mcp.apps.{listTools,callTool,readResource, + * setHostContext,getHostContext,diagnose}} JSON-RPC methods. + *

+ * If the runtime gate is off, the opt-in is silently dropped server-side (the + * runtime logs a warning); the session is created normally but the MCP Apps + * surface is unavailable. Inspect {@link SessionUiCapabilities#getMcpApps()} on + * {@link com.github.copilot.sdk.CopilotSession#getCapabilities()} to detect + * this. + *

+ * SDK consumers MUST set this to {@code true} only when they have an iframe + * renderer that can display {@code ui://} MCP App bundles. Setting it without + * a renderer will cause MCP servers to register UI-enabled tool variants the + * consumer cannot display. + * + * @param enableMcpApps + * {@code true} to opt into MCP Apps support + * @return this config instance for method chaining + */ + public SessionConfig setEnableMcpApps(boolean enableMcpApps) { + this.enableMcpApps = enableMcpApps; + return this; + } + /** * Gets the exit-plan-mode request handler. * @@ -1057,6 +1102,7 @@ public SessionConfig clone() { copy.onElicitationRequest = this.onElicitationRequest; copy.onExitPlanMode = this.onExitPlanMode; copy.onAutoModeSwitch = this.onAutoModeSwitch; + copy.enableMcpApps = this.enableMcpApps; copy.gitHubToken = this.gitHubToken; copy.remoteSession = this.remoteSession; copy.cloud = this.cloud; diff --git a/java/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java b/java/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java index d19d531ee..139d61615 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java +++ b/java/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java @@ -21,6 +21,9 @@ public class SessionUiCapabilities { @JsonProperty("elicitation") private Boolean elicitation; + @JsonProperty("mcpApps") + private Boolean mcpApps; + /** * Returns whether the host supports interactive elicitation dialogs. * @@ -53,4 +56,41 @@ public SessionUiCapabilities clearElicitation() { return this; } + /** + * Returns whether the runtime has accepted the session's MCP Apps (SEP-1865) + * opt-in. Present and {@code true} when the consumer set + * {@code enableMcpApps=true} on create/resume and the runtime's + * {@code MCP_APPS} feature flag (or {@code COPILOT_MCP_APPS=true} env + * override) is on. Otherwise empty or {@code false}, indicating the runtime + * silently dropped the opt-in. + * + * @return an {@link Optional} containing the boolean value, or empty if not set + */ + @JsonIgnore + public Optional getMcpApps() { + return Optional.ofNullable(mcpApps); + } + + /** + * Sets whether the runtime has accepted the MCP Apps opt-in. + * + * @param mcpApps + * {@code true} if MCP Apps is enabled for this session + * @return this instance for method chaining + */ + public SessionUiCapabilities setMcpApps(boolean mcpApps) { + this.mcpApps = mcpApps; + return this; + } + + /** + * Clears the mcpApps setting. + * + * @return this instance for method chaining + */ + public SessionUiCapabilities clearMcpApps() { + this.mcpApps = null; + return this; + } + } From 9ab8617d187b0d83780ee041f02c26cc6db6a29b Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 22 May 2026 10:56:04 +0100 Subject: [PATCH 10/15] chore: address sanity-check findings - Revert unintended java/mvnw mode change (644 -> 755) introduced in the Java SDK commit; CI runs mvnw with explicit bash and doesn't require the exec bit. - Refresh Rust doc comments left stale after renaming request_mcp_apps -> enable_mcp_apps on the user-facing API (session.rs warn helper docstring + tracing message; UiCapabilities.mcp_apps cref). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- java/mvnw | 0 rust/src/session.rs | 4 ++-- rust/src/types.rs | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) mode change 100755 => 100644 java/mvnw diff --git a/java/mvnw b/java/mvnw old mode 100755 new mode 100644 diff --git a/rust/src/session.rs b/rust/src/session.rs index 3633711be..d966253b4 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -1125,7 +1125,7 @@ impl Client { type CommandHandlerMap = HashMap>; -/// Emit a `tracing::warn!` when the consumer set `request_mcp_apps: Some(true)` +/// Emit a `tracing::warn!` when the consumer set `enable_mcp_apps: true` /// on create/resume but the runtime did not advertise `capabilities.ui.mcp_apps` /// in the response. The runtime silently drops the opt-in when its `MCP_APPS` /// feature flag (or `COPILOT_MCP_APPS=true` env override) is unset, so without @@ -1149,7 +1149,7 @@ fn warn_if_mcp_apps_dropped( } tracing::warn!( session_id = %session_id, - "request_mcp_apps was set but the runtime did not advertise capabilities.ui.mcpApps; the MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is likely unset and the MCP Apps surface is unavailable for this session" + "enable_mcp_apps was set but the runtime did not advertise capabilities.ui.mcpApps; the MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is likely unset and the MCP Apps surface is unavailable for this session" ); } diff --git a/rust/src/types.rs b/rust/src/types.rs index 33fc5a323..158983459 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -3293,9 +3293,9 @@ pub struct UiCapabilities { pub elicitation: Option, /// Whether the runtime has accepted the session's MCP Apps (SEP-1865) /// opt-in. `Some(true)` when the consumer set - /// [`SessionConfig::request_mcp_apps`] / [`ResumeSessionConfig::request_mcp_apps`] - /// to `Some(true)` on create/resume **and** the runtime's `MCP_APPS` - /// feature flag (or `COPILOT_MCP_APPS=true` env override) is on. Otherwise + /// [`SessionConfig::enable_mcp_apps`] / [`ResumeSessionConfig::enable_mcp_apps`] + /// to `true` on create/resume **and** the runtime's `MCP_APPS` feature + /// flag (or `COPILOT_MCP_APPS=true` env override) is on. Otherwise /// absent or `Some(false)`, indicating the runtime silently dropped the /// opt-in. #[serde(skip_serializing_if = "Option::is_none")] From 7c06494e4c00db9a6162d2c053ecaf9b7750a212 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 22 May 2026 11:04:47 +0100 Subject: [PATCH 11/15] style: apply spotless formatting to Java MCP Apps additions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../com/github/copilot/sdk/CopilotClient.java | 20 +++++++++---------- .../copilot/sdk/json/ResumeSessionConfig.java | 4 ++-- .../copilot/sdk/json/SessionConfig.java | 4 ++-- .../sdk/json/SessionUiCapabilities.java | 6 +++--- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/java/src/main/java/com/github/copilot/sdk/CopilotClient.java b/java/src/main/java/com/github/copilot/sdk/CopilotClient.java index c0f282228..f62c3a372 100644 --- a/java/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -294,26 +294,24 @@ private static boolean isUnsupportedConnectMethod(JsonRpcException ex) { /** * Logs a warning when the consumer set {@code enableMcpApps=true} on * create/resume but the runtime did not advertise - * {@code capabilities.ui.mcpApps} in the response. The runtime silently - * drops the opt-in when its {@code MCP_APPS} feature flag (or - * {@code COPILOT_MCP_APPS=true} env override) is unset, so without this - * warning a consumer trying to use MCP Apps would see no error -- just - * tools that never expose {@code _meta.ui.resourceUri}. + * {@code capabilities.ui.mcpApps} in the response. The runtime silently drops + * the opt-in when its {@code MCP_APPS} feature flag (or + * {@code COPILOT_MCP_APPS=true} env override) is unset, so without this warning + * a consumer trying to use MCP Apps would see no error -- just tools that never + * expose {@code _meta.ui.resourceUri}. */ private static void warnIfMcpAppsDropped(boolean requested, SessionCapabilities capabilities) { if (!requested) { return; } - boolean advertised = capabilities != null - && capabilities.getUi() != null + boolean advertised = capabilities != null && capabilities.getUi() != null && capabilities.getUi().getMcpApps().orElse(false); if (advertised) { return; } - LOG.warning( - "enableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. " - + "The runtime's MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is " - + "likely unset; the MCP Apps surface is unavailable for this session."); + LOG.warning("enableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. " + + "The runtime's MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is " + + "likely unset; the MCP Apps surface is unavailable for this session."); } /** diff --git a/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java index 2414344e0..02b8d0bd3 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java @@ -820,8 +820,8 @@ public boolean isEnableMcpApps() { /** * Enables MCP Apps (SEP-1865) UI passthrough on the resumed session. See - * {@link SessionConfig#setEnableMcpApps(boolean)} for full semantics - * (runtime gate, capability inspection, renderer requirement). + * {@link SessionConfig#setEnableMcpApps(boolean)} for full semantics (runtime + * gate, capability inspection, renderer requirement). * * @param enableMcpApps * {@code true} to opt into MCP Apps support on resume diff --git a/java/src/main/java/com/github/copilot/sdk/json/SessionConfig.java b/java/src/main/java/com/github/copilot/sdk/json/SessionConfig.java index 5810bf5b3..d377aa749 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/sdk/json/SessionConfig.java @@ -892,8 +892,8 @@ public boolean isEnableMcpApps() { * this. *

* SDK consumers MUST set this to {@code true} only when they have an iframe - * renderer that can display {@code ui://} MCP App bundles. Setting it without - * a renderer will cause MCP servers to register UI-enabled tool variants the + * renderer that can display {@code ui://} MCP App bundles. Setting it without a + * renderer will cause MCP servers to register UI-enabled tool variants the * consumer cannot display. * * @param enableMcpApps diff --git a/java/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java b/java/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java index 139d61615..591bbdef3 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java +++ b/java/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java @@ -60,9 +60,9 @@ public SessionUiCapabilities clearElicitation() { * Returns whether the runtime has accepted the session's MCP Apps (SEP-1865) * opt-in. Present and {@code true} when the consumer set * {@code enableMcpApps=true} on create/resume and the runtime's - * {@code MCP_APPS} feature flag (or {@code COPILOT_MCP_APPS=true} env - * override) is on. Otherwise empty or {@code false}, indicating the runtime - * silently dropped the opt-in. + * {@code MCP_APPS} feature flag (or {@code COPILOT_MCP_APPS=true} env override) + * is on. Otherwise empty or {@code false}, indicating the runtime silently + * dropped the opt-in. * * @return an {@link Optional} containing the boolean value, or empty if not set */ From cf5ecb369026f01d35a83edb9924850f46f09f9b Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 22 May 2026 12:56:44 +0100 Subject: [PATCH 12/15] Omit requestMcpApps from wire payload when disabled Aligns Node.js and Python with Go/.NET/Java/Rust, which all omit the field when the feature is not opted in. Previously these two SDKs always sent requestMcpApps: false, cluttering protocol logs and risking ambiguity if the protocol ever distinguishes 'not sent' from 'explicitly false'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 4 ++-- python/copilot/client.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index c89637275..8fc5c66c3 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -885,7 +885,7 @@ export class CopilotClient { requestPermission: !!config.onPermissionRequest, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, - requestMcpApps: !!config.enableMcpApps, + ...(config.enableMcpApps ? { requestMcpApps: true } : {}), requestExitPlanMode: !!config.onExitPlanModeRequest, requestAutoModeSwitch: !!config.onAutoModeSwitchRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), @@ -1022,7 +1022,7 @@ export class CopilotClient { config.onPermissionRequest !== defaultJoinSessionPermissionHandler, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, - requestMcpApps: !!config.enableMcpApps, + ...(config.enableMcpApps ? { requestMcpApps: true } : {}), requestExitPlanMode: !!config.onExitPlanModeRequest, requestAutoModeSwitch: !!config.onAutoModeSwitchRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), diff --git a/python/copilot/client.py b/python/copilot/client.py index 102d20488..f0195dae6 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1548,7 +1548,8 @@ async def create_session( payload["requestElicitation"] = bool(on_elicitation_request) payload["requestExitPlanMode"] = bool(on_exit_plan_mode) payload["requestAutoModeSwitch"] = bool(on_auto_mode_switch) - payload["requestMcpApps"] = bool(enable_mcp_apps) + if enable_mcp_apps: + payload["requestMcpApps"] = True # Serialize commands (name + description only) into payload if commands: @@ -1945,7 +1946,8 @@ async def resume_session( payload["requestElicitation"] = bool(on_elicitation_request) payload["requestExitPlanMode"] = bool(on_exit_plan_mode) payload["requestAutoModeSwitch"] = bool(on_auto_mode_switch) - payload["requestMcpApps"] = bool(enable_mcp_apps) + if enable_mcp_apps: + payload["requestMcpApps"] = True # Serialize commands (name + description only) into payload if commands: From 3d41f8e76ae9b355f3e4f146d3f8549a31fafa95 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 22 May 2026 16:05:41 +0100 Subject: [PATCH 13/15] fix(mcp-apps): address high-priority review feedback - nodejs: switch warnIfMcpAppsDropped from console.warn to process.emitWarning with name McpAppsCapabilityDroppedWarning so consumers can route/suppress it (--no-warnings, process.on('warning', ...)) like any other Node deprecation warning. - go: add TestCreateSessionRequest_RequestMcpApps / TestResumeSessionRequest_RequestMcpApps mirroring the existing RequestElicitation marshal/omit tests. - rust: add session_config_enable_mcp_apps_sets_wire_flag_and_serializes and resume_session_config_enable_mcp_apps_sets_wire_flag_and_serializes to cover the opt-in path (config field -> wire flag -> requestMcpApps in serialized JSON). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/client_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++ nodejs/src/client.ts | 22 +++++++++++------ rust/src/types.rs | 25 +++++++++++++++++++ 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/go/client_test.go b/go/client_test.go index 39358a72a..bff951a9c 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -946,6 +946,65 @@ func TestResumeSessionRequest_RequestElicitation(t *testing.T) { }) } +func TestCreateSessionRequest_RequestMcpApps(t *testing.T) { + t.Run("sends requestMcpApps flag when EnableMcpApps is set", func(t *testing.T) { + req := createSessionRequest{ + RequestMcpApps: Bool(true), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["requestMcpApps"] != true { + t.Errorf("Expected requestMcpApps to be true, got %v", m["requestMcpApps"]) + } + }) + + t.Run("does not send requestMcpApps when EnableMcpApps is unset", func(t *testing.T) { + req := createSessionRequest{} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["requestMcpApps"]; ok { + t.Error("Expected requestMcpApps to be omitted when not set") + } + }) +} + +func TestResumeSessionRequest_RequestMcpApps(t *testing.T) { + t.Run("sends requestMcpApps flag when EnableMcpApps is set", func(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + RequestMcpApps: Bool(true), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["requestMcpApps"] != true { + t.Errorf("Expected requestMcpApps to be true, got %v", m["requestMcpApps"]) + } + }) + + t.Run("does not send requestMcpApps when EnableMcpApps is unset", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1"} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["requestMcpApps"]; ok { + t.Error("Expected requestMcpApps to be omitted when not set") + } + }) +} + func TestResumeSessionRequest_ModeCallbackFlags(t *testing.T) { req := resumeSessionRequest{ SessionID: "s1", diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 8fc5c66c3..98ec2e02c 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -77,20 +77,26 @@ import { defaultJoinSessionPermissionHandler } from "./types.js"; const MIN_PROTOCOL_VERSION = 2; /** - * Emit a `console.warn` when the consumer set `enableMcpApps: true` on - * create/resume but the runtime did not advertise `capabilities.ui.mcpApps` - * in the response. The runtime silently drops the opt-in when its `MCP_APPS` - * feature flag (or `COPILOT_MCP_APPS=true` env override) is unset, so without - * this warning a consumer trying to use MCP Apps would see no error — just - * tools that never expose `_meta.ui.resourceUri`. + * Emit a Node warning (`process.emitWarning`) when the consumer set + * `enableMcpApps: true` on create/resume but the runtime did not advertise + * `capabilities.ui.mcpApps` in the response. The runtime silently drops the + * opt-in when its `MCP_APPS` feature flag (or `COPILOT_MCP_APPS=true` env + * override) is unset, so without this warning a consumer trying to use MCP + * Apps would see no error — just tools that never expose + * `_meta.ui.resourceUri`. + * + * Using `process.emitWarning` (rather than `console.warn`) means consumers + * can route the warning via `process.on("warning", …)`, suppress it with + * `--no-warnings`, or filter by the `name` field below. */ function warnIfMcpAppsDropped( requested: boolean | undefined, capabilities: { ui?: { mcpApps?: boolean } } | undefined ): void { if (requested && !capabilities?.ui?.mcpApps) { - console.warn( - "[copilot-sdk] enableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. The runtime's MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is likely unset; the MCP Apps surface is unavailable for this session." + process.emitWarning( + "enableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. The runtime's MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is likely unset; the MCP Apps surface is unavailable for this session.", + "McpAppsCapabilityDroppedWarning" ); } } diff --git a/rust/src/types.rs b/rust/src/types.rs index 158983459..3676fb71e 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -3596,6 +3596,31 @@ mod tests { assert!(!wire.request_mcp_apps); } + #[test] + fn session_config_enable_mcp_apps_sets_wire_flag_and_serializes() { + let cfg = SessionConfig::default().with_enable_mcp_apps(true); + assert!(cfg.enable_mcp_apps); + + let wire = cfg.to_wire(SessionId::from("enable-mcp-apps")); + assert!(wire.request_mcp_apps); + + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!(json["requestMcpApps"], serde_json::Value::Bool(true)); + } + + #[test] + fn resume_session_config_enable_mcp_apps_sets_wire_flag_and_serializes() { + let cfg = ResumeSessionConfig::new(SessionId::from("resume-enable-mcp-apps")) + .with_enable_mcp_apps(true); + assert!(cfg.enable_mcp_apps); + + let wire = cfg.to_wire(); + assert!(wire.request_mcp_apps); + + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!(json["requestMcpApps"], serde_json::Value::Bool(true)); + } + #[test] fn session_config_builder_composes() { use std::collections::HashMap; From f61d1feafd87e26e84c1e9f2c662be2f2a7a6454 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 22 May 2026 16:08:52 +0100 Subject: [PATCH 14/15] docs(mcp-apps): address worth-doing review feedback - python: add enable_mcp_apps entries to create_session and resume_session docstring Args lists, describing the runtime gate and the capabilities.ui.mcpApps detection mechanism. - java: thread sessionId through warnIfMcpAppsDropped and include it in the warning message, matching the Python/Go/.NET/Rust pattern for multi-session debugging. - nodejs/test: replace the 'see review feedback' marker in the sandbox sanitization section header with a self-contained reference to SEP-1865 \xc2\xa7Security Implications. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../java/com/github/copilot/sdk/CopilotClient.java | 9 +++++---- nodejs/test/mcpAppsSandbox.test.ts | 5 +++-- python/copilot/client.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/java/src/main/java/com/github/copilot/sdk/CopilotClient.java b/java/src/main/java/com/github/copilot/sdk/CopilotClient.java index f62c3a372..f453ff7d6 100644 --- a/java/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -300,7 +300,7 @@ private static boolean isUnsupportedConnectMethod(JsonRpcException ex) { * a consumer trying to use MCP Apps would see no error -- just tools that never * expose {@code _meta.ui.resourceUri}. */ - private static void warnIfMcpAppsDropped(boolean requested, SessionCapabilities capabilities) { + private static void warnIfMcpAppsDropped(boolean requested, SessionCapabilities capabilities, String sessionId) { if (!requested) { return; } @@ -309,7 +309,8 @@ private static void warnIfMcpAppsDropped(boolean requested, SessionCapabilities if (advertised) { return; } - LOG.warning("enableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. " + LOG.warning("Session " + sessionId + + ": enableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. " + "The runtime's MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is " + "likely unset; the MCP Apps surface is unavailable for this session."); } @@ -491,7 +492,7 @@ public CompletableFuture createSession(SessionConfig config) { rpcNanos); session.setWorkspacePath(response.workspacePath()); session.setCapabilities(response.capabilities()); - warnIfMcpAppsDropped(config.isEnableMcpApps(), response.capabilities()); + warnIfMcpAppsDropped(config.isEnableMcpApps(), response.capabilities(), sessionId); // If the server returned a different sessionId (e.g. a v2 CLI that ignores // the client-supplied ID), re-key the sessions map. String returnedId = response.sessionId(); @@ -577,7 +578,7 @@ public CompletableFuture resumeSession(String sessionId, ResumeS rpcNanos); session.setWorkspacePath(response.workspacePath()); session.setCapabilities(response.capabilities()); - warnIfMcpAppsDropped(config.isEnableMcpApps(), response.capabilities()); + warnIfMcpAppsDropped(config.isEnableMcpApps(), response.capabilities(), sessionId); // If the server returned a different sessionId than what was requested, re-key. String returnedId = response.sessionId(); if (returnedId != null && !returnedId.equals(sessionId)) { diff --git a/nodejs/test/mcpAppsSandbox.test.ts b/nodejs/test/mcpAppsSandbox.test.ts index dcd6494d7..a3784dcff 100644 --- a/nodejs/test/mcpAppsSandbox.test.ts +++ b/nodejs/test/mcpAppsSandbox.test.ts @@ -73,8 +73,9 @@ describe("buildMcpAppsCspHeader", () => { }); // ------------------------------------------------------------------ - // Domain-input sanitization (defends against CSP directive injection - // from malicious or sloppy MCP servers — see review feedback). + // Domain-input sanitization per SEP-1865 §Security Implications: + // server-supplied CSP domain entries must be validated before + // interpolation to prevent directive injection. // ------------------------------------------------------------------ it("drops entries containing CSP metacharacters that would inject a sibling directive", () => { diff --git a/python/copilot/client.py b/python/copilot/client.py index b555807ea..f164b11f4 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1644,6 +1644,13 @@ async def create_session( session. Optionally associates repository metadata with the cloud session. on_event: Callback for session events. + enable_mcp_apps: Opt into MCP Apps (SEP-1865) UI passthrough. + When True, the SDK sends ``requestMcpApps: True`` on + ``session.create``. The runtime only honors the opt-in when its + ``MCP_APPS`` feature flag (or ``COPILOT_MCP_APPS=true`` env + override) is on; otherwise the request is silently dropped. + Inspect ``capabilities.ui.mcpApps`` on the create response to + detect the drop (the SDK also logs a warning). Returns: A :class:`CopilotSession` instance for the new session. @@ -2020,6 +2027,13 @@ async def resume_session( disabled_skills: Skills to disable. infinite_sessions: Infinite session configuration. on_event: Callback for session events. + enable_mcp_apps: Opt into MCP Apps (SEP-1865) UI passthrough on + resume. When True, the SDK sends ``requestMcpApps: True`` on + ``session.resume``. The runtime only honors the opt-in when its + ``MCP_APPS`` feature flag (or ``COPILOT_MCP_APPS=true`` env + override) is on; otherwise the request is silently dropped. + Inspect ``capabilities.ui.mcpApps`` on the resume response to + detect the drop (the SDK also logs a warning). continue_pending_work: When True, instructs the runtime to continue any tool calls or permission prompts that were still pending when the session was last suspended. When False (the default), the runtime From 6ce0f033c512eb5c86474326ef9bdafb88a6ef35 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 22 May 2026 16:28:09 +0100 Subject: [PATCH 15/15] fix(go): route MCP Apps warning through log.Default() instead of os.Stderr Writing directly to os.Stderr from library code is unsuppressible and unroutable. Switch to log.Printf so consumers can call log.Default().SetOutput(io.Discard) (or any other writer) to control the warning. Default behavior is unchanged (log.Default() writes to stderr). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/client.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/go/client.go b/go/client.go index fea4ed1a2..1ad0a9c08 100644 --- a/go/client.go +++ b/go/client.go @@ -34,6 +34,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net" "os" "os/exec" @@ -54,12 +55,16 @@ import ( const noResultPermissionV2Error = "permission handlers cannot return 'no-result' when connected to a protocol v2 server" -// warnIfMcpAppsDropped emits a stderr warning when the consumer set -// EnableMcpApps=true on create/resume but the runtime did not advertise -// capabilities.ui.mcpApps in the response. The runtime silently drops the -// opt-in when its MCP_APPS feature flag (or COPILOT_MCP_APPS=true env +// warnIfMcpAppsDropped logs a warning via the standard log package when the +// consumer set EnableMcpApps=true on create/resume but the runtime did not +// advertise capabilities.ui.mcpApps in the response. The runtime silently drops +// the opt-in when its MCP_APPS feature flag (or COPILOT_MCP_APPS=true env // override) is unset, so without this warning a consumer trying to use MCP // Apps would see no error -- just tools that never expose _meta.ui.resourceUri. +// +// The warning is routed through log.Default(), so consumers can suppress or +// redirect it with log.SetOutput / log.Default().SetOutput (e.g. +// log.Default().SetOutput(io.Discard) to silence). func warnIfMcpAppsDropped(requested bool, capabilities *SessionCapabilities, sessionID string) { if !requested { return @@ -67,8 +72,8 @@ func warnIfMcpAppsDropped(requested bool, capabilities *SessionCapabilities, ses if capabilities != nil && capabilities.UI != nil && capabilities.UI.McpApps { return } - fmt.Fprintf(os.Stderr, - "[copilot-sdk] Session %s: EnableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. The runtime's MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is likely unset; the MCP Apps surface is unavailable for this session.\n", + log.Printf( + "[copilot-sdk] Session %s: EnableMcpApps was requested but the runtime did not advertise capabilities.ui.mcpApps. The runtime's MCP_APPS feature flag or COPILOT_MCP_APPS=true environment override is likely unset; the MCP Apps surface is unavailable for this session.", sessionID, ) }