diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 1537d85a0..34598b6a2 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -637,6 +637,7 @@ private void ApplyConfigDefaultsForMode(SessionConfigBase config) if (_options.Mode == CopilotClientMode.Empty) { config.EnableSessionTelemetry ??= false; + config.McpOAuthTokenStorage ??= McpOAuthTokenStorageMode.InMemory; } } @@ -868,6 +869,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.Streaming is true ? true : null, config.IncludeSubAgentStreamingEvents, config.McpServers, + config.McpOAuthTokenStorage, "direct", config.CustomAgents, config.DefaultAgent, @@ -1055,6 +1057,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Streaming is true ? true : null, config.IncludeSubAgentStreamingEvents, config.McpServers, + config.McpOAuthTokenStorage, "direct", config.CustomAgents, config.DefaultAgent, @@ -2169,6 +2172,7 @@ internal record CreateSessionRequest( bool? Streaming, bool? IncludeSubAgentStreamingEvents, IDictionary? McpServers, + McpOAuthTokenStorageMode? McpOAuthTokenStorage, string? EnvValueMode, IList? CustomAgents, DefaultAgentConfig? DefaultAgent, @@ -2247,6 +2251,7 @@ internal record ResumeSessionRequest( bool? Streaming, bool? IncludeSubAgentStreamingEvents, IDictionary? McpServers, + McpOAuthTokenStorageMode? McpOAuthTokenStorage, string? EnvValueMode, IList? CustomAgents, DefaultAgentConfig? DefaultAgent, @@ -2343,6 +2348,7 @@ internal record HooksInvokeResponse( [JsonSerializable(typeof(ListSessionsResponse))] [JsonSerializable(typeof(GetSessionMetadataRequest))] [JsonSerializable(typeof(GetSessionMetadataResponse))] + [JsonSerializable(typeof(McpOAuthTokenStorageMode))] [JsonSerializable(typeof(ModelCapabilitiesOverride))] [JsonSerializable(typeof(ProviderConfig))] [JsonSerializable(typeof(ResumeSessionRequest))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 9a892b571..05bab6e87 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2081,6 +2081,21 @@ public enum McpHttpServerConfigOauthGrantType ClientCredentials } +/// +/// Controls how MCP OAuth tokens are stored for a session. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum McpOAuthTokenStorageMode +{ + /// Tokens are stored in the OS keychain, shared across sessions. + [JsonStringEnumMemberName("persistent")] + Persistent, + + /// Tokens are stored in memory and discarded when the session ends. + [JsonStringEnumMemberName("in-memory")] + InMemory +} + /// /// Abstract base class for MCP server configurations. /// @@ -2397,6 +2412,7 @@ protected SessionConfigBase(SessionConfigBase? other) ? new Dictionary(dict, dict.Comparer) : new Dictionary(other.McpServers)) : null; + McpOAuthTokenStorage = other.McpOAuthTokenStorage; Model = other.Model; ModelCapabilities = other.ModelCapabilities; OnAutoModeSwitchRequest = other.OnAutoModeSwitchRequest; @@ -2616,6 +2632,12 @@ protected SessionConfigBase(SessionConfigBase? other) /// public IDictionary? McpServers { get; set; } + /// + /// Controls how MCP OAuth tokens are stored for this session. + /// Default: for safe multitenant behavior. + /// + public McpOAuthTokenStorageMode? McpOAuthTokenStorage { get; set; } + /// Custom agent configurations for the session. public IList? CustomAgents { get; set; } diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 59efc244d..926a39b75 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -77,6 +77,7 @@ public void SessionConfig_Clone_CopiesAllProperties() EnableSessionTelemetry = false, IncludeSubAgentStreamingEvents = false, McpServers = new Dictionary { ["server1"] = new McpStdioServerConfig { Command = "echo" } }, + McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent, CustomAgents = [new CustomAgentConfig { Name = "agent1", Model = "claude-haiku-4.5" }], Agent = "agent1", Cloud = new CloudSessionOptions @@ -113,6 +114,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.EnableSessionTelemetry, clone.EnableSessionTelemetry); Assert.Equal(original.IncludeSubAgentStreamingEvents, clone.IncludeSubAgentStreamingEvents); Assert.Equal(original.McpServers.Count, clone.McpServers!.Count); + Assert.Equal(original.McpOAuthTokenStorage, clone.McpOAuthTokenStorage); Assert.Equal(original.CustomAgents.Count, clone.CustomAgents!.Count); Assert.Equal(original.CustomAgents[0].Model, clone.CustomAgents[0].Model); Assert.Equal(original.Agent, clone.Agent); @@ -420,4 +422,30 @@ public void ResumeSessionConfig_Clone_PreservesEnableSessionTelemetryDefault() Assert.Null(clone.EnableSessionTelemetry); } + + [Fact] + public void SessionConfig_Clone_CopiesMcpOAuthTokenStorage() + { + var original = new SessionConfig + { + McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent, + }; + + var clone = original.Clone(); + + Assert.Equal(McpOAuthTokenStorageMode.Persistent, clone.McpOAuthTokenStorage); + } + + [Fact] + public void ResumeSessionConfig_Clone_CopiesMcpOAuthTokenStorage() + { + var original = new ResumeSessionConfig + { + McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent, + }; + + var clone = original.Clone(); + + Assert.Equal(McpOAuthTokenStorageMode.Persistent, clone.McpOAuthTokenStorage); + } } diff --git a/go/client.go b/go/client.go index 0e05cb6cb..b5933ce86 100644 --- a/go/client.go +++ b/go/client.go @@ -629,6 +629,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.ModelCapabilities = config.ModelCapabilities req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers + req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents req.DefaultAgent = config.DefaultAgent @@ -958,6 +959,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.ContinuePendingWork = Bool(true) } req.MCPServers = config.MCPServers + req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents req.DefaultAgent = config.DefaultAgent diff --git a/go/client_test.go b/go/client_test.go index b262bb496..155f81368 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -619,6 +619,70 @@ func TestResumeSessionRequest_InstructionDirectories(t *testing.T) { }) } +func TestCreateSessionRequest_MCPOAuthTokenStorage(t *testing.T) { + t.Run("includes mcpOAuthTokenStorage in JSON when set", func(t *testing.T) { + req := createSessionRequest{MCPOAuthTokenStorage: "in-memory"} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["mcpOAuthTokenStorage"] != "in-memory" { + t.Errorf("Expected mcpOAuthTokenStorage to be 'in-memory', got %v", m["mcpOAuthTokenStorage"]) + } + }) + + t.Run("omits mcpOAuthTokenStorage from JSON when empty", func(t *testing.T) { + req := createSessionRequest{} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if _, ok := m["mcpOAuthTokenStorage"]; ok { + t.Error("Expected mcpOAuthTokenStorage to be omitted when empty") + } + }) +} + +func TestResumeSessionRequest_MCPOAuthTokenStorage(t *testing.T) { + t.Run("includes mcpOAuthTokenStorage in JSON when set", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1", MCPOAuthTokenStorage: "persistent"} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["mcpOAuthTokenStorage"] != "persistent" { + t.Errorf("Expected mcpOAuthTokenStorage to be 'persistent', got %v", m["mcpOAuthTokenStorage"]) + } + }) + + t.Run("omits mcpOAuthTokenStorage from JSON when empty", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1"} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if _, ok := m["mcpOAuthTokenStorage"]; ok { + t.Error("Expected mcpOAuthTokenStorage to be omitted when empty") + } + }) +} + func TestOverridesBuiltInTool(t *testing.T) { t.Run("OverridesBuiltInTool is serialized in tool definition", func(t *testing.T) { tool := Tool{ diff --git a/go/mode_empty.go b/go/mode_empty.go index 36e689b24..0ab02b388 100644 --- a/go/mode_empty.go +++ b/go/mode_empty.go @@ -126,6 +126,9 @@ func (c *Client) applyConfigDefaultsForMode(config *SessionConfig) { f := false config.EnableSessionTelemetry = &f } + if config.MCPOAuthTokenStorage == "" { + config.MCPOAuthTokenStorage = "in-memory" + } } func (c *Client) applyResumeDefaultsForMode(config *ResumeSessionConfig) { @@ -136,6 +139,9 @@ func (c *Client) applyResumeDefaultsForMode(config *ResumeSessionConfig) { f := false config.EnableSessionTelemetry = &f } + if config.MCPOAuthTokenStorage == "" { + config.MCPOAuthTokenStorage = "in-memory" + } } // updateSessionOptionsForMode applies the per-mode safe-defaults patch via diff --git a/go/toolset_test.go b/go/toolset_test.go index 6992e1200..80b49f619 100644 --- a/go/toolset_test.go +++ b/go/toolset_test.go @@ -247,3 +247,30 @@ func TestApplyConfigDefaultsForMode_copilotCliLeavesNil(t *testing.T) { t.Errorf("non-empty mode must not default telemetry") } } + +func TestApplyConfigDefaultsForMode_emptyDefaultsMCPOAuthTokenStorage(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + cfg := &SessionConfig{} + c.applyConfigDefaultsForMode(cfg) + if cfg.MCPOAuthTokenStorage != "in-memory" { + t.Errorf("expected MCPOAuthTokenStorage 'in-memory' in empty mode, got %q", cfg.MCPOAuthTokenStorage) + } +} + +func TestApplyConfigDefaultsForMode_emptyHonorsCallerMCPOAuthTokenStorage(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + cfg := &SessionConfig{MCPOAuthTokenStorage: "persistent"} + c.applyConfigDefaultsForMode(cfg) + if cfg.MCPOAuthTokenStorage != "persistent" { + t.Errorf("caller-supplied MCPOAuthTokenStorage must win, got %q", cfg.MCPOAuthTokenStorage) + } +} + +func TestApplyConfigDefaultsForMode_copilotCliLeavesMCPOAuthTokenStorageEmpty(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeCopilotCli}) + cfg := &SessionConfig{} + c.applyConfigDefaultsForMode(cfg) + if cfg.MCPOAuthTokenStorage != "" { + t.Errorf("non-empty mode must not default MCPOAuthTokenStorage, got %q", cfg.MCPOAuthTokenStorage) + } +} diff --git a/go/types.go b/go/types.go index 0b5a4de0a..82780e149 100644 --- a/go/types.go +++ b/go/types.go @@ -965,6 +965,11 @@ type SessionConfig struct { ModelCapabilities *rpc.ModelCapabilitiesOverride // MCPServers configures MCP servers for the session MCPServers map[string]MCPServerConfig + // MCPOAuthTokenStorage controls how MCP OAuth tokens are stored for this session. + // "persistent" stores tokens in the OS keychain (shared across sessions). + // "in-memory" stores tokens in memory and discards them when the session ends. + // Defaults to "in-memory" for safe multitenant behavior. + MCPOAuthTokenStorage string // CustomAgents configures custom agents for the session CustomAgents []CustomAgentConfig // DefaultAgent configures the default agent (the built-in agent that handles turns when no custom agent is selected). @@ -1287,6 +1292,11 @@ type ResumeSessionConfig struct { IncludeSubAgentStreamingEvents *bool // MCPServers configures MCP servers for the session MCPServers map[string]MCPServerConfig + // MCPOAuthTokenStorage controls how MCP OAuth tokens are stored for this session. + // "persistent" stores tokens in the OS keychain (shared across sessions). + // "in-memory" stores tokens in memory and discards them when the session ends. + // Defaults to "in-memory" for safe multitenant behavior. + MCPOAuthTokenStorage string // CustomAgents configures custom agents for the session CustomAgents []CustomAgentConfig // DefaultAgent configures the default agent (the built-in agent that handles turns when no custom agent is selected). @@ -1602,6 +1612,7 @@ type createSessionRequest struct { Streaming *bool `json:"streaming,omitempty"` IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` + MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` DefaultAgent *DefaultAgentConfig `json:"defaultAgent,omitempty"` @@ -1673,6 +1684,7 @@ type resumeSessionRequest struct { Streaming *bool `json:"streaming,omitempty"` IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` + MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` DefaultAgent *DefaultAgentConfig `json:"defaultAgent,omitempty"` diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index ff6b3ccb8..471a4bcd7 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -512,6 +512,9 @@ public CompletableFuture createSession(SessionConfig config) { + "the tools it wants — e.g. setAvailableTools(new ToolSet().addBuiltIn(BuiltInTools.ISOLATED))."); } request.setToolFilterPrecedence("excluded"); + if (request.getMcpOAuthTokenStorage() == null) { + request.setMcpOAuthTokenStorage("in-memory"); + } } long rpcNanos = System.nanoTime(); @@ -626,6 +629,9 @@ public CompletableFuture resumeSession(String sessionId, ResumeS + "the tools it wants — e.g. setAvailableTools(new ToolSet().addBuiltIn(BuiltInTools.ISOLATED))."); } request.setToolFilterPrecedence("excluded"); + if (request.getMcpOAuthTokenStorage() == null) { + request.setMcpOAuthTokenStorage("in-memory"); + } } long rpcNanos = System.nanoTime(); diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index a8c63a575..52f4e2179 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -125,6 +125,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess } config.getIncludeSubAgentStreamingEvents().ifPresent(request::setIncludeSubAgentStreamingEvents); request.setMcpServers(config.getMcpServers()); + request.setMcpOAuthTokenStorage(config.getMcpOAuthTokenStorage()); request.setCustomAgents(config.getCustomAgents()); request.setDefaultAgent(config.getDefaultAgent()); request.setAgent(config.getAgent()); @@ -227,6 +228,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo } config.getIncludeSubAgentStreamingEvents().ifPresent(request::setIncludeSubAgentStreamingEvents); request.setMcpServers(config.getMcpServers()); + request.setMcpOAuthTokenStorage(config.getMcpOAuthTokenStorage()); request.setCustomAgents(config.getCustomAgents()); request.setDefaultAgent(config.getDefaultAgent()); request.setAgent(config.getAgent()); 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 0e2f411c9..b8dddbec7 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -81,6 +81,9 @@ public final class CreateSessionRequest { @JsonProperty("mcpServers") private Map mcpServers; + @JsonProperty("mcpOAuthTokenStorage") + private String mcpOAuthTokenStorage; + @JsonProperty("envValueMode") private String envValueMode; @@ -369,6 +372,19 @@ public void setMcpServers(Map mcpServers) { this.mcpServers = mcpServers; } + /** Gets MCP OAuth token storage mode. @return the storage mode */ + public String getMcpOAuthTokenStorage() { + return mcpOAuthTokenStorage; + } + + /** + * Sets MCP OAuth token storage mode. @param mcpOAuthTokenStorage the storage + * mode + */ + public void setMcpOAuthTokenStorage(String mcpOAuthTokenStorage) { + this.mcpOAuthTokenStorage = mcpOAuthTokenStorage; + } + /** Gets MCP environment variable value mode. @return the mode */ public String getEnvValueMode() { return envValueMode; 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 da1e90bf6..3cdf19182 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -63,6 +63,7 @@ public class ResumeSessionConfig { private boolean streaming; private Boolean includeSubAgentStreamingEvents; private Map mcpServers; + private String mcpOAuthTokenStorage; private List customAgents; private DefaultAgentConfig defaultAgent; private String agent; @@ -767,6 +768,37 @@ public ResumeSessionConfig setMcpServers(Map mcpServers return this; } + /** + * Gets the MCP OAuth token storage mode. + * + * @return the storage mode, or {@code null} if not set + */ + public String getMcpOAuthTokenStorage() { + return mcpOAuthTokenStorage; + } + + /** + * Sets the MCP OAuth token storage mode. + *

+ * Controls how MCP OAuth tokens are stored for this session: + *

    + *
  • {@code "persistent"} — tokens are stored in the OS keychain (shared + * across sessions)
  • + *
  • {@code "in-memory"} — tokens are stored in memory and discarded when the + * session ends
  • + *
+ * If not set, the SDK defaults to {@code "in-memory"} for safe multitenant + * behavior. + * + * @param mcpOAuthTokenStorage + * the storage mode + * @return this config for method chaining + */ + public ResumeSessionConfig setMcpOAuthTokenStorage(String mcpOAuthTokenStorage) { + this.mcpOAuthTokenStorage = mcpOAuthTokenStorage; + return this; + } + /** * Gets the custom agent configurations. * 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 e59577f8d..5305639ed 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -92,6 +92,9 @@ public final class ResumeSessionRequest { @JsonProperty("mcpServers") private Map mcpServers; + @JsonProperty("mcpOAuthTokenStorage") + private String mcpOAuthTokenStorage; + @JsonProperty("envValueMode") private String envValueMode; @@ -439,6 +442,19 @@ public void setMcpServers(Map mcpServers) { this.mcpServers = mcpServers; } + /** Gets MCP OAuth token storage mode. @return the storage mode */ + public String getMcpOAuthTokenStorage() { + return mcpOAuthTokenStorage; + } + + /** + * Sets MCP OAuth token storage mode. @param mcpOAuthTokenStorage the storage + * mode + */ + public void setMcpOAuthTokenStorage(String mcpOAuthTokenStorage) { + this.mcpOAuthTokenStorage = mcpOAuthTokenStorage; + } + /** Gets MCP environment variable value mode. @return the mode */ public String getEnvValueMode() { return envValueMode; 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 487ca7493..c84530687 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -60,6 +60,7 @@ public class SessionConfig { private boolean streaming; private Boolean includeSubAgentStreamingEvents; private Map mcpServers; + private String mcpOAuthTokenStorage; private List customAgents; private DefaultAgentConfig defaultAgent; private String agent; @@ -677,6 +678,37 @@ public SessionConfig setMcpServers(Map mcpServers) { return this; } + /** + * Gets the MCP OAuth token storage mode. + * + * @return the storage mode, or {@code null} if not set + */ + public String getMcpOAuthTokenStorage() { + return mcpOAuthTokenStorage; + } + + /** + * Sets the MCP OAuth token storage mode. + *

+ * Controls how MCP OAuth tokens are stored for this session: + *

    + *
  • {@code "persistent"} — tokens are stored in the OS keychain (shared + * across sessions)
  • + *
  • {@code "in-memory"} — tokens are stored in memory and discarded when the + * session ends
  • + *
+ * If not set, the SDK defaults to {@code "in-memory"} for safe multitenant + * behavior. + * + * @param mcpOAuthTokenStorage + * the storage mode + * @return this config instance for method chaining + */ + public SessionConfig setMcpOAuthTokenStorage(String mcpOAuthTokenStorage) { + this.mcpOAuthTokenStorage = mcpOAuthTokenStorage; + return this; + } + /** * Gets the custom agent configurations. * diff --git a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index b10e6bd8e..9fca584d1 100644 --- a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -122,6 +122,26 @@ void testBuildCreateRequestOmitsEnableSessionTelemetryWhenNotSet() { assertNull(request.getEnableSessionTelemetry()); } + @Test + void testBuildCreateRequestPassesThroughNullMcpOAuthTokenStorage() { + var config = new SessionConfig(); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + assertNull(request.getMcpOAuthTokenStorage()); + } + + @Test + void testBuildCreateRequestForwardsExplicitMcpOAuthTokenStorage() { + var config = new SessionConfig().setMcpOAuthTokenStorage("persistent"); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + assertEquals("persistent", request.getMcpOAuthTokenStorage()); + } + + @Test + void testBuildCreateRequestNullConfigHasNullMcpOAuthTokenStorage() { + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(null); + assertNull(request.getMcpOAuthTokenStorage()); + } + // ========================================================================= // buildResumeRequest // ========================================================================= @@ -230,10 +250,30 @@ void testBuildResumeRequestSetsClientName() { assertEquals("my-app", request.getClientName()); } + @Test + void testBuildResumeRequestPassesThroughNullMcpOAuthTokenStorage() { + var config = new ResumeSessionConfig(); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-11", config); + assertNull(request.getMcpOAuthTokenStorage()); + } + + @Test + void testBuildResumeRequestForwardsExplicitMcpOAuthTokenStorage() { + var config = new ResumeSessionConfig().setMcpOAuthTokenStorage("persistent"); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-12", config); + assertEquals("persistent", request.getMcpOAuthTokenStorage()); + } + + @Test + void testBuildResumeRequestNullConfigHasNullMcpOAuthTokenStorage() { + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-13", null); + assertNull(request.getMcpOAuthTokenStorage()); + } + @Test void testBuildResumeRequestSetsReasoningSummary() { var config = new ResumeSessionConfig().setReasoningSummary("none"); - ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-11", config); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-14", config); assertEquals("none", request.getReasoningSummary()); } @@ -242,7 +282,7 @@ void testBuildResumeRequestSetsPluginDirectoriesAndLargeOutput() { var largeOutput = new LargeToolOutputConfig().setEnabled(false).setMaxSizeBytes(2048L) .setOutputDirectory("/tmp/resume"); var config = new ResumeSessionConfig().setPluginDirectories(List.of("/plugins/r")).setLargeOutput(largeOutput); - ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-12", config); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-15", config); assertEquals(List.of("/plugins/r"), request.getPluginDirectories()); assertEquals(largeOutput, request.getLargeOutput()); } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 45067d65f..26a5dc1dc 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -908,7 +908,7 @@ export class CopilotClient { /** Mode-specific defaults spread under the caller's config (app values win). */ private configDefaultsForMode(): Partial { if (this.options.mode === "empty") { - return { enableSessionTelemetry: false }; + return { enableSessionTelemetry: false, mcpOAuthTokenStorage: "in-memory" }; } return {}; } @@ -1124,6 +1124,7 @@ export class CopilotClient { streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, mcpServers: toWireMcpServers(config.mcpServers), + mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", customAgents: toWireCustomAgents(config.customAgents), defaultAgent: config.defaultAgent, @@ -1297,6 +1298,7 @@ export class CopilotClient { streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, mcpServers: toWireMcpServers(config.mcpServers), + mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", customAgents: toWireCustomAgents(config.customAgents), defaultAgent: config.defaultAgent, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index cf08e2e53..dd5604af0 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1830,6 +1830,15 @@ export interface SessionConfigBase { */ includeSubAgentStreamingEvents?: boolean; + /** + * Controls how MCP OAuth tokens are stored for this session. + * - `"persistent"` — tokens are stored in the OS keychain (shared across sessions) + * - `"in-memory"` — tokens are stored in memory and discarded when the session ends + * + * @default "in-memory" + */ + mcpOAuthTokenStorage?: "persistent" | "in-memory"; + /** * MCP server configurations for the session. * Keys are server names, values are server configurations. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index ac29c6aa1..959389fa4 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -430,6 +430,102 @@ describe("CopilotClient", () => { spy.mockRestore(); }); + it("defaults mcpOAuthTokenStorage to 'in-memory' in session.create when mode is empty", async () => { + const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId ?? "s1" }; + if (method === "session.options.update") return {}; + throw new Error(`Unexpected method: ${method}`); + }); + await client.createSession({ onPermissionRequest: approveAll, availableTools: [] }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; + expect(payload.mcpOAuthTokenStorage).toBe("in-memory"); + }); + + it("does not send mcpOAuthTokenStorage in session.create when mode is copilot-cli", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ onPermissionRequest: approveAll }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; + expect(payload.mcpOAuthTokenStorage).toBeUndefined(); + }); + + it("forwards explicit 'persistent' for mcpOAuthTokenStorage in session.create", async () => { + const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId ?? "s1" }; + if (method === "session.options.update") return {}; + throw new Error(`Unexpected method: ${method}`); + }); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: [], + mcpOAuthTokenStorage: "persistent", + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; + expect(payload.mcpOAuthTokenStorage).toBe("persistent"); + }); + + it("defaults mcpOAuthTokenStorage to 'in-memory' in session.resume when mode is empty", async () => { + const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId ?? "s1" }; + if (method === "session.resume") return { sessionId: params.sessionId }; + if (method === "session.options.update") return {}; + throw new Error(`Unexpected method: ${method}`); + }); + await client.createSession({ onPermissionRequest: approveAll, availableTools: [] }); + await client.resumeSession("s1", { onPermissionRequest: approveAll, availableTools: [] }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; + expect(payload.mcpOAuthTokenStorage).toBe("in-memory"); + }); + + it("forwards explicit 'persistent' for mcpOAuthTokenStorage in session.resume", async () => { + const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId ?? "s1" }; + if (method === "session.resume") return { sessionId: params.sessionId }; + if (method === "session.options.update") return {}; + throw new Error(`Unexpected method: ${method}`); + }); + await client.createSession({ onPermissionRequest: approveAll, availableTools: [] }); + await client.resumeSession("s1", { + onPermissionRequest: approveAll, + availableTools: [], + mcpOAuthTokenStorage: "persistent", + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; + expect(payload.mcpOAuthTokenStorage).toBe("persistent"); + }); + it("forwards continuePendingWork in session.resume request", async () => { const client = new CopilotClient(); await client.start(); diff --git a/python/copilot/_mode.py b/python/copilot/_mode.py index 23e392239..928eefbec 100644 --- a/python/copilot/_mode.py +++ b/python/copilot/_mode.py @@ -182,6 +182,16 @@ def _enable_session_telemetry_default( return supplied +def _mcp_oauth_token_storage_default( + mode: CopilotClientMode | None, + supplied: Literal["persistent", "in-memory"] | None, +) -> Literal["persistent", "in-memory"] | None: + """Empty mode defaults MCP OAuth token storage to in-memory; caller value wins.""" + if mode == "empty" and supplied is None: + return "in-memory" + return supplied + + def _post_create_options_patch( mode: CopilotClientMode | None, skip_custom_instructions: bool | None, diff --git a/python/copilot/client.py b/python/copilot/client.py index ea2a55964..f22734014 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -38,6 +38,7 @@ CopilotClientMode, ToolSet, _enable_session_telemetry_default, + _mcp_oauth_token_storage_default, _normalize_tool_filter, _post_create_options_patch, _require_available_tools_for_empty_mode, @@ -1571,6 +1572,7 @@ async def create_session( streaming: bool | None = None, include_sub_agent_streaming_events: bool | None = None, mcp_servers: dict[str, MCPServerConfig] | None = None, + mcp_oauth_token_storage: Literal["persistent", "in-memory"] | None = None, custom_agents: list[CustomAgentConfig] | None = None, default_agent: DefaultAgentConfig | dict[str, Any] | None = None, agent: str | None = None, @@ -1645,6 +1647,10 @@ async def create_session( ``agentId`` set). When False, only non-streaming sub-agent events and ``subagent.*`` lifecycle events are forwarded. Defaults to True. mcp_servers: MCP server configurations. + mcp_oauth_token_storage: Controls how MCP OAuth tokens are stored. + ``"persistent"`` uses the OS keychain (shared across sessions). + ``"in-memory"`` stores tokens in memory (discarded on session end). + Defaults to ``"in-memory"`` for safe multitenant behavior. custom_agents: Custom agent configurations. default_agent: Configuration for the default agent, including tool visibility controls. @@ -1815,6 +1821,10 @@ async def create_session( # Add MCP servers configuration if provided if mcp_servers: payload["mcpServers"] = _mcp_servers_to_wire(mcp_servers) + # Mode "empty" defaults MCP OAuth token storage to in-memory; caller wins. + mcp_oauth_token_storage = _mcp_oauth_token_storage_default(mode, mcp_oauth_token_storage) + if mcp_oauth_token_storage is not None: + payload["mcpOAuthTokenStorage"] = mcp_oauth_token_storage payload["envValueMode"] = "direct" # Add custom agents configuration if provided @@ -2075,6 +2085,7 @@ async def resume_session( streaming: bool | None = None, include_sub_agent_streaming_events: bool | None = None, mcp_servers: dict[str, MCPServerConfig] | None = None, + mcp_oauth_token_storage: Literal["persistent", "in-memory"] | None = None, custom_agents: list[CustomAgentConfig] | None = None, default_agent: DefaultAgentConfig | dict[str, Any] | None = None, agent: str | None = None, @@ -2150,6 +2161,10 @@ async def resume_session( ``agentId`` set). When False, only non-streaming sub-agent events and ``subagent.*`` lifecycle events are forwarded. Defaults to True. mcp_servers: MCP server configurations. + mcp_oauth_token_storage: Controls how MCP OAuth tokens are stored. + ``"persistent"`` uses the OS keychain (shared across sessions). + ``"in-memory"`` stores tokens in memory (discarded on session end). + Defaults to ``"in-memory"`` for safe multitenant behavior. custom_agents: Custom agent configurations. default_agent: Configuration for the default agent, including tool visibility controls. @@ -2311,6 +2326,10 @@ async def resume_session( # TODO: disable_resume is not a keyword arg yet; keeping for future use if mcp_servers: payload["mcpServers"] = _mcp_servers_to_wire(mcp_servers) + # Mode "empty" defaults MCP OAuth token storage to in-memory; caller wins. + mcp_oauth_token_storage = _mcp_oauth_token_storage_default(mode, mcp_oauth_token_storage) + if mcp_oauth_token_storage is not None: + payload["mcpOAuthTokenStorage"] = mcp_oauth_token_storage payload["envValueMode"] = "direct" if custom_agents: diff --git a/python/test_client.py b/python/test_client.py index 1d2919459..f023149b7 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1056,6 +1056,157 @@ async def mock_request(method, params, **kwargs): await client.force_stop() +class TestMcpOAuthTokenStorage: + @pytest.mark.asyncio + async def test_create_session_defaults_mcp_oauth_token_storage_to_in_memory_in_empty_mode( + self, + ): + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + mode="empty", + base_directory="/tmp/copilot-test", + ) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=[], + ) + assert captured["session.create"]["mcpOAuthTokenStorage"] == "in-memory" + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_create_session_does_not_send_mcp_oauth_token_storage_in_copilot_cli_mode( + self, + ): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + assert "mcpOAuthTokenStorage" not in captured["session.create"] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_create_session_forwards_explicit_mcp_oauth_token_storage(self): + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + mode="empty", + base_directory="/tmp/copilot-test", + ) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=[], + mcp_oauth_token_storage="persistent", + ) + assert captured["session.create"]["mcpOAuthTokenStorage"] == "persistent" + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_defaults_mcp_oauth_token_storage_to_in_memory_in_empty_mode( + self, + ): + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + mode="empty", + base_directory="/tmp/copilot-test", + ) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=[], + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method == "session.resume": + return {"sessionId": session.session_id} + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + available_tools=[], + ) + assert captured["session.resume"]["mcpOAuthTokenStorage"] == "in-memory" + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_forwards_explicit_mcp_oauth_token_storage(self): + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + mode="empty", + base_directory="/tmp/copilot-test", + ) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=[], + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method == "session.resume": + return {"sessionId": session.session_id} + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + available_tools=[], + mcp_oauth_token_storage="persistent", + ) + assert captured["session.resume"]["mcpOAuthTokenStorage"] == "persistent" + finally: + await client.force_stop() + + class TestCopilotClientContextManager: @pytest.mark.asyncio async def test_aenter_calls_start_and_returns_self(self): diff --git a/rust/src/session.rs b/rust/src/session.rs index 1fcf433a5..eb6689ff7 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -839,6 +839,9 @@ impl Client { if mode == crate::ClientMode::Empty && config.enable_session_telemetry.is_none() { config.enable_session_telemetry = Some(false); } + if mode == crate::ClientMode::Empty && config.mcp_oauth_token_storage.is_none() { + config.mcp_oauth_token_storage = Some("in-memory".into()); + } let opt_skip_custom_instructions = config.skip_custom_instructions; let opt_custom_agents_local_only = config.custom_agents_local_only; let opt_coauthor_enabled = config.coauthor_enabled; @@ -1066,6 +1069,9 @@ impl Client { if mode == crate::ClientMode::Empty && config.enable_session_telemetry.is_none() { config.enable_session_telemetry = Some(false); } + if mode == crate::ClientMode::Empty && config.mcp_oauth_token_storage.is_none() { + config.mcp_oauth_token_storage = Some("in-memory".into()); + } let opt_skip_custom_instructions = config.skip_custom_instructions; let opt_custom_agents_local_only = config.custom_agents_local_only; let opt_coauthor_enabled = config.coauthor_enabled; diff --git a/rust/src/types.rs b/rust/src/types.rs index 01a3a4247..d63ede83b 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1182,6 +1182,15 @@ pub struct SessionConfig { pub excluded_tools: Option>, /// MCP server configurations passed through to the CLI. pub mcp_servers: Option>, + /// Controls how MCP OAuth tokens are stored for this session. + /// + /// - `"persistent"` — tokens are stored in the OS keychain (shared across sessions). + /// - `"in-memory"` — tokens are stored in memory and discarded when the session ends. + /// + /// Defaults to `"in-memory"` when the client is in [`crate::ClientMode::Empty`], + /// applied automatically at session creation/resume time. `None` means no + /// explicit value is set and the runtime default takes effect. + pub mcp_oauth_token_storage: Option, /// When true, the CLI runs config discovery (MCP config files, skills, plugins). pub enable_config_discovery: Option, /// **Experimental.** This option is part of an experimental wire-protocol @@ -1358,6 +1367,7 @@ impl std::fmt::Debug for SessionConfig { .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) + .field("mcp_oauth_token_storage", &self.mcp_oauth_token_storage) .field("enable_config_discovery", &self.enable_config_discovery) .field("enable_mcp_apps", &self.enable_mcp_apps) .field("skill_directories", &self.skill_directories) @@ -1446,6 +1456,7 @@ impl Default for SessionConfig { available_tools: None, excluded_tools: None, mcp_servers: None, + mcp_oauth_token_storage: None, enable_config_discovery: None, enable_mcp_apps: None, skill_directories: None, @@ -1571,6 +1582,7 @@ impl SessionConfig { excluded_tools: self.excluded_tools, tool_filter_precedence: "excluded", mcp_servers: self.mcp_servers, + mcp_oauth_token_storage: self.mcp_oauth_token_storage, env_value_mode: "direct", enable_config_discovery: self.enable_config_discovery, request_user_input, @@ -1827,6 +1839,18 @@ impl SessionConfig { self } + /// Set MCP OAuth token storage mode. + /// + /// - `"persistent"` — tokens stored in the OS keychain. + /// - `"in-memory"` — tokens discarded when the session ends. + /// + /// Defaults to `"in-memory"` when the client is in [`crate::ClientMode::Empty`], + /// applied automatically at session creation/resume time. + pub fn with_mcp_oauth_token_storage(mut self, mode: impl Into) -> Self { + self.mcp_oauth_token_storage = Some(mode.into()); + self + } + /// Enable or disable CLI config discovery (MCP config files, skills, plugins). pub fn with_enable_config_discovery(mut self, enable: bool) -> Self { self.enable_config_discovery = Some(enable); @@ -2058,6 +2082,9 @@ pub struct ResumeSessionConfig { pub excluded_tools: Option>, /// Re-supply MCP servers so they remain available after app restart. pub mcp_servers: Option>, + /// Controls how MCP OAuth tokens are stored for this session. + /// See [`SessionConfig::mcp_oauth_token_storage`] for details. + pub mcp_oauth_token_storage: Option, /// Enable config discovery on resume. pub enable_config_discovery: Option, /// **Experimental.** This option is part of an experimental wire-protocol @@ -2184,6 +2211,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) + .field("mcp_oauth_token_storage", &self.mcp_oauth_token_storage) .field("enable_config_discovery", &self.enable_config_discovery) .field("enable_mcp_apps", &self.enable_mcp_apps) .field("skill_directories", &self.skill_directories) @@ -2310,6 +2338,7 @@ impl ResumeSessionConfig { excluded_tools: self.excluded_tools, tool_filter_precedence: "excluded", mcp_servers: self.mcp_servers, + mcp_oauth_token_storage: self.mcp_oauth_token_storage, env_value_mode: "direct", enable_config_discovery: self.enable_config_discovery, request_user_input, @@ -2381,6 +2410,7 @@ impl ResumeSessionConfig { available_tools: None, excluded_tools: None, mcp_servers: None, + mcp_oauth_token_storage: None, enable_config_discovery: None, enable_mcp_apps: None, skill_directories: None, @@ -2610,6 +2640,13 @@ impl ResumeSessionConfig { self } + /// Set MCP OAuth token storage mode on resume. + /// See [`SessionConfig::with_mcp_oauth_token_storage`] for details. + pub fn with_mcp_oauth_token_storage(mut self, mode: impl Into) -> Self { + self.mcp_oauth_token_storage = Some(mode.into()); + self + } + /// Enable or disable CLI config discovery on resume. pub fn with_enable_config_discovery(mut self, enable: bool) -> Self { self.enable_config_discovery = Some(enable); @@ -4076,6 +4113,7 @@ mod tests { #[test] fn session_config_default_wire_flags_off_without_handlers() { let cfg = SessionConfig::default(); + assert_eq!(cfg.mcp_oauth_token_storage, None); // Wire flags are derived from handler presence at create_session // time, not stored on the config. With no handlers installed, every // request_* flag should serialize as false. @@ -4094,6 +4132,7 @@ mod tests { #[test] fn resume_session_config_new_wire_flags_off_without_handlers() { let cfg = ResumeSessionConfig::new(SessionId::from("resume-flags")); + assert_eq!(cfg.mcp_oauth_token_storage, None); let (wire, _runtime) = cfg .into_wire() .expect("default resume config has no duplicate handlers"); @@ -4290,6 +4329,7 @@ mod tests { .with_available_tools(["bash", "view"]) .with_excluded_tools(["dangerous"]) .with_mcp_servers(HashMap::new()) + .with_mcp_oauth_token_storage("persistent") .with_enable_config_discovery(true) .with_skill_directories([PathBuf::from("/tmp/skills")]) .with_disabled_skills(["broken-skill"]) @@ -4317,6 +4357,7 @@ mod tests { Some(&["dangerous".to_string()][..]) ); assert!(cfg.mcp_servers.is_some()); + assert_eq!(cfg.mcp_oauth_token_storage.as_deref(), Some("persistent")); assert_eq!(cfg.enable_config_discovery, Some(true)); assert_eq!( cfg.skill_directories.as_deref(), @@ -4350,6 +4391,7 @@ mod tests { .with_available_tools(["bash", "view"]) .with_excluded_tools(["dangerous"]) .with_mcp_servers(HashMap::new()) + .with_mcp_oauth_token_storage("persistent") .with_enable_config_discovery(true) .with_skill_directories([PathBuf::from("/tmp/skills")]) .with_disabled_skills(["broken-skill"]) @@ -4377,6 +4419,7 @@ mod tests { Some(&["dangerous".to_string()][..]) ); assert!(cfg.mcp_servers.is_some()); + assert_eq!(cfg.mcp_oauth_token_storage.as_deref(), Some("persistent")); assert_eq!(cfg.enable_config_discovery, Some(true)); assert_eq!( cfg.skill_directories.as_deref(), diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 7f89dc8e4..60a7b409b 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -77,6 +77,8 @@ pub(crate) struct SessionCreateWire { pub tool_filter_precedence: &'static str, #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_oauth_token_storage: Option, pub env_value_mode: &'static str, #[serde(skip_serializing_if = "Option::is_none")] pub enable_config_discovery: Option, @@ -162,6 +164,8 @@ pub(crate) struct SessionResumeWire { pub tool_filter_precedence: &'static str, #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_oauth_token_storage: Option, pub env_value_mode: &'static str, #[serde(skip_serializing_if = "Option::is_none")] pub enable_config_discovery: Option,