diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index f314a519b..02b417c46 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, @@ -652,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) { @@ -793,6 +795,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, @@ -811,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) { @@ -1756,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. /// @@ -2034,6 +2055,7 @@ internal record CreateSessionRequest( InfiniteSessionConfig? InfiniteSessions, IList? Commands = null, bool? RequestElicitation = null, + bool? RequestMcpApps = null, string? Traceparent = null, string? Tracestate = null, ModelCapabilitiesOverride? ModelCapabilities = null, @@ -2096,6 +2118,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 8a0970306..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; } } // ============================================================================ @@ -2065,6 +2075,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; @@ -2217,6 +2228,30 @@ protected SessionConfig(SessionConfig? other) /// public AutoModeSwitchHandler? OnAutoModeSwitch { get; set; } + /// + /// Enable MCP Apps (SEP-1865) UI passthrough on this 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,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. + /// + /// + public bool EnableMcpApps { get; set; } + /// /// Hook handlers for session lifecycle events. /// @@ -2377,6 +2412,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 @@ -2507,6 +2543,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 9fa772129..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 @@ -670,6 +689,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) @@ -774,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 } @@ -882,6 +905,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 @@ -959,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 19bc892cc..a51130be9 100644 --- a/go/types.go +++ b/go/types.go @@ -697,6 +697,26 @@ 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 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. + 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. @@ -784,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. @@ -956,6 +982,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". @@ -1177,6 +1206,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"` @@ -1233,6 +1263,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/nodejs/src/client.ts b/nodejs/src/client.ts index 6342b6667..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) */ @@ -823,6 +843,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)), @@ -848,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; @@ -962,6 +984,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)), @@ -988,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/index.ts b/nodejs/src/index.ts index 13c8eb1bb..37ee142bd 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..ee10c3a0c --- /dev/null +++ b/nodejs/src/mcpAppsSandbox.ts @@ -0,0 +1,193 @@ +/** + * 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; +} + +/** + * 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'; " + + "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"). + * + * 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; + * res.setHeader("Content-Security-Policy", buildMcpAppsCspHeader(meta?.csp)); + * ``` + */ +export function buildMcpAppsCspHeader(csp: McpAppsCspInput | undefined): string { + if (!csp) { + return RESTRICTIVE_DEFAULT_CSP; + } + // 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'", + `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 a8e3bdfe5..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; }; } @@ -1409,6 +1417,31 @@ export interface SessionConfig { */ onElicitationRequest?: ElicitationHandler; + /** + * Enable MCP Apps (SEP-1865) UI passthrough on this 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,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. + * + * @default false + */ + enableMcpApps?: boolean; + /** * Handler for exit-plan-mode requests from the agent. * When provided, enables `exitPlanMode.request` callbacks. @@ -1566,6 +1599,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..dcd6494d7 --- /dev/null +++ b/nodejs/test/mcpAppsSandbox.test.ts @@ -0,0 +1,215 @@ +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'"); + }); + + // ------------------------------------------------------------------ + // 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", () => { + 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"); + }); +}); diff --git a/python/copilot/client.py b/python/copilot/client.py index 6adb52061..a2aa71bcf 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -75,6 +75,33 @@ 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 # ============================================================================ @@ -1361,6 +1388,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, @@ -1504,6 +1532,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: @@ -1682,6 +1711,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) @@ -1740,6 +1770,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, @@ -1898,6 +1929,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: @@ -2035,6 +2067,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 bd1fb6928..8f29b8695 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1079,6 +1079,31 @@ 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)` **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 `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>, @@ -1209,6 +1234,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) @@ -1272,6 +1298,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, @@ -1492,6 +1519,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 @@ -1681,6 +1715,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>, @@ -1791,6 +1829,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) @@ -1853,6 +1892,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, @@ -2044,6 +2084,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 @@ -3091,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. @@ -3367,6 +3423,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] @@ -3377,6 +3434,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] 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, }), };