diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index c8d83dfee2..ca37ea6f30 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1196,6 +1196,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance ToolFilterPrecedence: toolFilter.ToolFilterPrecedence, ExpAssignments: config.ExpAssignments, EnableManagedSettings: config.EnableManagedSettings, + GitHubMcpToolConfig: config.GitHubMcpToolConfig, EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null); var rpcTimestamp = Stopwatch.GetTimestamp(); @@ -1410,6 +1411,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes ToolFilterPrecedence: toolFilter.ToolFilterPrecedence, ExpAssignments: config.ExpAssignments, EnableManagedSettings: config.EnableManagedSettings, + GitHubMcpToolConfig: config.GitHubMcpToolConfig, EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null); var rpcTimestamp = Stopwatch.GetTimestamp(); @@ -2762,7 +2764,8 @@ internal record CreateSessionRequest( OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, [property: JsonPropertyName("expAssignments")] CopilotExpAssignmentResponse? ExpAssignments = null, [property: JsonPropertyName("enableManagedSettings")] bool? EnableManagedSettings = null, - bool? EnableGitHubTelemetryForwarding = null); + bool? EnableGitHubTelemetryForwarding = null, + [property: JsonPropertyName("githubMcpToolConfig")] GitHubMcpToolConfig? GitHubMcpToolConfig = null); #pragma warning restore GHCP001 internal record ToolDefinition( @@ -2868,7 +2871,8 @@ internal record ResumeSessionRequest( OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, [property: JsonPropertyName("expAssignments")] CopilotExpAssignmentResponse? ExpAssignments = null, [property: JsonPropertyName("enableManagedSettings")] bool? EnableManagedSettings = null, - bool? EnableGitHubTelemetryForwarding = null); + bool? EnableGitHubTelemetryForwarding = null, + [property: JsonPropertyName("githubMcpToolConfig")] GitHubMcpToolConfig? GitHubMcpToolConfig = null); #pragma warning restore GHCP001 internal record ResumeSessionResponse( diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 85248707fd..f3523b0f68 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2957,6 +2957,36 @@ public sealed class CopilotExpAssignmentResponse public string AssignmentContext { get; set; } = string.Empty; } +/// +/// Configuration for the built-in GitHub MCP server. +/// +public sealed class GitHubMcpToolConfig +{ + /// Enables all GitHub MCP tools. + [JsonPropertyName("enableAllTools")] + public bool? EnableAllTools { get; set; } + + /// Additional GitHub MCP toolsets to enable. + [JsonPropertyName("additionalToolsets")] + public IList? AdditionalToolsets { get; set; } + + /// Additional GitHub MCP tools to enable. + [JsonPropertyName("additionalTools")] + public IList? AdditionalTools { get; set; } + + /// Enables GitHub MCP insiders-mode tools. + [JsonPropertyName("enableInsidersMode")] + public bool? EnableInsidersMode { get; set; } + + /// + /// Disables form deferral for GitHub MCP tools. This only applies to the + /// built-in GitHub MCP server and only has an effect when MCP Apps and + /// form-backed GitHub tools are enabled. + /// + [JsonPropertyName("disableFormDeferral")] + public bool? DisableFormDeferral { get; set; } +} + /// /// Shared configuration properties for creating or resuming a Copilot session. /// Use when creating a new session, or @@ -2994,6 +3024,20 @@ protected SessionConfigBase(SessionConfigBase? other) EnableSessionStore = other.EnableSessionStore; EnableSkills = other.EnableSkills; EnableMcpApps = other.EnableMcpApps; + GitHubMcpToolConfig = other.GitHubMcpToolConfig is null + ? null + : new GitHubMcpToolConfig + { + EnableAllTools = other.GitHubMcpToolConfig.EnableAllTools, + AdditionalToolsets = other.GitHubMcpToolConfig.AdditionalToolsets is not null + ? [.. other.GitHubMcpToolConfig.AdditionalToolsets] + : null, + AdditionalTools = other.GitHubMcpToolConfig.AdditionalTools is not null + ? [.. other.GitHubMcpToolConfig.AdditionalTools] + : null, + EnableInsidersMode = other.GitHubMcpToolConfig.EnableInsidersMode, + DisableFormDeferral = other.GitHubMcpToolConfig.DisableFormDeferral, + }; ExcludedBuiltInAgents = other.ExcludedBuiltInAgents is not null ? [.. other.ExcludedBuiltInAgents] : null; ExcludedTools = other.ExcludedTools is not null ? [.. other.ExcludedTools] : null; Hooks = other.Hooks; @@ -3309,6 +3353,13 @@ protected SessionConfigBase(SessionConfigBase? other) [Experimental(Diagnostics.Experimental)] public bool EnableMcpApps { get; set; } + /// + /// Configuration for the built-in GitHub MCP server. + /// DisableFormDeferral only applies to that server and only has an + /// effect when MCP Apps and form-backed GitHub tools are enabled. + /// + public GitHubMcpToolConfig? GitHubMcpToolConfig { get; set; } + /// Hook handlers for session lifecycle events. public SessionHooks? Hooks { get; set; } diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 6aaee01e77..8ef5fedc4b 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -299,6 +299,51 @@ public void SessionRequests_OmitCapiOptions_WhenUnset() Assert.False(resumeDocument.RootElement.TryGetProperty("capi", out _)); } + [Fact] + public void SessionRequests_CanSerializeGitHubMcpToolConfig_WithSdkOptions() + { + var options = GetSerializerOptions(); + var githubConfig = new GitHubMcpToolConfig + { + EnableAllTools = true, + AdditionalToolsets = ["repos"], + AdditionalTools = ["get_issue"], + EnableInsidersMode = true, + DisableFormDeferral = true, + }; + + var createRequestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); + var createRequest = CreateInternalRequest( + createRequestType, + ("GitHubMcpToolConfig", githubConfig)); + using var createDocument = JsonDocument.Parse(JsonSerializer.Serialize(createRequest, createRequestType, options)); + var createConfig = createDocument.RootElement.GetProperty("githubMcpToolConfig"); + Assert.True(createConfig.GetProperty("enableAllTools").GetBoolean()); + Assert.Equal("repos", createConfig.GetProperty("additionalToolsets")[0].GetString()); + Assert.True(createConfig.GetProperty("disableFormDeferral").GetBoolean()); + + var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); + var resumeRequest = CreateInternalRequest( + resumeRequestType, + ("SessionId", "session-id"), + ("GitHubMcpToolConfig", githubConfig)); + using var resumeDocument = JsonDocument.Parse(JsonSerializer.Serialize(resumeRequest, resumeRequestType, options)); + Assert.True(resumeDocument.RootElement.TryGetProperty("githubMcpToolConfig", out _)); + } + + [Fact] + public void SessionRequests_OmitGitHubMcpToolConfig_WhenUnset() + { + var options = GetSerializerOptions(); + foreach (var requestName in new[] { "CreateSessionRequest", "ResumeSessionRequest" }) + { + var requestType = GetNestedType(typeof(CopilotClient), requestName); + var request = CreateInternalRequest(requestType, ("SessionId", "session-id")); + using var document = JsonDocument.Parse(JsonSerializer.Serialize(request, requestType, options)); + Assert.False(document.RootElement.TryGetProperty("githubMcpToolConfig", out _)); + } + } + [Fact] public void SessionRequests_CanSerializeReasoningSummary_WithSdkOptions() { diff --git a/go/client.go b/go/client.go index 0d07e21f54..0c5ac7a396 100644 --- a/go/client.go +++ b/go/client.go @@ -848,6 +848,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.EnableMCPApps { req.RequestMCPApps = Bool(true) } + req.GitHubMCPToolConfig = config.GitHubMCPToolConfig if config.Streaming != nil { req.Streaming = config.Streaming @@ -1220,6 +1221,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.EnableMCPApps { req.RequestMCPApps = Bool(true) } + req.GitHubMCPToolConfig = config.GitHubMCPToolConfig traceparent, tracestate := getTraceContext(ctx) req.Traceparent = traceparent diff --git a/go/client_test.go b/go/client_test.go index 3e4675d0b3..2301c990d3 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -2363,6 +2363,68 @@ func TestResumeSessionRequest_RequestMCPApps(t *testing.T) { }) } +func TestSessionRequests_GitHubMCPToolConfig(t *testing.T) { + config := &GitHubMCPToolConfig{ + EnableAllTools: Bool(true), + AdditionalToolsets: []string{"repos"}, + AdditionalTools: []string{"get_issue"}, + EnableInsidersMode: Bool(true), + DisableFormDeferral: Bool(true), + } + expected := map[string]any{ + "enableAllTools": true, + "additionalToolsets": []any{"repos"}, + "additionalTools": []any{"get_issue"}, + "enableInsidersMode": true, + "disableFormDeferral": true, + } + + t.Run("create", func(t *testing.T) { + data, err := json.Marshal(createSessionRequest{GitHubMCPToolConfig: config}) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if !reflect.DeepEqual(payload["githubMcpToolConfig"], expected) { + t.Fatalf("Unexpected githubMcpToolConfig: %#v", payload["githubMcpToolConfig"]) + } + }) + + t.Run("resume", func(t *testing.T) { + data, err := json.Marshal(resumeSessionRequest{ + SessionID: "s1", + GitHubMCPToolConfig: config, + }) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if !reflect.DeepEqual(payload["githubMcpToolConfig"], expected) { + t.Fatalf("Unexpected githubMcpToolConfig: %#v", payload["githubMcpToolConfig"]) + } + }) + + t.Run("unset is omitted", func(t *testing.T) { + data, err := json.Marshal(createSessionRequest{}) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if _, ok := payload["githubMcpToolConfig"]; ok { + t.Fatal("Expected githubMcpToolConfig to be omitted") + } + }) +} + func TestResumeSessionRequest_ModeCallbackFlags(t *testing.T) { req := resumeSessionRequest{ SessionID: "s1", diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index 672a905dc0..2ce48e3b33 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -5,10 +5,12 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/http" "os" "path/filepath" "strings" "testing" + "time" copilot "github.com/github/copilot-sdk/go" "github.com/github/copilot-sdk/go/internal/e2e/testharness" @@ -988,6 +990,79 @@ func TestSessionConfigExtrasE2E(t *testing.T) { t.Errorf("Expected toolNames=[view], got %v", toolNames) } }) + + t.Run("should apply GitHub MCP tool config on create", func(t *testing.T) { + ctx.ConfigureForTest(t) + enableAllTools := true + enableInsidersMode := true + disableFormDeferral := true + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + EnableConfigDiscovery: copilot.Bool(true), + EnableMCPApps: true, + GitHubMCPToolConfig: &copilot.GitHubMCPToolConfig{ + EnableAllTools: &enableAllTools, + AdditionalToolsets: []string{"actions"}, + AdditionalTools: []string{"get_me"}, + EnableInsidersMode: &enableInsidersMode, + DisableFormDeferral: &disableFormDeferral, + }, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + t.Cleanup(func() { _ = session.Disconnect() }) + + assertGitHubMCPConfigApplied(t, ctx, session) + }) +} + +func assertGitHubMCPConfigApplied(t *testing.T, ctx *testharness.TestContext, session *copilot.Session) { + t.Helper() + if _, err := session.RPC.MCP.List(t.Context()); err != nil { + t.Fatalf("MCP.List failed: %v", err) + } + deadline := time.Now().Add(60 * time.Second) + var lastRequests []testharness.CapturedRequest + for time.Now().Before(deadline) { + requests, err := ctx.GetRequests() + if err == nil { + lastRequests = requests + var writableRequest *testharness.CapturedRequest + hasReadonlyRequest := false + for i := range requests { + request := &requests[i] + if request.URL == "/mcp/readonly" { + hasReadonlyRequest = true + } + if request.Method == http.MethodPost && request.URL == "/mcp" { + writableRequest = request + } + } + if writableRequest != nil { + if hasReadonlyRequest { + t.Fatalf("Expected writable GitHub MCP endpoint, got requests: %+v", requests) + } + assertCapturedHeader(t, writableRequest.Headers, "x-mcp-toolsets", "all") + assertCapturedHeader(t, writableRequest.Headers, "x-mcp-insiders", "true") + return + } + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("Timed out waiting for configured GitHub MCP request; captured: %+v", lastRequests) +} + +func assertCapturedHeader(t *testing.T, headers map[string]json.RawMessage, name, expected string) { + t.Helper() + var actual string + if err := json.Unmarshal(headers[name], &actual); err != nil { + t.Fatalf("Failed to decode %s header: %v", name, err) + } + if actual != expected { + t.Fatalf("Expected %s=%q, got %q", name, expected, actual) + } } // createProxyProvider returns a ProviderConfig that points at the test proxy and diff --git a/go/internal/e2e/testharness/context.go b/go/internal/e2e/testharness/context.go index b73153dfff..03ebf24cbf 100644 --- a/go/internal/e2e/testharness/context.go +++ b/go/internal/e2e/testharness/context.go @@ -312,6 +312,11 @@ func (c *TestContext) GetExchanges() ([]ParsedHttpExchange, error) { return c.proxy.GetExchanges() } +// GetRequests retrieves all captured outbound HTTP requests from the proxy. +func (c *TestContext) GetRequests() ([]CapturedRequest, error) { + return c.proxy.GetRequests() +} + // WaitForExchanges waits until the proxy has captured at least the requested exchanges. func (c *TestContext) WaitForExchanges(t *testing.T, minimumCount int) []ParsedHttpExchange { t.Helper() diff --git a/go/internal/e2e/testharness/proxy.go b/go/internal/e2e/testharness/proxy.go index ec16124bc6..2545882bce 100644 --- a/go/internal/e2e/testharness/proxy.go +++ b/go/internal/e2e/testharness/proxy.go @@ -188,6 +188,38 @@ func (p *CapiProxy) GetExchanges() ([]ParsedHttpExchange, error) { return exchanges, nil } +// GetRequests retrieves all captured outbound HTTP requests from the proxy. +func (p *CapiProxy) GetRequests() ([]CapturedRequest, error) { + p.mu.Lock() + url := p.proxyURL + p.mu.Unlock() + + if url == "" { + return nil, fmt.Errorf("proxy not started") + } + + resp, err := http.Get(url + "/requests") + if err != nil { + return nil, fmt.Errorf("failed to get requests: %w", err) + } + defer resp.Body.Close() + + var requests []CapturedRequest + if err := json.NewDecoder(resp.Body).Decode(&requests); err != nil { + return nil, fmt.Errorf("failed to decode requests: %w", err) + } + + return requests, nil +} + +// CapturedRequest represents an outbound HTTP request captured by the proxy. +type CapturedRequest struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]json.RawMessage `json:"headers"` + Body string `json:"body"` +} + // ParsedHttpExchange represents a captured HTTP exchange. type ParsedHttpExchange struct { Request ChatCompletionRequest `json:"request"` diff --git a/go/types.go b/go/types.go index b0fd9c06ef..31a8e355d9 100644 --- a/go/types.go +++ b/go/types.go @@ -1140,6 +1140,18 @@ func (e ExpConfigEntry) MarshalJSON() ([]byte, error) { return json.Marshal(w) } +// GitHubMCPToolConfig configures the built-in GitHub MCP server. +// +// DisableFormDeferral only applies to the built-in GitHub MCP server and only +// has an effect when MCP Apps and form-backed GitHub tools are enabled. +type GitHubMCPToolConfig struct { + EnableAllTools *bool `json:"enableAllTools,omitempty"` + AdditionalToolsets []string `json:"additionalToolsets,omitempty"` + AdditionalTools []string `json:"additionalTools,omitempty"` + EnableInsidersMode *bool `json:"enableInsidersMode,omitempty"` + DisableFormDeferral *bool `json:"disableFormDeferral,omitempty"` +} + // SessionConfig configures a new session type SessionConfig struct { // SessionID is an optional custom session ID @@ -1379,6 +1391,10 @@ type SessionConfig struct { // cause MCP servers to register UI-enabled tool variants the consumer cannot // display. EnableMCPApps bool + // GitHubMCPToolConfig configures the built-in GitHub MCP server. + // DisableFormDeferral only applies to that server and only has an effect + // when MCP Apps and form-backed GitHub tools are enabled. + GitHubMCPToolConfig *GitHubMCPToolConfig // 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. @@ -1847,6 +1863,10 @@ type ResumeSessionConfig struct { // Experimental: EnableMCPApps is part of an experimental wire-protocol // surface (SEP-1865) and may change or be removed in a future release. EnableMCPApps bool + // GitHubMCPToolConfig configures the built-in GitHub MCP server. + // DisableFormDeferral only applies to that server and only has an effect + // when MCP Apps and form-backed GitHub tools are enabled. + GitHubMCPToolConfig *GitHubMCPToolConfig // Canvases declares canvases this session provides. Sent over the wire on // `session.resume`. See SessionConfig.Canvases. Canvases []CanvasDeclaration @@ -2327,6 +2347,7 @@ type createSessionRequest struct { Commands []wireCommand `json:"commands,omitempty"` RequestElicitation *bool `json:"requestElicitation,omitempty"` RequestMCPApps *bool `json:"requestMcpApps,omitempty"` + GitHubMCPToolConfig *GitHubMCPToolConfig `json:"githubMcpToolConfig,omitempty"` GitHubToken string `json:"gitHubToken,omitempty"` RemoteSession rpc.RemoteSessionMode `json:"remoteSession,omitempty"` Cloud *CloudSessionOptions `json:"cloud,omitempty"` @@ -2419,6 +2440,7 @@ type resumeSessionRequest struct { Commands []wireCommand `json:"commands,omitempty"` RequestElicitation *bool `json:"requestElicitation,omitempty"` RequestMCPApps *bool `json:"requestMcpApps,omitempty"` + GitHubMCPToolConfig *GitHubMCPToolConfig `json:"githubMcpToolConfig,omitempty"` GitHubToken string `json:"gitHubToken,omitempty"` RemoteSession rpc.RemoteSessionMode `json:"remoteSession,omitempty"` Canvases []CanvasDeclaration `json:"canvases,omitempty"` diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index 57d9a46a21..545d8a9f20 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -176,6 +176,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess if (config.isEnableMcpApps()) { request.setRequestMcpApps(true); } + request.setGitHubMcpToolConfig(config.getGitHubMcpToolConfig()); if (config.getOnExitPlanMode() != null) { request.setRequestExitPlanMode(true); } @@ -300,6 +301,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo if (config.isEnableMcpApps()) { request.setRequestMcpApps(true); } + request.setGitHubMcpToolConfig(config.getGitHubMcpToolConfig()); if (config.getOnExitPlanMode() != null) { request.setRequestExitPlanMode(true); } diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 9a9f4f152f..651ca8d642 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -193,6 +193,9 @@ public final class CreateSessionRequest { @JsonProperty("requestMcpApps") private Boolean requestMcpApps; + @JsonProperty("githubMcpToolConfig") + private GitHubMcpToolConfig githubMcpToolConfig; + @JsonProperty("requestExitPlanMode") private Boolean requestExitPlanMode; @@ -902,6 +905,16 @@ public void clearRequestMcpApps() { this.requestMcpApps = null; } + /** Gets the GitHub MCP tool configuration. @return the configuration */ + public GitHubMcpToolConfig getGitHubMcpToolConfig() { + return githubMcpToolConfig; + } + + /** Sets the GitHub MCP tool configuration. @param config the value */ + public void setGitHubMcpToolConfig(GitHubMcpToolConfig config) { + this.githubMcpToolConfig = config; + } + /** Gets the requestExitPlanMode flag. @return the flag */ public Boolean getRequestExitPlanMode() { return requestExitPlanMode; diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubMcpToolConfig.java b/java/src/main/java/com/github/copilot/rpc/GitHubMcpToolConfig.java new file mode 100644 index 0000000000..75a8e30163 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/GitHubMcpToolConfig.java @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Configuration for the built-in GitHub MCP server. + * + *

