From 290c4fa9f9eb42a59f4c27650f4a237a5cb77687 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 28 Jul 2026 11:10:11 -0700 Subject: [PATCH 1/6] sdk: Expose githubMcpToolConfig across languages Adds the `githubMcpToolConfig` session option to `session.create` and `session.resume` in all six SDKs, forwarding the built-in GitHub MCP server settings the runtime already accepts: `enableAllTools`, `additionalToolsets`, `additionalTools`, `enableInsidersMode`, and the new `disableFormDeferral`. `disableFormDeferral` lets autonomous SDK workflows opt out of MCP App form deferral, so form-backed GitHub write tools execute directly instead of returning an awaiting-form stub. It requires MCP Apps to be enabled for the session. The option is omitted from the wire payload entirely when unset, so existing consumers see no change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 8 +- dotnet/src/Types.cs | 51 ++++++ dotnet/test/Unit/SerializationTests.cs | 45 ++++++ go/client.go | 2 + go/client_test.go | 60 +++++++ go/types.go | 22 +++ .../github/copilot/SessionRequestBuilder.java | 2 + .../copilot/rpc/CreateSessionRequest.java | 13 ++ .../copilot/rpc/GitHubMcpToolConfig.java | 80 +++++++++ .../copilot/rpc/ResumeSessionConfig.java | 23 +++ .../copilot/rpc/ResumeSessionRequest.java | 13 ++ .../com/github/copilot/rpc/SessionConfig.java | 23 +++ .../copilot/SessionRequestBuilderTest.java | 23 +++ nodejs/src/client.ts | 6 + nodejs/src/index.ts | 1 + nodejs/src/types.ts | 22 +++ nodejs/test/client.test.ts | 49 ++++++ python/copilot/__init__.py | 2 + python/copilot/client.py | 27 ++++ python/copilot/session.py | 16 ++ python/test_client.py | 40 +++++ rust/src/types.rs | 153 +++++++++++++++++- rust/src/wire.rs | 11 +- 23 files changed, 681 insertions(+), 11 deletions(-) create mode 100644 java/src/main/java/com/github/copilot/rpc/GitHubMcpToolConfig.java 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..8f0bd080a9 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -2363,6 +2363,66 @@ 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 + _ = json.Unmarshal(data, &payload) + 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/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..ccf538baef 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 built-in GitHub MCP tool configuration. @return the configuration */ + public GitHubMcpToolConfig getGitHubMcpToolConfig() { + return githubMcpToolConfig; + } + + /** Sets the built-in GitHub MCP tool configuration. @param config the configuration */ + 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..d5bc0157da --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/GitHubMcpToolConfig.java @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * 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..15e05e2261 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 built-in GitHub MCP tool configuration. @return the configuration */ + public GitHubMcpToolConfig getGitHubMcpToolConfig() { + return githubMcpToolConfig; + } + + /** Sets the built-in GitHub MCP tool configuration. @param config the configuration */ + 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..c2f91bc07e 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,26 @@ 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())) + .contains("\"githubMcpToolConfig\"")); + } } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 61f4a99416..c09e50f4fc 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 !== undefined + ? { 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 !== undefined + ? { 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/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..272dbd0c10 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. @@ -2312,6 +2330,10 @@ 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 +2762,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. @@ -3014,6 +3037,10 @@ 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>, From 975d6e89f9424cdaacc311c4eb527a51470c7fa9 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 28 Jul 2026 11:18:28 -0700 Subject: [PATCH 2/6] python: apply ruff formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/copilot/client.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/python/copilot/client.py b/python/copilot/client.py index 272dbd0c10..b15fcbfe92 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2331,9 +2331,7 @@ async def create_session( 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["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) @@ -3038,9 +3036,7 @@ async def resume_session( 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["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) From 9307a1a9d0c7cbd9c0b30f85485125041aba2f00 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 28 Jul 2026 11:24:41 -0700 Subject: [PATCH 3/6] java: apply spotless formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../github/copilot/rpc/CreateSessionRequest.java | 4 ++-- .../github/copilot/rpc/GitHubMcpToolConfig.java | 6 ++++-- .../github/copilot/rpc/ResumeSessionRequest.java | 4 ++-- .../copilot/SessionRequestBuilderTest.java | 16 ++++++---------- 4 files changed, 14 insertions(+), 16 deletions(-) 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 ccf538baef..651ca8d642 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -905,12 +905,12 @@ public void clearRequestMcpApps() { this.requestMcpApps = null; } - /** Gets the built-in GitHub MCP tool configuration. @return the configuration */ + /** Gets the GitHub MCP tool configuration. @return the configuration */ public GitHubMcpToolConfig getGitHubMcpToolConfig() { return githubMcpToolConfig; } - /** Sets the built-in GitHub MCP tool configuration. @param config the configuration */ + /** Sets the GitHub MCP tool configuration. @param config the value */ public void setGitHubMcpToolConfig(GitHubMcpToolConfig config) { this.githubMcpToolConfig = config; } diff --git a/java/src/main/java/com/github/copilot/rpc/GitHubMcpToolConfig.java b/java/src/main/java/com/github/copilot/rpc/GitHubMcpToolConfig.java index d5bc0157da..75a8e30163 100644 --- a/java/src/main/java/com/github/copilot/rpc/GitHubMcpToolConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/GitHubMcpToolConfig.java @@ -12,8 +12,10 @@ /** * 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. + *

+ * {@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 { 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 15e05e2261..9e6ba77496 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -930,12 +930,12 @@ public void clearRequestMcpApps() { this.requestMcpApps = null; } - /** Gets the built-in GitHub MCP tool configuration. @return the configuration */ + /** Gets the GitHub MCP tool configuration. @return the configuration */ public GitHubMcpToolConfig getGitHubMcpToolConfig() { return githubMcpToolConfig; } - /** Sets the built-in GitHub MCP tool configuration. @param config the configuration */ + /** Sets the GitHub MCP tool configuration. @param config the value */ public void setGitHubMcpToolConfig(GitHubMcpToolConfig config) { this.githubMcpToolConfig = config; } diff --git a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index c2f91bc07e..e300858b5b 100644 --- a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -962,16 +962,12 @@ void testClonePreservesAndForwardsExpAssignments() throws Exception { @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)); + 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()); From 33138005447c37fb4bd36a390c57c2d187d0cf21 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 28 Jul 2026 11:41:23 -0700 Subject: [PATCH 4/6] Address review feedback - node: omit githubMcpToolConfig for null as well as undefined - python: document github_mcp_tool_config on create_session/resume_session - go: assert json.Unmarshal succeeds in the omitted-when-unset subtest - java: call the non-deprecated buildCreateRequest(config, sessionId) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/client_test.go | 4 +++- .../copilot/SessionRequestBuilderTest.java | 5 +++-- nodejs/src/client.ts | 4 ++-- python/copilot/client.py | 20 +++++++++++++++++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/go/client_test.go b/go/client_test.go index 8f0bd080a9..2301c990d3 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -2416,7 +2416,9 @@ func TestSessionRequests_GitHubMCPToolConfig(t *testing.T) { t.Fatalf("Failed to marshal: %v", err) } var payload map[string]any - _ = json.Unmarshal(data, &payload) + 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") } diff --git a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index e300858b5b..4efdafd0e4 100644 --- a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -974,7 +974,8 @@ void githubMcpToolConfigIsMappedAndSerializedForCreateAndResume() throws Excepti var mapper = JsonRpcClient.getObjectMapper(); assertTrue(mapper.writeValueAsString(createRequest).contains("\"githubMcpToolConfig\"")); assertTrue(mapper.writeValueAsString(resumeRequest).contains("\"githubMcpToolConfig\"")); - assertFalse(mapper.writeValueAsString(SessionRequestBuilder.buildCreateRequest(new SessionConfig())) - .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 c09e50f4fc..a09a55a2c4 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1557,7 +1557,7 @@ export class CopilotClient { requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, ...(config.enableMcpApps ? { requestMcpApps: true } : {}), - ...(config.githubMcpToolConfig !== undefined + ...(config.githubMcpToolConfig != null ? { githubMcpToolConfig: config.githubMcpToolConfig } : {}), requestExitPlanMode: !!config.onExitPlanModeRequest, @@ -1773,7 +1773,7 @@ export class CopilotClient { requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, ...(config.enableMcpApps ? { requestMcpApps: true } : {}), - ...(config.githubMcpToolConfig !== undefined + ...(config.githubMcpToolConfig != null ? { githubMcpToolConfig: config.githubMcpToolConfig } : {}), requestExitPlanMode: !!config.onExitPlanModeRequest, diff --git a/python/copilot/client.py b/python/copilot/client.py index b15fcbfe92..907ce61cdd 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2205,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 @@ -2878,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 From cdb0b9f14a0784a9c34be6cf9ff05261e67534a2 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 31 Jul 2026 12:32:35 -0700 Subject: [PATCH 5/6] test: add GitHub MCP tool config E2E coverage Exercise the writable built-in GitHub MCP endpoint from Node and Go so regressions that drop githubMcpToolConfig fail end to end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/internal/e2e/session_config_e2e_test.go | 43 ++++++++++++++ nodejs/test/e2e/session_config.e2e.test.ts | 43 +++++++++++++- test/harness/replayingCapiProxy.ts | 58 +++++++++++++++++++ ...pply_github_mcp_tool_config_on_create.yaml | 3 + 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 test/snapshots/session_config/should_apply_github_mcp_tool_config_on_create.yaml diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index 672a905dc0..94dfe284f0 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -988,6 +988,49 @@ 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, session) + }) +} + +func assertGitHubMCPConfigApplied(t *testing.T, session *copilot.Session) { + t.Helper() + const serverName = "github-mcp-server" + waitForMCPServerStatus(t, session, serverName, rpc.MCPServerStatusConnected) + + tools, err := session.RPC.MCP.ListTools(t.Context(), &rpc.MCPListToolsRequest{ServerName: serverName}) + if err != nil { + t.Fatalf("MCP.ListTools failed: %v", err) + } + for _, tool := range tools.Tools { + if tool.Name == "github_config_applied" { + return + } + } + t.Fatalf("Expected configured GitHub MCP tool, got %+v", tools.Tools) } // createProxyProvider returns a ProviderConfig that points at the test proxy and diff --git a/nodejs/test/e2e/session_config.e2e.test.ts b/nodejs/test/e2e/session_config.e2e.test.ts index 98b1a0bfaa..f6f750f200 100644 --- a/nodejs/test/e2e/session_config.e2e.test.ts +++ b/nodejs/test/e2e/session_config.e2e.test.ts @@ -9,7 +9,7 @@ import { type CopilotRequestContext, } from "../../src/index.js"; import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; -import { retry } from "./harness/sdkTestHelper.js"; +import { retry, waitForCondition } from "./harness/sdkTestHelper.js"; describe("Session Configuration", async () => { const { copilotClient: client, workDir, openAiEndpoint, env } = await createSdkTestContext(); @@ -222,6 +222,26 @@ describe("Session Configuration", async () => { return (exchange.request.tools ?? []).map((t) => t.function.name); } + async function expectGitHubMcpConfigApplied(session: { + rpc: CopilotSession["rpc"]; + }): Promise { + const serverName = "github-mcp-server"; + await waitForCondition( + async () => + (await session.rpc.mcp.list()).servers.some( + (server) => server.name === serverName && server.status === "connected" + ), + { + timeoutMs: 60_000, + intervalMs: 200, + timeoutMessage: `${serverName} did not reach connected`, + } + ); + + const tools = await session.rpc.mcp.listTools({ serverName }); + expect(tools.tools.map((tool) => tool.name)).toContain("github_config_applied"); + } + async function sendAndGetNextExchange( session: { sendAndWait(options: { prompt: string }): Promise }, prompt: string @@ -848,4 +868,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/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index 5e07449f73..c448af1010 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -422,6 +422,64 @@ export class ReplayingCapiProxy extends CapturingHttpProxy { return; } const requestPath = options.requestOptions.path ?? ""; + // This snapshot verifies that enableAllTools selects the writable GitHub MCP endpoint. + if ( + path.basename(state.filePath) === + "should_apply_github_mcp_tool_config_on_create.yaml" && + (requestPath === "/mcp" || requestPath === "/mcp/readonly") && + options.requestOptions.method === "POST" + ) { + const request = JSON.parse(options.body ?? "{}") as { + id?: string | number; + method?: string; + params?: { protocolVersion?: string }; + }; + if (request.id === undefined) { + options.onResponseStart(202, commonResponseHeaders); + options.onResponseEnd(); + return; + } + + const result = + request.method === "initialize" + ? { + protocolVersion: + request.params?.protocolVersion ?? "2025-03-26", + capabilities: { tools: {} }, + serverInfo: { + name: "github-mcp-e2e-server", + version: "1.0.0", + }, + } + : request.method === "tools/list" + ? { + tools: [ + { + name: + requestPath === "/mcp" + ? "github_config_applied" + : "github_config_not_applied", + description: + "Reports whether the GitHub MCP E2E configuration was applied.", + inputSchema: { type: "object", properties: {} }, + }, + ], + } + : {}; + + options.onResponseStart(200, { + "content-type": "application/json", + ...commonResponseHeaders, + }); + options.onData( + Buffer.from( + JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), + ), + ); + options.onResponseEnd(); + return; + } + const protocol = replayProtocols[state.backend]; if ( modelEndpoints.has(requestPath) && 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: [] From a2fe6baeca85f7363163242c2d61a68b12265208 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 31 Jul 2026 12:58:57 -0700 Subject: [PATCH 6/6] test: capture GitHub MCP requests generically Replace snapshot-specific replay behavior with a reusable captured-request endpoint and assert the writable GitHub MCP route and headers from Node and Go E2E tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/internal/e2e/session_config_e2e_test.go | 56 +++++++++++++---- go/internal/e2e/testharness/context.go | 5 ++ go/internal/e2e/testharness/proxy.go | 32 ++++++++++ nodejs/test/e2e/harness/CapiProxy.ts | 6 ++ nodejs/test/e2e/session_config.e2e.test.ts | 35 +++++------ test/harness/replayingCapiProxy.ts | 73 +++++----------------- 6 files changed, 118 insertions(+), 89 deletions(-) diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index 94dfe284f0..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" @@ -1012,25 +1014,55 @@ func TestSessionConfigExtrasE2E(t *testing.T) { } t.Cleanup(func() { _ = session.Disconnect() }) - assertGitHubMCPConfigApplied(t, session) + assertGitHubMCPConfigApplied(t, ctx, session) }) } -func assertGitHubMCPConfigApplied(t *testing.T, session *copilot.Session) { +func assertGitHubMCPConfigApplied(t *testing.T, ctx *testharness.TestContext, session *copilot.Session) { t.Helper() - const serverName = "github-mcp-server" - waitForMCPServerStatus(t, session, serverName, rpc.MCPServerStatusConnected) - - tools, err := session.RPC.MCP.ListTools(t.Context(), &rpc.MCPListToolsRequest{ServerName: serverName}) - if err != nil { - t.Fatalf("MCP.ListTools failed: %v", err) + if _, err := session.RPC.MCP.List(t.Context()); err != nil { + t.Fatalf("MCP.List failed: %v", err) } - for _, tool := range tools.Tools { - if tool.Name == "github_config_applied" { - return + 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) } - t.Fatalf("Expected configured GitHub MCP tool, got %+v", tools.Tools) } // 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/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 f6f750f200..85137e0ff9 100644 --- a/nodejs/test/e2e/session_config.e2e.test.ts +++ b/nodejs/test/e2e/session_config.e2e.test.ts @@ -9,7 +9,7 @@ import { type CopilotRequestContext, } from "../../src/index.js"; import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; -import { retry, waitForCondition } from "./harness/sdkTestHelper.js"; +import { retry } from "./harness/sdkTestHelper.js"; describe("Session Configuration", async () => { const { copilotClient: client, workDir, openAiEndpoint, env } = await createSdkTestContext(); @@ -222,24 +222,21 @@ describe("Session Configuration", async () => { return (exchange.request.tools ?? []).map((t) => t.function.name); } - async function expectGitHubMcpConfigApplied(session: { - rpc: CopilotSession["rpc"]; - }): Promise { - const serverName = "github-mcp-server"; - await waitForCondition( - async () => - (await session.rpc.mcp.list()).servers.some( - (server) => server.name === serverName && server.status === "connected" - ), - { - timeoutMs: 60_000, - intervalMs: 200, - timeoutMessage: `${serverName} did not reach connected`, - } - ); - - const tools = await session.rpc.mcp.listTools({ serverName }); - expect(tools.tools.map((tool) => tool.name)).toContain("github_config_applied"); + 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( diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index c448af1010..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 @@ -422,64 +437,6 @@ export class ReplayingCapiProxy extends CapturingHttpProxy { return; } const requestPath = options.requestOptions.path ?? ""; - // This snapshot verifies that enableAllTools selects the writable GitHub MCP endpoint. - if ( - path.basename(state.filePath) === - "should_apply_github_mcp_tool_config_on_create.yaml" && - (requestPath === "/mcp" || requestPath === "/mcp/readonly") && - options.requestOptions.method === "POST" - ) { - const request = JSON.parse(options.body ?? "{}") as { - id?: string | number; - method?: string; - params?: { protocolVersion?: string }; - }; - if (request.id === undefined) { - options.onResponseStart(202, commonResponseHeaders); - options.onResponseEnd(); - return; - } - - const result = - request.method === "initialize" - ? { - protocolVersion: - request.params?.protocolVersion ?? "2025-03-26", - capabilities: { tools: {} }, - serverInfo: { - name: "github-mcp-e2e-server", - version: "1.0.0", - }, - } - : request.method === "tools/list" - ? { - tools: [ - { - name: - requestPath === "/mcp" - ? "github_config_applied" - : "github_config_not_applied", - description: - "Reports whether the GitHub MCP E2E configuration was applied.", - inputSchema: { type: "object", properties: {} }, - }, - ], - } - : {}; - - options.onResponseStart(200, { - "content-type": "application/json", - ...commonResponseHeaders, - }); - options.onData( - Buffer.from( - JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), - ), - ); - options.onResponseEnd(); - return; - } - const protocol = replayProtocols[state.backend]; if ( modelEndpoints.has(requestPath) &&