Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1196,6 +1196,7 @@ public async Task<CopilotSession> 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();
Expand Down Expand Up @@ -1410,6 +1411,7 @@ public async Task<CopilotSession> 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();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
51 changes: 51 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2957,6 +2957,36 @@ public sealed class CopilotExpAssignmentResponse
public string AssignmentContext { get; set; } = string.Empty;
}

/// <summary>
/// Configuration for the built-in GitHub MCP server.
/// </summary>
public sealed class GitHubMcpToolConfig
{
/// <summary>Enables all GitHub MCP tools.</summary>
[JsonPropertyName("enableAllTools")]
public bool? EnableAllTools { get; set; }

/// <summary>Additional GitHub MCP toolsets to enable.</summary>
[JsonPropertyName("additionalToolsets")]
public IList<string>? AdditionalToolsets { get; set; }

/// <summary>Additional GitHub MCP tools to enable.</summary>
[JsonPropertyName("additionalTools")]
public IList<string>? AdditionalTools { get; set; }

/// <summary>Enables GitHub MCP insiders-mode tools.</summary>
[JsonPropertyName("enableInsidersMode")]
public bool? EnableInsidersMode { get; set; }

/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("disableFormDeferral")]
public bool? DisableFormDeferral { get; set; }
}

/// <summary>
/// Shared configuration properties for creating or resuming a Copilot session.
/// Use <see cref="SessionConfig"/> when creating a new session, or
Expand Down Expand Up @@ -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,
Comment thread
connor4312 marked this conversation as resolved.
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;
Expand Down Expand Up @@ -3309,6 +3353,13 @@ protected SessionConfigBase(SessionConfigBase? other)
[Experimental(Diagnostics.Experimental)]
public bool EnableMcpApps { get; set; }

/// <summary>
/// Configuration for the built-in GitHub MCP server.
/// <c>DisableFormDeferral</c> only applies to that server and only has an
/// effect when MCP Apps and form-backed GitHub tools are enabled.
/// </summary>
public GitHubMcpToolConfig? GitHubMcpToolConfig { get; set; }

/// <summary>Hook handlers for session lifecycle events.</summary>
public SessionHooks? Hooks { get; set; }

Expand Down
45 changes: 45 additions & 0 deletions dotnet/test/Unit/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
2 changes: 2 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Comment thread
connor4312 marked this conversation as resolved.
})
}

func TestResumeSessionRequest_ModeCallbackFlags(t *testing.T) {
req := resumeSessionRequest{
SessionID: "s1",
Expand Down
75 changes: 75 additions & 0 deletions go/internal/e2e/session_config_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions go/internal/e2e/testharness/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
32 changes: 32 additions & 0 deletions go/internal/e2e/testharness/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading
Loading