diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 8311a3eb1..47603a798 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -615,6 +615,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, @@ -633,6 +634,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) { @@ -775,6 +777,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, @@ -793,6 +796,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) { @@ -1676,6 +1680,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. /// @@ -1960,6 +1981,7 @@ internal record CreateSessionRequest( InfiniteSessionConfig? InfiniteSessions, IList? Commands = null, bool? RequestElicitation = null, + bool? RequestMcpApps = null, string? Traceparent = null, string? Tracestate = null, ModelCapabilitiesOverride? ModelCapabilities = null, @@ -2022,6 +2044,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 a323eaab4..c3aba0c25 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1177,6 +1177,15 @@ public sealed 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 + /// 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; } } // ============================================================================ @@ -2301,6 +2310,7 @@ protected SessionConfigBase(SessionConfigBase? 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; @@ -2415,6 +2425,30 @@ protected SessionConfigBase(SessionConfigBase? other) /// Handler for auto-mode-switch requests from the server. public Func>? OnAutoModeSwitchRequest { 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. public SessionHooks? Hooks { get; set; } diff --git a/go/client.go b/go/client.go index e7ac2a9a1..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,6 +55,29 @@ import ( const noResultPermissionV2Error = "permission handlers cannot return 'no-result' when connected to a protocol v2 server" +// 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 + } + if capabilities != nil && capabilities.UI != nil && capabilities.UI.McpApps { + return + } + 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, + ) +} + func validateSessionFsConfig(config *SessionFsConfig) error { if config == nil { return nil @@ -645,6 +669,9 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.OnAutoModeSwitchRequest != nil { req.RequestAutoModeSwitch = Bool(true) } + if config.EnableMcpApps { + req.RequestMcpApps = Bool(true) + } if config.Streaming != nil { req.Streaming = config.Streaming @@ -752,6 +779,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 } @@ -863,6 +891,9 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.OnAutoModeSwitchRequest != nil { req.RequestAutoModeSwitch = Bool(true) } + if config.EnableMcpApps { + req.RequestMcpApps = Bool(true) + } traceparent, tracestate := getTraceContext(ctx) req.Traceparent = traceparent @@ -940,6 +971,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/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/go/types.go b/go/types.go index a16ef87e3..8f2c04a56 100644 --- a/go/types.go +++ b/go/types.go @@ -940,6 +940,26 @@ type SessionConfig struct { // OnAutoModeSwitchRequest is a handler for auto-mode-switch requests from the server. // When provided, enables autoModeSwitch.request callbacks for the session. OnAutoModeSwitchRequest AutoModeSwitchRequestHandler + // 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. @@ -1027,6 +1047,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. @@ -1200,6 +1226,9 @@ type ResumeSessionConfig struct { // OnAutoModeSwitchRequest is a handler for auto-mode-switch requests from the server. // See SessionConfig.OnAutoModeSwitchRequest. OnAutoModeSwitchRequest AutoModeSwitchRequestHandler + // 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". @@ -1421,6 +1450,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"` @@ -1477,6 +1507,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/java/src/main/java/com/github/copilot/sdk/CopilotClient.java b/java/src/main/java/com/github/copilot/sdk/CopilotClient.java index 4d0770319..f453ff7d6 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,30 @@ 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, String sessionId) { + if (!requested) { + return; + } + boolean advertised = capabilities != null && capabilities.getUi() != null + && capabilities.getUi().getMcpApps().orElse(false); + if (advertised) { + return; + } + 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."); + } + /** * Disconnects from the Copilot server and closes all active sessions. *

@@ -467,6 +492,7 @@ public CompletableFuture createSession(SessionConfig config) { rpcNanos); session.setWorkspacePath(response.workspacePath()); session.setCapabilities(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(); @@ -552,6 +578,7 @@ public CompletableFuture resumeSession(String sessionId, ResumeS rpcNanos); session.setWorkspacePath(response.workspacePath()); session.setCapabilities(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/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..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 @@ -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..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 @@ -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..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 @@ -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; + } + } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 904fb0ee2..98ec2e02c 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -76,6 +76,31 @@ import { defaultJoinSessionPermissionHandler } from "./types.js"; */ const MIN_PROTOCOL_VERSION = 2; +/** + * 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) { + 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" + ); + } +} + /** * Check if value is a Zod schema (has toJSONSchema method) */ @@ -866,6 +891,7 @@ export class CopilotClient { requestPermission: !!config.onPermissionRequest, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, + ...(config.enableMcpApps ? { requestMcpApps: true } : {}), requestExitPlanMode: !!config.onExitPlanModeRequest, requestAutoModeSwitch: !!config.onAutoModeSwitchRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), @@ -891,10 +917,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); } catch (e) { this.sessions.delete(sessionId); throw e; @@ -1001,6 +1028,7 @@ export class CopilotClient { config.onPermissionRequest !== defaultJoinSessionPermissionHandler, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, + ...(config.enableMcpApps ? { requestMcpApps: true } : {}), requestExitPlanMode: !!config.onExitPlanModeRequest, requestAutoModeSwitch: !!config.onAutoModeSwitchRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), @@ -1027,10 +1055,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); } catch (e) { this.sessions.delete(sessionId); throw e; diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 11d642ba0..3bb56ed47 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -11,6 +11,12 @@ export { CopilotClient } from "./client.js"; export { RuntimeConnection } from "./types.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 2793a0b3e..b8836b517 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -543,6 +543,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; }; } @@ -1523,6 +1531,31 @@ export interface SessionConfigBase { */ 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. diff --git a/nodejs/test/mcpAppsSandbox.test.ts b/nodejs/test/mcpAppsSandbox.test.ts new file mode 100644 index 000000000..a3784dcff --- /dev/null +++ b/nodejs/test/mcpAppsSandbox.test.ts @@ -0,0 +1,216 @@ +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 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", () => { + 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 7e381dce9..f164b11f4 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -77,6 +77,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 # ============================================================================ @@ -1544,6 +1571,7 @@ async def create_session( on_event: Callable[[SessionEvent], None] | None = None, commands: list[CommandDefinition] | None = None, on_elicitation_request: ElicitationHandler | None = None, + enable_mcp_apps: bool = False, on_exit_plan_mode_request: ExitPlanModeHandler | None = None, on_auto_mode_switch_request: AutoModeSwitchHandler | None = None, create_session_fs_handler: CreateSessionFsHandler | None = None, @@ -1616,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. @@ -1683,6 +1718,8 @@ async def create_session( # Enable elicitation request callback if handler provided payload["requestElicitation"] = bool(on_elicitation_request) + if enable_mcp_apps: + payload["requestMcpApps"] = True payload["requestExitPlanMode"] = bool(on_exit_plan_mode_request) payload["requestAutoModeSwitch"] = bool(on_auto_mode_switch_request) @@ -1863,6 +1900,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) @@ -1919,6 +1957,7 @@ async def resume_session( on_event: Callable[[SessionEvent], None] | None = None, commands: list[CommandDefinition] | None = None, on_elicitation_request: ElicitationHandler | None = None, + enable_mcp_apps: bool = False, on_exit_plan_mode_request: ExitPlanModeHandler | None = None, on_auto_mode_switch_request: AutoModeSwitchHandler | None = None, create_session_fs_handler: CreateSessionFsHandler | None = None, @@ -1988,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 @@ -2074,6 +2120,8 @@ async def resume_session( # Enable elicitation request callback if handler provided payload["requestElicitation"] = bool(on_elicitation_request) + if enable_mcp_apps: + payload["requestMcpApps"] = True payload["requestExitPlanMode"] = bool(on_exit_plan_mode_request) payload["requestAutoModeSwitch"] = bool(on_auto_mode_switch_request) @@ -2213,6 +2261,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 7f3dd0f3c..1a21fe560 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -388,6 +388,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 842d5d732..d966253b4 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -906,6 +906,7 @@ impl Client { })); } *capabilities.write() = create_result.capabilities.unwrap_or_default(); + warn_if_mcp_apps_dropped(config.enable_mcp_apps, &capabilities.read(), &session_id); tracing::debug!( elapsed_ms = total_start.elapsed().as_millis(), @@ -1099,6 +1100,7 @@ impl Client { } *capabilities.write() = resume_capabilities.unwrap_or_default(); + warn_if_mcp_apps_dropped(config.enable_mcp_apps, &capabilities.read(), &session_id); tracing::debug!( elapsed_ms = total_start.elapsed().as_millis(), @@ -1123,6 +1125,34 @@ impl Client { type CommandHandlerMap = HashMap>; +/// 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 +/// 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, + "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" + ); +} + 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 bbdbedb33..37c08598e 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1100,6 +1100,31 @@ pub struct SessionConfig { /// When true, the CLI runs config discovery (MCP config files, skills, plugins). #[serde(skip_serializing_if = "Option::is_none")] pub enable_config_discovery: Option, + /// 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. + /// + /// Defaults to `false`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub enable_mcp_apps: bool, /// Skill directory paths passed through to the GitHub Copilot CLI. #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, @@ -1246,6 +1271,7 @@ impl std::fmt::Debug for SessionConfig { .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) .field("enable_config_discovery", &self.enable_config_discovery) + .field("enable_mcp_apps", &self.enable_mcp_apps) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) .field("disabled_skills", &self.disabled_skills) @@ -1326,6 +1352,7 @@ impl Default for SessionConfig { mcp_servers: None, env_value_mode: default_env_value_mode(), enable_config_discovery: None, + enable_mcp_apps: false, skill_directories: None, instruction_directories: None, disabled_skills: None, @@ -1382,6 +1409,7 @@ impl SessionConfig { request_exit_plan_mode: self.exit_plan_mode_handler.is_some(), request_auto_mode_switch: self.auto_mode_switch_handler.is_some(), request_elicitation: self.elicitation_handler.is_some(), + request_mcp_apps: self.enable_mcp_apps, hooks: self.hooks_handler.is_some(), skill_directories: self.skill_directories.clone(), instruction_directories: self.instruction_directories.clone(), @@ -1585,6 +1613,13 @@ impl SessionConfig { self } + /// Enable MCP Apps (SEP-1865) UI passthrough on this session. Defaults + /// to `false`. See [`SessionConfig::enable_mcp_apps`]. + pub fn with_enable_mcp_apps(mut self, enable: bool) -> Self { + self.enable_mcp_apps = enable; + self + } + /// Set skill directory paths passed through to the CLI. pub fn with_skill_directories(mut self, paths: I) -> Self where @@ -1756,6 +1791,10 @@ pub struct ResumeSessionConfig { /// Enable config discovery on resume. #[serde(skip_serializing_if = "Option::is_none")] pub enable_config_discovery: Option, + /// Enable MCP Apps (SEP-1865) UI passthrough on resume. See + /// [`SessionConfig::enable_mcp_apps`]. Defaults to `false`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub enable_mcp_apps: bool, /// Skill directory paths passed through to the GitHub Copilot CLI on resume. #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, @@ -1881,6 +1920,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) .field("enable_config_discovery", &self.enable_config_discovery) + .field("enable_mcp_apps", &self.enable_mcp_apps) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) .field("disabled_skills", &self.disabled_skills) @@ -1966,6 +2006,7 @@ impl ResumeSessionConfig { request_exit_plan_mode: self.exit_plan_mode_handler.is_some(), request_auto_mode_switch: self.auto_mode_switch_handler.is_some(), request_elicitation: self.elicitation_handler.is_some(), + request_mcp_apps: self.enable_mcp_apps, hooks: self.hooks_handler.is_some(), skill_directories: self.skill_directories.clone(), instruction_directories: self.instruction_directories.clone(), @@ -2012,6 +2053,7 @@ impl ResumeSessionConfig { mcp_servers: None, env_value_mode: default_env_value_mode(), enable_config_discovery: None, + enable_mcp_apps: false, skill_directories: None, instruction_directories: None, disabled_skills: None, @@ -2194,6 +2236,13 @@ impl ResumeSessionConfig { self } + /// Enable MCP Apps (SEP-1865) UI passthrough on resume. Defaults to + /// `false`. See [`SessionConfig::enable_mcp_apps`]. + pub fn with_enable_mcp_apps(mut self, enable: bool) -> Self { + self.enable_mcp_apps = enable; + self + } + /// Set skill directory paths passed through to the CLI on resume. pub fn with_skill_directories(mut self, paths: I) -> Self where @@ -3242,6 +3291,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::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")] + pub mcp_apps: Option, } /// Options for the [`SessionUi::input`](crate::session::SessionUi::input) convenience method. @@ -3522,6 +3580,7 @@ mod tests { assert!(!wire.request_exit_plan_mode); assert!(!wire.request_auto_mode_switch); assert!(!wire.hooks); + assert!(!wire.request_mcp_apps); } #[test] @@ -3534,6 +3593,32 @@ mod tests { assert!(!wire.request_exit_plan_mode); assert!(!wire.request_auto_mode_switch); assert!(!wire.hooks); + 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] diff --git a/rust/src/wire.rs b/rust/src/wire.rs index bc6af5651..e43043efb 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -66,6 +66,8 @@ pub(crate) struct SessionCreateWire { pub request_exit_plan_mode: bool, pub request_auto_mode_switch: bool, pub request_elicitation: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub request_mcp_apps: bool, pub hooks: bool, #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, @@ -132,6 +134,8 @@ pub(crate) struct SessionResumeWire { pub request_exit_plan_mode: bool, pub request_auto_mode_switch: bool, pub request_elicitation: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub request_mcp_apps: bool, pub hooks: bool, #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, diff --git a/rust/tests/e2e/elicitation.rs b/rust/tests/e2e/elicitation.rs index 91961e60f..1d9d211ad 100644 --- a/rust/tests/e2e/elicitation.rs +++ b/rust/tests/e2e/elicitation.rs @@ -383,6 +383,7 @@ async fn session_capabilities_types_are_properly_structured() { let capabilities = github_copilot_sdk::SessionCapabilities { ui: Some(UiCapabilities { elicitation: Some(true), + mcp_apps: None, }), };