+ * {@code disableFormDeferral} only applies to the built-in GitHub MCP server + * and only has an effect when MCP Apps and form-backed GitHub tools are + * enabled. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class GitHubMcpToolConfig { + + @JsonProperty("enableAllTools") + private Boolean enableAllTools; + + @JsonProperty("additionalToolsets") + private List additionalToolsets; + + @JsonProperty("additionalTools") + private List additionalTools; + + @JsonProperty("enableInsidersMode") + private Boolean enableInsidersMode; + + @JsonProperty("disableFormDeferral") + private Boolean disableFormDeferral; + + public Boolean getEnableAllTools() { + return enableAllTools; + } + + public GitHubMcpToolConfig setEnableAllTools(Boolean enableAllTools) { + this.enableAllTools = enableAllTools; + return this; + } + + public List getAdditionalToolsets() { + return additionalToolsets; + } + + public GitHubMcpToolConfig setAdditionalToolsets(List additionalToolsets) { + this.additionalToolsets = additionalToolsets; + return this; + } + + public List getAdditionalTools() { + return additionalTools; + } + + public GitHubMcpToolConfig setAdditionalTools(List additionalTools) { + this.additionalTools = additionalTools; + return this; + } + + public Boolean getEnableInsidersMode() { + return enableInsidersMode; + } + + public GitHubMcpToolConfig setEnableInsidersMode(Boolean enableInsidersMode) { + this.enableInsidersMode = enableInsidersMode; + return this; + } + + public Boolean getDisableFormDeferral() { + return disableFormDeferral; + } + + public GitHubMcpToolConfig setDisableFormDeferral(Boolean disableFormDeferral) { + this.disableFormDeferral = disableFormDeferral; + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index a3dd336cf5..59f75e8e46 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -99,6 +99,7 @@ public class ResumeSessionConfig { private ExitPlanModeHandler onExitPlanMode; private AutoModeSwitchHandler onAutoModeSwitch; private boolean enableMcpApps; + private GitHubMcpToolConfig githubMcpToolConfig; private String gitHubToken; private String remoteSession; private CopilotExpAssignmentResponse expAssignments; @@ -1634,6 +1635,27 @@ public ResumeSessionConfig setEnableMcpApps(boolean enableMcpApps) { return this; } + /** + * Gets the configuration for the built-in GitHub MCP server. + * + * @return the GitHub MCP configuration, or {@code null} + */ + public GitHubMcpToolConfig getGitHubMcpToolConfig() { + return githubMcpToolConfig; + } + + /** + * Sets the configuration for the built-in GitHub MCP server. + * + * @param githubMcpToolConfig + * the GitHub MCP configuration + * @return this config instance for method chaining + */ + public ResumeSessionConfig setGitHubMcpToolConfig(GitHubMcpToolConfig githubMcpToolConfig) { + this.githubMcpToolConfig = githubMcpToolConfig; + return this; + } + /** * Gets the exit-plan-mode request handler. * @@ -1870,6 +1892,7 @@ public ResumeSessionConfig clone() { copy.onExitPlanMode = this.onExitPlanMode; copy.onAutoModeSwitch = this.onAutoModeSwitch; copy.enableMcpApps = this.enableMcpApps; + copy.githubMcpToolConfig = this.githubMcpToolConfig; copy.gitHubToken = this.gitHubToken; copy.remoteSession = this.remoteSession; copy.expAssignments = this.expAssignments; diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index a81ed49dde..9e6ba77496 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -198,6 +198,9 @@ public final class ResumeSessionRequest { @JsonProperty("requestMcpApps") private Boolean requestMcpApps; + @JsonProperty("githubMcpToolConfig") + private GitHubMcpToolConfig githubMcpToolConfig; + @JsonProperty("requestExitPlanMode") private Boolean requestExitPlanMode; @@ -927,6 +930,16 @@ public void clearRequestMcpApps() { this.requestMcpApps = null; } + /** Gets the GitHub MCP tool configuration. @return the configuration */ + public GitHubMcpToolConfig getGitHubMcpToolConfig() { + return githubMcpToolConfig; + } + + /** Sets the GitHub MCP tool configuration. @param config the value */ + public void setGitHubMcpToolConfig(GitHubMcpToolConfig config) { + this.githubMcpToolConfig = config; + } + /** Gets the requestExitPlanMode flag. @return the flag */ public Boolean getRequestExitPlanMode() { return requestExitPlanMode; diff --git a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java index 0e02482de4..327491b5fe 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -99,6 +99,7 @@ public class SessionConfig { private ExitPlanModeHandler onExitPlanMode; private AutoModeSwitchHandler onAutoModeSwitch; private boolean enableMcpApps; + private GitHubMcpToolConfig githubMcpToolConfig; private String gitHubToken; private String remoteSession; private CloudSessionOptions cloud; @@ -1718,6 +1719,27 @@ public SessionConfig setEnableMcpApps(boolean enableMcpApps) { return this; } + /** + * Gets the configuration for the built-in GitHub MCP server. + * + * @return the GitHub MCP configuration, or {@code null} + */ + public GitHubMcpToolConfig getGitHubMcpToolConfig() { + return githubMcpToolConfig; + } + + /** + * Sets the configuration for the built-in GitHub MCP server. + * + * @param githubMcpToolConfig + * the GitHub MCP configuration + * @return this config instance for method chaining + */ + public SessionConfig setGitHubMcpToolConfig(GitHubMcpToolConfig githubMcpToolConfig) { + this.githubMcpToolConfig = githubMcpToolConfig; + return this; + } + /** * Gets the exit-plan-mode request handler. * @@ -2007,6 +2029,7 @@ public SessionConfig clone() { copy.onExitPlanMode = this.onExitPlanMode; copy.onAutoModeSwitch = this.onAutoModeSwitch; copy.enableMcpApps = this.enableMcpApps; + copy.githubMcpToolConfig = this.githubMcpToolConfig; copy.gitHubToken = this.gitHubToken; copy.remoteSession = this.remoteSession; copy.cloud = this.cloud; diff --git a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 652be026b1..4efdafd0e4 100644 --- a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -24,6 +24,7 @@ import com.github.copilot.rpc.ElicitationResultAction; import com.github.copilot.rpc.ExitPlanModeResult; import com.github.copilot.rpc.ExpConfigEntry; +import com.github.copilot.rpc.GitHubMcpToolConfig; import com.github.copilot.rpc.LargeToolOutputConfig; import com.github.copilot.rpc.MemoryConfiguration; import com.github.copilot.rpc.ResumeSessionConfig; @@ -958,4 +959,23 @@ void testClonePreservesAndForwardsExpAssignments() throws Exception { assertEquals(resumeAssignments, resumeRequest.getExpAssignments()); assertTrue(mapper.writeValueAsString(resumeRequest).contains("\"Id\":\"exp-resume\"")); } + + @Test + void githubMcpToolConfigIsMappedAndSerializedForCreateAndResume() throws Exception { + var config = new GitHubMcpToolConfig().setEnableAllTools(true).setAdditionalToolsets(List.of("repos")) + .setAdditionalTools(List.of("get_issue")).setEnableInsidersMode(true).setDisableFormDeferral(true); + var createRequest = SessionRequestBuilder.buildCreateRequest(new SessionConfig().setGitHubMcpToolConfig(config), + "session-1"); + var resumeRequest = SessionRequestBuilder.buildResumeRequest("session-1", + new ResumeSessionConfig().setGitHubMcpToolConfig(config)); + + assertSame(config, createRequest.getGitHubMcpToolConfig()); + assertSame(config, resumeRequest.getGitHubMcpToolConfig()); + var mapper = JsonRpcClient.getObjectMapper(); + assertTrue(mapper.writeValueAsString(createRequest).contains("\"githubMcpToolConfig\"")); + assertTrue(mapper.writeValueAsString(resumeRequest).contains("\"githubMcpToolConfig\"")); + assertFalse( + mapper.writeValueAsString(SessionRequestBuilder.buildCreateRequest(new SessionConfig(), "session-2")) + .contains("\"githubMcpToolConfig\"")); + } } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 61f4a99416..a09a55a2c4 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1557,6 +1557,9 @@ export class CopilotClient { requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, ...(config.enableMcpApps ? { requestMcpApps: true } : {}), + ...(config.githubMcpToolConfig != null + ? { githubMcpToolConfig: config.githubMcpToolConfig } + : {}), requestExitPlanMode: !!config.onExitPlanModeRequest, requestAutoModeSwitch: !!config.onAutoModeSwitchRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), @@ -1770,6 +1773,9 @@ export class CopilotClient { requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, ...(config.enableMcpApps ? { requestMcpApps: true } : {}), + ...(config.githubMcpToolConfig != null + ? { githubMcpToolConfig: config.githubMcpToolConfig } + : {}), requestExitPlanMode: !!config.onExitPlanModeRequest, requestAutoModeSwitch: !!config.onAutoModeSwitchRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 352afc6a94..07643a812b 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -85,6 +85,7 @@ export type { ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, + GitHubMcpToolConfig, GitHubTelemetryNotification, GitHubTelemetryEvent, GitHubTelemetryClientInfo, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 5f89ca3bed..208097606e 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1953,6 +1953,20 @@ export interface CopilotExpAssignmentResponse { AssignmentContext: string; } +/** + * Configuration for the built-in GitHub MCP server. + * + * `disableFormDeferral` only applies to the built-in GitHub MCP server and + * only has an effect when MCP Apps and form-backed GitHub tools are enabled. + */ +export interface GitHubMcpToolConfig { + enableAllTools?: boolean; + additionalToolsets?: string[]; + additionalTools?: string[]; + enableInsidersMode?: boolean; + disableFormDeferral?: boolean; +} + /** * Shared configuration fields used by both {@link SessionConfig} (for * creating a new session) and {@link ResumeSessionConfig} (for resuming @@ -2287,6 +2301,14 @@ export interface SessionConfigBase { */ enableMcpApps?: boolean; + /** + * Configuration for the built-in GitHub MCP server. + * + * `disableFormDeferral` only applies to the built-in GitHub MCP server and + * only has an effect when MCP Apps and form-backed GitHub tools are enabled. + */ + githubMcpToolConfig?: GitHubMcpToolConfig; + /** * Handler for exit-plan-mode requests from the agent. * When provided, enables `exitPlanMode.request` callbacks. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 77149bc4b9..974e1c971b 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -102,6 +102,55 @@ describe("CopilotClient", () => { }); }); + it("forwards GitHub MCP tool config on create and resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + const githubMcpToolConfig = { + enableAllTools: true, + additionalToolsets: ["repos"], + additionalTools: ["get_issue"], + enableInsidersMode: true, + disableFormDeferral: true, + }; + + const session = await client.createSession({ githubMcpToolConfig }); + await client.resumeSession(session.sessionId, { githubMcpToolConfig }); + + expect(spy.mock.calls.find(([method]) => method === "session.create")![1]).toMatchObject({ + githubMcpToolConfig, + }); + expect(spy.mock.calls.find(([method]) => method === "session.resume")![1]).toMatchObject({ + githubMcpToolConfig, + }); + }); + + it("omits GitHub MCP tool config when unset", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.createSession({}); + + expect( + spy.mock.calls.find(([method]) => method === "session.create")![1] + ).not.toHaveProperty("githubMcpToolConfig"); + }); + it("passes MCP OAuth requests through when optional metadata is absent", async () => { let observedRequest: any; const session = new CopilotSession( diff --git a/nodejs/test/e2e/harness/CapiProxy.ts b/nodejs/test/e2e/harness/CapiProxy.ts index a6232587e2..c25d422f99 100644 --- a/nodejs/test/e2e/harness/CapiProxy.ts +++ b/nodejs/test/e2e/harness/CapiProxy.ts @@ -2,6 +2,7 @@ import { spawn } from "child_process"; import { resolve } from "path"; import { createInterface } from "readline"; import { expect } from "vitest"; +import type { CapturedRequest } from "../../../../test/harness/replayingCapiProxy"; import { CopilotUserResponse, ParsedHttpExchange, @@ -121,6 +122,11 @@ export class CapiProxy { return await response.json(); } + async getRequests(): Promise { + const response = await fetch(`${this.proxyUrl}/requests`, { method: "GET" }); + return await response.json(); + } + async stop(skipWritingCache?: boolean): Promise { const url = skipWritingCache ? `${this.proxyUrl}/stop?skipWritingCache=true` diff --git a/nodejs/test/e2e/session_config.e2e.test.ts b/nodejs/test/e2e/session_config.e2e.test.ts index 98b1a0bfaa..85137e0ff9 100644 --- a/nodejs/test/e2e/session_config.e2e.test.ts +++ b/nodejs/test/e2e/session_config.e2e.test.ts @@ -222,6 +222,23 @@ describe("Session Configuration", async () => { return (exchange.request.tools ?? []).map((t) => t.function.name); } + async function expectGitHubMcpConfigApplied(session: CopilotSession): Promise { + await session.rpc.mcp.list(); + await retry("capture configured GitHub MCP request", async () => { + const requests = await openAiEndpoint.getRequests(); + const request = requests.find( + (entry) => entry.method === "POST" && entry.url === "/mcp" + ); + expect( + request, + `captured requests: ${requests.map((entry) => `${entry.method} ${entry.url}`).join(", ")}` + ).toBeDefined(); + expect(request?.headers["x-mcp-toolsets"]).toBe("all"); + expect(request?.headers["x-mcp-insiders"]).toBe("true"); + expect(requests.some((entry) => entry.url === "/mcp/readonly")).toBe(false); + }); + } + async function sendAndGetNextExchange( session: { sendAndWait(options: { prompt: string }): Promise }, prompt: string @@ -848,4 +865,25 @@ describe("Session Configuration", async () => { await session2.disconnect(); } }); + + it("should apply GitHub MCP tool config on create", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + enableConfigDiscovery: true, + enableMcpApps: true, + githubMcpToolConfig: { + enableAllTools: true, + additionalToolsets: ["actions"], + additionalTools: ["get_me"], + enableInsidersMode: true, + disableFormDeferral: true, + }, + }); + + try { + await expectGitHubMcpConfigApplied(session); + } finally { + await session.disconnect(); + } + }); }); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 27c220ecf2..2f5b9b3309 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -113,6 +113,7 @@ ExitPlanModeHandler, ExitPlanModeRequest, ExitPlanModeResult, + GitHubMcpToolConfig, InfiniteSessionConfig, InputOptions, LargeToolOutputConfig, @@ -247,6 +248,7 @@ "GetAuthStatusResponse", "BearerTokenProvider", "GetStatusResponse", + "GitHubMcpToolConfig", "GitHubTelemetryClientInfo", "GitHubTelemetryEvent", "GitHubTelemetryNotification", diff --git a/python/copilot/client.py b/python/copilot/client.py index a7706dfe57..907ce61cdd 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -94,6 +94,7 @@ DefaultAgentConfig, ElicitationHandler, ExitPlanModeHandler, + GitHubMcpToolConfig, InfiniteSessionConfig, LargeToolOutputConfig, McpAuthHandler, @@ -334,6 +335,22 @@ def _tool_search_to_wire(config: Mapping[str, Any]) -> dict[str, Any]: return wire +def _github_mcp_tool_config_to_wire(config: Mapping[str, Any]) -> dict[str, Any]: + """Convert a ``GitHubMcpToolConfig`` mapping to wire format.""" + wire: dict[str, Any] = {} + if "enable_all_tools" in config: + wire["enableAllTools"] = config["enable_all_tools"] + if "additional_toolsets" in config: + wire["additionalToolsets"] = config["additional_toolsets"] + if "additional_tools" in config: + wire["additionalTools"] = config["additional_tools"] + if "enable_insiders_mode" in config: + wire["enableInsidersMode"] = config["enable_insiders_mode"] + if "disable_form_deferral" in config: + wire["disableFormDeferral"] = config["disable_form_deferral"] + return wire + + class TelemetryConfig(TypedDict, total=False): """Configuration for OpenTelemetry integration with the Copilot CLI.""" @@ -2067,6 +2084,7 @@ async def create_session( canvas_handler: CanvasHandler | None = None, exp_assignments: CopilotExpAssignmentResponse | None = None, enable_managed_settings: bool | None = None, + github_mcp_tool_config: GitHubMcpToolConfig | None = None, ) -> CopilotSession: """ Create a new conversation session with the Copilot CLI. @@ -2187,6 +2205,16 @@ async def create_session( override) is on; otherwise the request is silently dropped. Inspect ``capabilities.ui.mcpApps`` on the create response to detect the drop. + github_mcp_tool_config: Configuration for the built-in GitHub MCP + server, sent as ``githubMcpToolConfig`` on ``session.create``. + Supports ``enable_all_tools``, ``additional_toolsets``, + ``additional_tools``, ``enable_insiders_mode``, and + ``disable_form_deferral``. Setting ``disable_form_deferral`` + makes form-backed GitHub write tools execute directly instead + of returning an awaiting-form stub; it does not enable MCP Apps + on its own and has no effect unless MCP Apps are enabled for + the session (see ``enable_mcp_apps``). Omitted from the wire + payload entirely when None. exp_assignments: ExP assignment ("flight") data injected by a trusted integrator, in the same JSON shape the Copilot CLI fetches from the experimentation service @@ -2312,6 +2340,8 @@ async def create_session( payload["requestElicitation"] = bool(on_elicitation_request) if enable_mcp_apps: payload["requestMcpApps"] = True + if github_mcp_tool_config is not None: + payload["githubMcpToolConfig"] = _github_mcp_tool_config_to_wire(github_mcp_tool_config) payload["requestExitPlanMode"] = bool(on_exit_plan_mode_request) payload["requestAutoModeSwitch"] = bool(on_auto_mode_switch_request) @@ -2740,6 +2770,7 @@ async def resume_session( open_canvases: list[OpenCanvasInstance] | None = None, exp_assignments: CopilotExpAssignmentResponse | None = None, enable_managed_settings: bool | None = None, + github_mcp_tool_config: GitHubMcpToolConfig | None = None, ) -> CopilotSession: """ Resume an existing conversation session by its ID. @@ -2857,6 +2888,16 @@ async def resume_session( override) is on; otherwise the request is silently dropped. Inspect ``capabilities.ui.mcpApps`` on the resume response to detect the drop. + github_mcp_tool_config: Configuration for the built-in GitHub MCP + server, sent as ``githubMcpToolConfig`` on ``session.resume``. + Supports ``enable_all_tools``, ``additional_toolsets``, + ``additional_tools``, ``enable_insiders_mode``, and + ``disable_form_deferral``. Setting ``disable_form_deferral`` + makes form-backed GitHub write tools execute directly instead + of returning an awaiting-form stub; it does not enable MCP Apps + on its own and has no effect unless MCP Apps are enabled for + the session (see ``enable_mcp_apps``). Omitted from the wire + payload entirely when None. 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 @@ -3014,6 +3055,8 @@ async def resume_session( payload["requestElicitation"] = bool(on_elicitation_request) if enable_mcp_apps: payload["requestMcpApps"] = True + if github_mcp_tool_config is not None: + payload["githubMcpToolConfig"] = _github_mcp_tool_config_to_wire(github_mcp_tool_config) payload["requestExitPlanMode"] = bool(on_exit_plan_mode_request) payload["requestAutoModeSwitch"] = bool(on_auto_mode_switch_request) diff --git a/python/copilot/session.py b/python/copilot/session.py index d9fc04dcef..9c467e086b 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -1084,6 +1084,22 @@ class MCPHTTPServerConfig(TypedDict, total=False): MCPServerConfig = MCPStdioServerConfig | MCPHTTPServerConfig + +class GitHubMcpToolConfig(TypedDict, total=False): + """Configuration for the built-in GitHub MCP server. + + ``disable_form_deferral`` only applies to the built-in GitHub MCP server + and only has an effect when MCP Apps and form-backed GitHub tools are + enabled. + """ + + enable_all_tools: bool + additional_toolsets: list[str] + additional_tools: list[str] + enable_insiders_mode: bool + disable_form_deferral: bool + + # ============================================================================ # Custom Agent Configuration Types # ============================================================================ diff --git a/python/test_client.py b/python/test_client.py index a37d8dce2b..ba353bde38 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -504,6 +504,46 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_and_resume_session_forward_github_mcp_tool_config(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + config = { + "enable_all_tools": True, + "additional_toolsets": ["repos"], + "additional_tools": ["get_issue"], + "enable_insiders_mode": True, + "disable_form_deferral": True, + } + session = await client.create_session(github_mcp_tool_config=config) + await client.resume_session(session.session_id, github_mcp_tool_config=config) + + expected = { + "enableAllTools": True, + "additionalToolsets": ["repos"], + "additionalTools": ["get_issue"], + "enableInsidersMode": True, + "disableFormDeferral": True, + } + assert captured["session.create"]["githubMcpToolConfig"] == expected + assert captured["session.resume"]["githubMcpToolConfig"] == expected + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_and_resume_session_forward_reasoning_summary(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) diff --git a/rust/src/types.rs b/rust/src/types.rs index 163e1d29c0..064b96ec7c 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -824,6 +824,80 @@ impl ToolSearchConfig { } } +/// Configuration for the built-in GitHub MCP server. +/// +/// `disable_form_deferral` only applies to the built-in GitHub MCP server and +/// only has an effect when MCP Apps and form-backed GitHub tools are enabled. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct GitHubMcpToolConfig { + /// Whether all GitHub MCP tools are enabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_all_tools: Option, + /// Additional GitHub MCP toolsets to enable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional_toolsets: Option>, + /// Additional GitHub MCP tools to enable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional_tools: Option>, + /// Whether GitHub MCP insiders mode is enabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_insiders_mode: Option, + /// Disables form deferral for GitHub MCP tools. This only applies to the + /// built-in GitHub MCP server and only has an effect when MCP Apps and + /// form-backed GitHub tools are enabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disable_form_deferral: Option, +} + +impl GitHubMcpToolConfig { + /// Construct an empty GitHub MCP tool configuration. + pub fn new() -> Self { + Self::default() + } + + /// Set whether all GitHub MCP tools are enabled. + pub fn with_enable_all_tools(mut self, value: bool) -> Self { + self.enable_all_tools = Some(value); + self + } + + /// Set the additional GitHub MCP toolsets to enable. + pub fn with_additional_toolsets(mut self, values: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.additional_toolsets = Some(values.into_iter().map(Into::into).collect()); + self + } + + /// Set the additional GitHub MCP tools to enable. + pub fn with_additional_tools(mut self, values: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.additional_tools = Some(values.into_iter().map(Into::into).collect()); + self + } + + /// Set whether GitHub MCP insiders mode is enabled. + pub fn with_enable_insiders_mode(mut self, value: bool) -> Self { + self.enable_insiders_mode = Some(value); + self + } + + /// Disable form deferral for GitHub MCP tools. This only applies to the + /// built-in GitHub MCP server and only has an effect when MCP Apps and + /// form-backed GitHub tools are enabled. + pub fn with_disable_form_deferral(mut self, value: bool) -> Self { + self.disable_form_deferral = Some(value); + self + } +} + /// Configures infinite sessions: persistent workspaces with automatic /// context-window compaction. /// @@ -1831,6 +1905,11 @@ pub struct SessionConfig { /// /// Defaults to `None` (treated as `false`). pub enable_mcp_apps: Option, + /// Configuration for the built-in GitHub MCP server. + /// + /// `disable_form_deferral` only applies to that server and only has an + /// effect when MCP Apps and form-backed GitHub tools are enabled. + pub github_mcp_tool_config: Option, /// Skill directory paths passed through to the GitHub Copilot CLI. pub skill_directories: Option>, /// Additional directories to search for custom instruction files. @@ -2164,6 +2243,7 @@ impl Default for SessionConfig { enable_skills: None, embedding_cache_storage: None, enable_mcp_apps: None, + github_mcp_tool_config: None, skill_directories: None, instruction_directories: None, plugin_directories: None, @@ -2322,6 +2402,7 @@ impl SessionConfig { request_auto_mode_switch, request_elicitation, request_mcp_apps: self.enable_mcp_apps.unwrap_or(false), + github_mcp_tool_config: self.github_mcp_tool_config, hooks: hooks_flag, skill_directories: self.skill_directories, instruction_directories: self.instruction_directories, @@ -2701,6 +2782,12 @@ impl SessionConfig { self } + /// Set the built-in GitHub MCP server configuration. + pub fn with_github_mcp_tool_config(mut self, config: GitHubMcpToolConfig) -> Self { + self.github_mcp_tool_config = Some(config); + self + } + /// Set skill directory paths passed through to the CLI. pub fn with_skill_directories(mut self, paths: I) -> Self where @@ -3034,6 +3121,11 @@ pub struct ResumeSessionConfig { /// Enable MCP Apps (SEP-1865) UI passthrough on resume. See /// [`SessionConfig::enable_mcp_apps`]. Defaults to `None` (treated as `false`). pub enable_mcp_apps: Option, + /// Configuration for the built-in GitHub MCP server. + /// + /// `disable_form_deferral` only applies to that server and only has an + /// effect when MCP Apps and form-backed GitHub tools are enabled. + pub github_mcp_tool_config: Option, /// Skill directory paths passed through to the GitHub Copilot CLI on resume. pub skill_directories: Option>, /// Additional directories to search for custom instruction files on @@ -3376,6 +3468,7 @@ impl ResumeSessionConfig { request_auto_mode_switch, request_elicitation, request_mcp_apps: self.enable_mcp_apps.unwrap_or(false), + github_mcp_tool_config: self.github_mcp_tool_config, hooks: hooks_flag, skill_directories: self.skill_directories, instruction_directories: self.instruction_directories, @@ -3467,6 +3560,7 @@ impl ResumeSessionConfig { enable_skills: None, embedding_cache_storage: None, enable_mcp_apps: None, + github_mcp_tool_config: None, skill_directories: None, instruction_directories: None, plugin_directories: None, @@ -3825,6 +3919,12 @@ impl ResumeSessionConfig { self } + /// Set the built-in GitHub MCP server configuration. + pub fn with_github_mcp_tool_config(mut self, config: GitHubMcpToolConfig) -> Self { + self.github_mcp_tool_config = Some(config); + self + } + /// Set skill directory paths passed through to the CLI on resume. pub fn with_skill_directories(mut self, paths: I) -> Self where @@ -5490,12 +5590,12 @@ mod tests { AgentMode, Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange, AzureProviderOptions, CapiSessionOptions, ConnectionState, CopilotExpAssignmentResponse, CustomAgentConfig, DeliveryMode, ExpConfigEntry, - ExpFlagValue, ExtensionInfo, GitHubReferenceType, InfiniteSessionConfig, - LargeToolOutputConfig, McpServerConfig, McpStdioServerConfig, MemoryConfiguration, - NamedProviderConfig, ProviderConfig, ProviderModelConfig, ReasoningSummary, - ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, SystemMessageConfig, Tool, - ToolBinaryResult, ToolResult, ToolResultExpanded, ToolResultResponse, - ensure_attachment_display_names, + ExpFlagValue, ExtensionInfo, GitHubMcpToolConfig, GitHubReferenceType, + InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, McpStdioServerConfig, + MemoryConfiguration, NamedProviderConfig, ProviderConfig, ProviderModelConfig, + ReasoningSummary, ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, + SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, + ToolResultResponse, ensure_attachment_display_names, }; use crate::generated::session_events::TypedSessionEvent; @@ -5800,6 +5900,47 @@ mod tests { assert_eq!(json["requestMcpApps"], serde_json::Value::Bool(true)); } + #[test] + fn github_mcp_tool_config_serializes_for_create_and_resume() { + let github_config = GitHubMcpToolConfig::new() + .with_enable_all_tools(true) + .with_additional_toolsets(["repos"]) + .with_additional_tools(["get_issue"]) + .with_enable_insiders_mode(true) + .with_disable_form_deferral(true); + + let (create_wire, _) = SessionConfig::default() + .with_github_mcp_tool_config(github_config.clone()) + .into_wire(Some(SessionId::from("github-mcp"))) + .expect("create config has no duplicate handlers"); + assert_eq!( + serde_json::to_value(&create_wire).unwrap()["githubMcpToolConfig"], + serde_json::json!({ + "enableAllTools": true, + "additionalToolsets": ["repos"], + "additionalTools": ["get_issue"], + "enableInsidersMode": true, + "disableFormDeferral": true, + }) + ); + + let (resume_wire, _) = ResumeSessionConfig::new(SessionId::from("github-mcp")) + .with_github_mcp_tool_config(github_config) + .into_wire() + .expect("resume config has no duplicate handlers"); + assert!(resume_wire.github_mcp_tool_config.is_some()); + + let (unset_wire, _) = SessionConfig::default() + .into_wire(Some(SessionId::from("github-mcp-unset"))) + .expect("default config has no duplicate handlers"); + assert!( + serde_json::to_value(&unset_wire) + .unwrap() + .get("githubMcpToolConfig") + .is_none() + ); + } + #[test] fn memory_configuration_constructors_and_serde() { assert!(MemoryConfiguration::enabled().enabled); diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 2eabbe848d..036dbb11a8 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -25,9 +25,10 @@ use crate::generated::api_types::{ use crate::generated::session_events::ReasoningSummary; use crate::types::{ CanvasProviderIdentity, CapiSessionOptions, CloudSessionOptions, CustomAgentConfig, - DefaultAgentConfig, ExtensionInfo, InfiniteSessionConfig, LargeToolOutputConfig, - McpServerConfig, MemoryConfiguration, NamedProviderConfig, ProviderConfig, ProviderModelConfig, - SessionId, SessionLimitsConfig, SystemMessageConfig, Tool, ToolSearchConfig, + DefaultAgentConfig, ExtensionInfo, GitHubMcpToolConfig, InfiniteSessionConfig, + LargeToolOutputConfig, McpServerConfig, MemoryConfiguration, NamedProviderConfig, + ProviderConfig, ProviderModelConfig, SessionId, SessionLimitsConfig, SystemMessageConfig, Tool, + ToolSearchConfig, }; /// Wire representation of a slash command (name + description only). The @@ -113,6 +114,8 @@ pub(crate) struct SessionCreateWire { pub request_auto_mode_switch: bool, pub request_elicitation: bool, pub request_mcp_apps: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub github_mcp_tool_config: Option, pub hooks: bool, #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, @@ -249,6 +252,8 @@ pub(crate) struct SessionResumeWire { pub request_auto_mode_switch: bool, pub request_elicitation: bool, pub request_mcp_apps: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub github_mcp_tool_config: Option, pub hooks: bool, #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index 5e07449f73..23407955fe 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -19,6 +19,7 @@ import { CapturingHttpProxy, PerformRequestOptions, } from "./capturingHttpProxy"; +export type { CapturedRequest } from "./capturingHttpProxy"; import { anthropicMessagesEndpoint, anthropicMessagesRequestToChatCompletion, @@ -327,6 +328,20 @@ export class ReplayingCapiProxy extends CapturingHttpProxy { return; } + // Handle /requests endpoint for retrieving all captured outbound requests. + if ( + options.requestOptions.path === "/requests" && + options.requestOptions.method === "GET" + ) { + const requests = this.exchanges + .map((exchange) => exchange.request) + .filter((request) => request.url !== "/requests"); + options.onResponseStart(200, { "content-type": "application/json" }); + options.onData(Buffer.from(JSON.stringify(requests))); + options.onResponseEnd(); + return; + } + // Handle /copilot_internal/user endpoint for per-session auth. // This must run before the state guard below: the CLI authenticates and // calls /copilot_internal/user at startup, which can race ahead of the diff --git a/test/snapshots/session_config/should_apply_github_mcp_tool_config_on_create.yaml b/test/snapshots/session_config/should_apply_github_mcp_tool_config_on_create.yaml new file mode 100644 index 0000000000..056351ddb4 --- /dev/null +++ b/test/snapshots/session_config/should_apply_github_mcp_tool_config_on_create.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: []