From e5aa2f5c4efbbe547a22778d414a23a32f2e779b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 18 May 2026 12:40:39 -0700 Subject: [PATCH 01/13] feat: add mcpOAuthTokenStorage support across all SDKs Add the mcpOAuthTokenStorage protocol property to session creation and resume flows in all five language SDKs (Node.js, Python, Go, .NET, Rust). When set to "in-memory", the runtime uses an in-memory MCP OAuth token store instead of the OS keychain. The SDK defaults to "in-memory" for safe multitenant behavior. - Node.js: Add to SessionConfig interface and ResumeSessionConfig Pick type - Python: Add to both TypedDicts and client methods with docstrings - Go: Add to config structs, wire request structs, and client wiring - .NET: Add McpOAuthTokenStorageMode enum with JsonStringEnumConverter, update config classes, copy constructors, wire records, and serialization context - Rust: Add field, builder methods, Default/new impls, and Debug impls Tests: - Rust: Assert defaults and builder composition in existing type tests - .NET: Add property to SessionConfig_Clone_CopiesAllProperties test - Go: Add wire serialization tests for both request types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 5 ++++ dotnet/src/Types.cs | 29 ++++++++++++++++++ dotnet/test/Unit/CloneTests.cs | 2 ++ go/client.go | 10 +++++++ go/client_test.go | 54 ++++++++++++++++++++++++++++++++++ go/types.go | 12 ++++++++ nodejs/src/client.ts | 2 ++ nodejs/src/types.ts | 10 +++++++ python/copilot/client.py | 14 +++++++++ python/copilot/session.py | 10 +++++++ rust/src/types.rs | 41 ++++++++++++++++++++++++++ 11 files changed, 189 insertions(+) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 8f879043a..abde5a8ee 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -616,6 +616,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.Streaming is true ? true : null, config.IncludeSubAgentStreamingEvents, config.McpServers, + config.McpOAuthTokenStorage ?? McpOAuthTokenStorageMode.InMemory, "direct", config.CustomAgents, config.DefaultAgent, @@ -780,6 +781,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Streaming is true ? true : null, config.IncludeSubAgentStreamingEvents, config.McpServers, + config.McpOAuthTokenStorage ?? McpOAuthTokenStorageMode.InMemory, "direct", config.CustomAgents, config.DefaultAgent, @@ -1986,6 +1988,7 @@ internal record CreateSessionRequest( bool? Streaming, bool? IncludeSubAgentStreamingEvents, IDictionary? McpServers, + McpOAuthTokenStorageMode? McpOAuthTokenStorage, string? EnvValueMode, IList? CustomAgents, DefaultAgentConfig? DefaultAgent, @@ -2050,6 +2053,7 @@ internal record ResumeSessionRequest( bool? Streaming, bool? IncludeSubAgentStreamingEvents, IDictionary? McpServers, + McpOAuthTokenStorageMode? McpOAuthTokenStorage, string? EnvValueMode, IList? CustomAgents, DefaultAgentConfig? DefaultAgent, @@ -2139,6 +2143,7 @@ internal record PermissionRequestResponseV2( [JsonSerializable(typeof(ListSessionsResponse))] [JsonSerializable(typeof(GetSessionMetadataRequest))] [JsonSerializable(typeof(GetSessionMetadataResponse))] + [JsonSerializable(typeof(McpOAuthTokenStorageMode))] [JsonSerializable(typeof(ModelCapabilitiesOverride))] [JsonSerializable(typeof(PermissionRequestResult))] [JsonSerializable(typeof(PermissionRequestResultKind))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index f93051111..cff225057 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1804,6 +1804,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. /// @@ -2085,6 +2100,7 @@ protected SessionConfig(SessionConfig? other) ? new Dictionary(dict, dict.Comparer) : new Dictionary(other.McpServers)) : null; + McpOAuthTokenStorage = other.McpOAuthTokenStorage; Model = other.Model; ModelCapabilities = other.ModelCapabilities; OnAutoModeSwitch = other.OnAutoModeSwitch; @@ -2261,6 +2277,12 @@ protected SessionConfig(SessionConfig? 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. /// @@ -2394,6 +2416,7 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) ? new Dictionary(dict, dict.Comparer) : new Dictionary(other.McpServers)) : null; + McpOAuthTokenStorage = other.McpOAuthTokenStorage; Model = other.Model; ModelCapabilities = other.ModelCapabilities; OnAutoModeSwitch = other.OnAutoModeSwitch; @@ -2587,6 +2610,12 @@ protected ResumeSessionConfig(ResumeSessionConfig? 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. /// diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 0816da9b2..d747e50d3 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -94,6 +94,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 @@ -127,6 +128,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); diff --git a/go/client.go b/go/client.go index 9730fc6d4..909adfadc 100644 --- a/go/client.go +++ b/go/client.go @@ -636,6 +636,11 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.ModelCapabilities = config.ModelCapabilities req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers + if config.McpOAuthTokenStorage != "" { + req.McpOAuthTokenStorage = config.McpOAuthTokenStorage + } else { + req.McpOAuthTokenStorage = "in-memory" + } req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents req.DefaultAgent = config.DefaultAgent @@ -841,6 +846,11 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.ContinuePendingWork = Bool(true) } req.MCPServers = config.MCPServers + if config.McpOAuthTokenStorage != "" { + req.McpOAuthTokenStorage = config.McpOAuthTokenStorage + } else { + req.McpOAuthTokenStorage = "in-memory" + } req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents req.DefaultAgent = config.DefaultAgent diff --git a/go/client_test.go b/go/client_test.go index 42e45ea15..dcf4a082d 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -615,6 +615,60 @@ 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, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + 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, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + 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/types.go b/go/types.go index 68a1c38a3..12d856c61 100644 --- a/go/types.go +++ b/go/types.go @@ -658,6 +658,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). @@ -902,6 +907,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). @@ -1162,6 +1172,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"` @@ -1220,6 +1231,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/nodejs/src/client.ts b/nodejs/src/client.ts index 1f0e8e9c9..55f02b3d0 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -824,6 +824,7 @@ export class CopilotClient { streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, mcpServers: config.mcpServers, + mcpOAuthTokenStorage: config.mcpOAuthTokenStorage ?? "in-memory", envValueMode: "direct", customAgents: config.customAgents, defaultAgent: config.defaultAgent, @@ -981,6 +982,7 @@ export class CopilotClient { streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, mcpServers: config.mcpServers, + mcpOAuthTokenStorage: config.mcpOAuthTokenStorage ?? "in-memory", envValueMode: "direct", customAgents: config.customAgents, defaultAgent: config.defaultAgent, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 0cdf84ad3..02e917279 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1444,6 +1444,15 @@ export interface SessionConfig { */ 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. @@ -1567,6 +1576,7 @@ export type ResumeSessionConfig = Pick< | "customAgents" | "defaultAgent" | "agent" + | "mcpOAuthTokenStorage" | "skillDirectories" | "instructionDirectories" | "disabledSkills" diff --git a/python/copilot/client.py b/python/copilot/client.py index e7acd2c25..4d01b3e70 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1340,6 +1340,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, @@ -1402,6 +1403,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. @@ -1551,6 +1556,8 @@ async def create_session( # Add MCP servers configuration if provided if mcp_servers: payload["mcpServers"] = mcp_servers + # Default MCP OAuth token storage to in-memory for safe multitenant behavior + payload["mcpOAuthTokenStorage"] = mcp_oauth_token_storage or "in-memory" payload["envValueMode"] = "direct" # Add custom agents configuration if provided @@ -1713,6 +1720,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, @@ -1775,6 +1783,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. @@ -1918,6 +1930,8 @@ async def resume_session( # TODO: disable_resume is not a keyword arg yet; keeping for future use if mcp_servers: payload["mcpServers"] = mcp_servers + # Default MCP OAuth token storage to in-memory for safe multitenant behavior + payload["mcpOAuthTokenStorage"] = mcp_oauth_token_storage or "in-memory" payload["envValueMode"] = "direct" if custom_agents: diff --git a/python/copilot/session.py b/python/copilot/session.py index f243d86e1..be5712cdb 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -947,6 +947,11 @@ class SessionConfig(TypedDict, total=False): include_sub_agent_streaming_events: bool # MCP server configurations for the session mcp_servers: dict[str, MCPServerConfig] + # 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, discarded when the session ends. + # Defaults to "in-memory" for safe multitenant behavior. + mcp_oauth_token_storage: Literal["persistent", "in-memory"] # Custom agent configurations for the session custom_agents: list[CustomAgentConfig] # Configuration for the default agent. @@ -1034,6 +1039,11 @@ class ResumeSessionConfig(TypedDict, total=False): include_sub_agent_streaming_events: bool # MCP server configurations for the session mcp_servers: dict[str, MCPServerConfig] + # 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, discarded when the session ends. + # Defaults to "in-memory" for safe multitenant behavior. + mcp_oauth_token_storage: Literal["persistent", "in-memory"] # Custom agent configurations for the session custom_agents: list[CustomAgentConfig] # Configuration for the default agent. diff --git a/rust/src/types.rs b/rust/src/types.rs index 2858f3c50..661003d41 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1044,6 +1044,15 @@ pub struct SessionConfig { /// MCP server configurations passed through to the CLI. #[serde(skip_serializing_if = "Option::is_none")] 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 `Some("in-memory")` via [`SessionConfig::default`] for safe + /// multitenant behavior. + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_oauth_token_storage: Option, /// Wire-format hint for MCP `env` map values. The runtime understands /// `"direct"` (literal values) and `"indirect"` (env-var lookup); the /// SDK only ever sends `"direct"` and consumers don't have a knob. @@ -1202,6 +1211,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("request_user_input", &self.request_user_input) .field("request_permission", &self.request_permission) @@ -1265,6 +1275,7 @@ impl Default for SessionConfig { available_tools: None, excluded_tools: None, mcp_servers: None, + mcp_oauth_token_storage: Some("in-memory".into()), env_value_mode: default_env_value_mode(), enable_config_discovery: None, request_user_input: Some(true), @@ -1457,6 +1468,17 @@ 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"` via [`Self::default`]. + 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); @@ -1658,6 +1680,10 @@ pub struct ResumeSessionConfig { /// Re-supply MCP servers so they remain available after app restart. #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, + /// Controls how MCP OAuth tokens are stored for this session. + /// See [`SessionConfig::mcp_oauth_token_storage`] for details. + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_oauth_token_storage: Option, /// See [`SessionConfig::env_value_mode`]. Always `"direct"` on the wire. #[serde(default = "default_env_value_mode", skip_deserializing)] pub(crate) env_value_mode: String, @@ -1785,6 +1811,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("request_user_input", &self.request_user_input) .field("request_permission", &self.request_permission) @@ -1846,6 +1873,7 @@ impl ResumeSessionConfig { available_tools: None, excluded_tools: None, mcp_servers: None, + mcp_oauth_token_storage: Some("in-memory".into()), env_value_mode: default_env_value_mode(), enable_config_discovery: None, request_user_input: Some(true), @@ -2008,6 +2036,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); @@ -3367,6 +3402,7 @@ mod tests { assert_eq!(cfg.request_elicitation, Some(true)); assert_eq!(cfg.request_exit_plan_mode, Some(true)); assert_eq!(cfg.request_auto_mode_switch, Some(true)); + assert_eq!(cfg.mcp_oauth_token_storage.as_deref(), Some("in-memory")); } #[test] @@ -3377,6 +3413,7 @@ mod tests { assert_eq!(cfg.request_elicitation, Some(true)); assert_eq!(cfg.request_exit_plan_mode, Some(true)); assert_eq!(cfg.request_auto_mode_switch, Some(true)); + assert_eq!(cfg.mcp_oauth_token_storage.as_deref(), Some("in-memory")); } #[test] @@ -3393,6 +3430,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_request_user_input(false) .with_request_exit_plan_mode(false) @@ -3421,6 +3459,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.request_user_input, Some(false)); // overrode default assert_eq!(cfg.request_permission, Some(true)); // default preserved @@ -3453,6 +3492,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_request_user_input(false) .with_request_exit_plan_mode(false) @@ -3481,6 +3521,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.request_user_input, Some(false)); // overrode default assert_eq!(cfg.request_permission, Some(true)); // default preserved From c8c0cd60644a8c43a9f3a62c1b0a8c2feaa793aa Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 18 May 2026 12:46:50 -0700 Subject: [PATCH 02/13] fix(go): rename McpOAuthTokenStorage to MCPOAuthTokenStorage Follow Go naming convention for initialisms (consistent with MCPServers). Also fixes JSON tags that were accidentally changed from camelCase wire format during the rename. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/client.go | 12 ++++++------ go/client_test.go | 8 ++++---- go/types.go | 12 ++++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/go/client.go b/go/client.go index 909adfadc..d913841c8 100644 --- a/go/client.go +++ b/go/client.go @@ -636,10 +636,10 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.ModelCapabilities = config.ModelCapabilities req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers - if config.McpOAuthTokenStorage != "" { - req.McpOAuthTokenStorage = config.McpOAuthTokenStorage + if config.MCPOAuthTokenStorage != "" { + req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage } else { - req.McpOAuthTokenStorage = "in-memory" + req.MCPOAuthTokenStorage = "in-memory" } req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents @@ -846,10 +846,10 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.ContinuePendingWork = Bool(true) } req.MCPServers = config.MCPServers - if config.McpOAuthTokenStorage != "" { - req.McpOAuthTokenStorage = config.McpOAuthTokenStorage + if config.MCPOAuthTokenStorage != "" { + req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage } else { - req.McpOAuthTokenStorage = "in-memory" + req.MCPOAuthTokenStorage = "in-memory" } req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents diff --git a/go/client_test.go b/go/client_test.go index dcf4a082d..367113447 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -615,9 +615,9 @@ func TestResumeSessionRequest_InstructionDirectories(t *testing.T) { }) } -func TestCreateSessionRequest_McpOAuthTokenStorage(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"} + req := createSessionRequest{MCPOAuthTokenStorage: "in-memory"} data, err := json.Marshal(req) if err != nil { t.Fatalf("Failed to marshal: %v", err) @@ -642,9 +642,9 @@ func TestCreateSessionRequest_McpOAuthTokenStorage(t *testing.T) { }) } -func TestResumeSessionRequest_McpOAuthTokenStorage(t *testing.T) { +func TestResumeSessionRequest_MCPOAuthTokenStorage(t *testing.T) { t.Run("includes mcpOAuthTokenStorage in JSON when set", func(t *testing.T) { - req := resumeSessionRequest{SessionID: "s1", McpOAuthTokenStorage: "persistent"} + req := resumeSessionRequest{SessionID: "s1", MCPOAuthTokenStorage: "persistent"} data, err := json.Marshal(req) if err != nil { t.Fatalf("Failed to marshal: %v", err) diff --git a/go/types.go b/go/types.go index 12d856c61..87616fcd6 100644 --- a/go/types.go +++ b/go/types.go @@ -658,11 +658,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. + // 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 + 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). @@ -907,11 +907,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. + // 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 + 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). @@ -1172,7 +1172,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"` + MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` DefaultAgent *DefaultAgentConfig `json:"defaultAgent,omitempty"` @@ -1231,7 +1231,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"` + MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` DefaultAgent *DefaultAgentConfig `json:"defaultAgent,omitempty"` From 6b6583cceb4858f3a1488227d67e56e55f65d97f Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 18 May 2026 13:18:32 -0700 Subject: [PATCH 03/13] fix(go): add error handling in MCPOAuthTokenStorage omit subtests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/client_test.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/go/client_test.go b/go/client_test.go index 367113447..0a13394f2 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -633,9 +633,14 @@ func TestCreateSessionRequest_MCPOAuthTokenStorage(t *testing.T) { t.Run("omits mcpOAuthTokenStorage from JSON when empty", func(t *testing.T) { req := createSessionRequest{} - data, _ := json.Marshal(req) + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } var m map[string]any - json.Unmarshal(data, &m) + 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") } @@ -660,9 +665,14 @@ func TestResumeSessionRequest_MCPOAuthTokenStorage(t *testing.T) { t.Run("omits mcpOAuthTokenStorage from JSON when empty", func(t *testing.T) { req := resumeSessionRequest{SessionID: "s1"} - data, _ := json.Marshal(req) + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } var m map[string]any - json.Unmarshal(data, &m) + 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") } From 06c091fa36149563dcf78e649c5ca3e7e30b8441 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 18 May 2026 13:27:55 -0700 Subject: [PATCH 04/13] test(nodejs): add mcpOAuthTokenStorage default and forwarding tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/client.test.ts | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index c3090eb76..9f4d80eb6 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -232,6 +232,74 @@ describe("CopilotClient", () => { spy.mockRestore(); }); + it("defaults mcpOAuthTokenStorage to 'in-memory' in session.create when not specified", 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).toBe("in-memory"); + }); + + it("forwards explicit 'persistent' for mcpOAuthTokenStorage in session.create", 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, + 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 not specified", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; + expect(payload.mcpOAuthTokenStorage).toBe("in-memory"); + spy.mockRestore(); + }); + + it("forwards explicit 'persistent' for mcpOAuthTokenStorage in session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + mcpOAuthTokenStorage: "persistent", + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; + expect(payload.mcpOAuthTokenStorage).toBe("persistent"); + spy.mockRestore(); + }); + it("forwards continuePendingWork in session.resume request", async () => { const client = new CopilotClient(); await client.start(); From d277fa80016c76d8bf775b9057c0dd9756302dc0 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 18 May 2026 13:29:51 -0700 Subject: [PATCH 05/13] test(python): add mcp_oauth_token_storage default and forwarding tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/test_client.py | 102 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/python/test_client.py b/python/test_client.py index f7c2e3bf0..0dafdf08a 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -987,6 +987,108 @@ async def mock_request(method, params): await client.force_stop() +class TestMcpOAuthTokenStorage: + @pytest.mark.asyncio + async def test_create_session_defaults_mcp_oauth_token_storage_to_in_memory(self): + client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + assert captured["session.create"]["mcpOAuthTokenStorage"] == "in-memory" + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_create_session_forwards_explicit_mcp_oauth_token_storage(self): + client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + 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(self): + client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + if method == "session.resume": + return {"sessionId": session.session_id} + return await original_request(method, params) + + client._client.request = mock_request + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + ) + 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(SubprocessConfig(cli_path=CLI_PATH)) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + if method == "session.resume": + return {"sessionId": session.session_id} + return await original_request(method, params) + + client._client.request = mock_request + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + 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): From 799d03eff84e29d645886029f323f3dd11742c25 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 26 May 2026 12:24:05 -0700 Subject: [PATCH 06/13] fix(python): use current CopilotClient constructor in mcpOAuthTokenStorage tests Replace SubprocessConfig positional arg (removed in main) with keyword-only connection=RuntimeConnection.for_stdio(path=CLI_PATH), matching the pattern used by all other tests in the file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/test_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/test_client.py b/python/test_client.py index a802750a5..b4f14d3c8 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -959,7 +959,7 @@ async def mock_request(method, params): class TestMcpOAuthTokenStorage: @pytest.mark.asyncio async def test_create_session_defaults_mcp_oauth_token_storage_to_in_memory(self): - client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) await client.start() try: @@ -980,7 +980,7 @@ async def mock_request(method, params): @pytest.mark.asyncio async def test_create_session_forwards_explicit_mcp_oauth_token_storage(self): - client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) await client.start() try: @@ -1002,7 +1002,7 @@ async def mock_request(method, params): @pytest.mark.asyncio async def test_resume_session_defaults_mcp_oauth_token_storage_to_in_memory(self): - client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) await client.start() try: @@ -1030,7 +1030,7 @@ async def mock_request(method, params): @pytest.mark.asyncio async def test_resume_session_forwards_explicit_mcp_oauth_token_storage(self): - client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) await client.start() try: From 2ab266bd2e23b6067daf7838339ff6ab9dff15ad Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 26 May 2026 12:41:45 -0700 Subject: [PATCH 07/13] feat(java): add mcpOAuthTokenStorage support Add mcpOAuthTokenStorage field to SessionConfig, ResumeSessionConfig, CreateSessionRequest, and ResumeSessionRequest. The SessionRequestBuilder defaults to "in-memory" for safe multitenant behavior, consistent with all other SDK implementations. Includes 6 unit tests covering default and explicit value forwarding for both create and resume paths, plus null config handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../copilot/sdk/SessionRequestBuilder.java | 8 ++++ .../sdk/json/CreateSessionRequest.java | 16 ++++++++ .../copilot/sdk/json/ResumeSessionConfig.java | 32 +++++++++++++++ .../sdk/json/ResumeSessionRequest.java | 16 ++++++++ .../copilot/sdk/json/SessionConfig.java | 32 +++++++++++++++ .../sdk/SessionRequestBuilderTest.java | 40 +++++++++++++++++++ 6 files changed, 144 insertions(+) diff --git a/java/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java index 0cdc4f942..327db8519 100644 --- a/java/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java @@ -98,6 +98,8 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setRequestPermission(true); // Always send envValueMode=direct for MCP servers request.setEnvValueMode("direct"); + // Default to in-memory for safe multitenant behavior + request.setMcpOAuthTokenStorage("in-memory"); request.setSessionId(sessionId); if (config == null) { return request; @@ -124,6 +126,8 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess } config.getIncludeSubAgentStreamingEvents().ifPresent(request::setIncludeSubAgentStreamingEvents); request.setMcpServers(config.getMcpServers()); + request.setMcpOAuthTokenStorage( + config.getMcpOAuthTokenStorage() != null ? config.getMcpOAuthTokenStorage() : "in-memory"); request.setCustomAgents(config.getCustomAgents()); request.setDefaultAgent(config.getDefaultAgent()); request.setAgent(config.getAgent()); @@ -189,6 +193,8 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setRequestPermission(true); // Always send envValueMode=direct for MCP servers request.setEnvValueMode("direct"); + // Default to in-memory for safe multitenant behavior + request.setMcpOAuthTokenStorage("in-memory"); if (config == null) { return request; @@ -220,6 +226,8 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo } config.getIncludeSubAgentStreamingEvents().ifPresent(request::setIncludeSubAgentStreamingEvents); request.setMcpServers(config.getMcpServers()); + request.setMcpOAuthTokenStorage( + config.getMcpOAuthTokenStorage() != null ? config.getMcpOAuthTokenStorage() : "in-memory"); request.setCustomAgents(config.getCustomAgents()); request.setDefaultAgent(config.getDefaultAgent()); request.setAgent(config.getAgent()); diff --git a/java/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java index 881840a73..1d29dd41a 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java @@ -76,6 +76,9 @@ public final class CreateSessionRequest { @JsonProperty("mcpServers") private Map mcpServers; + @JsonProperty("mcpOAuthTokenStorage") + private String mcpOAuthTokenStorage; + @JsonProperty("envValueMode") private String envValueMode; @@ -329,6 +332,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/sdk/json/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java index 72c9f6f47..82cf44afa 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java @@ -58,6 +58,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; @@ -574,6 +575,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/sdk/json/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java index 8aca77b7d..55a0b7f0e 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java @@ -86,6 +86,9 @@ public final class ResumeSessionRequest { @JsonProperty("mcpServers") private Map mcpServers; + @JsonProperty("mcpOAuthTokenStorage") + private String mcpOAuthTokenStorage; + @JsonProperty("envValueMode") private String envValueMode; @@ -398,6 +401,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/sdk/json/SessionConfig.java b/java/src/main/java/com/github/copilot/sdk/json/SessionConfig.java index ddf06cca7..a94769a32 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/sdk/json/SessionConfig.java @@ -55,6 +55,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; @@ -477,6 +478,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/sdk/SessionRequestBuilderTest.java b/java/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java index 5c8f00838..34d7ca55e 100644 --- a/java/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java @@ -104,6 +104,26 @@ void testBuildCreateRequestOmitsEnableSessionTelemetryWhenNotSet() { assertNull(request.getEnableSessionTelemetry()); } + @Test + void testBuildCreateRequestDefaultsMcpOAuthTokenStorageToInMemory() { + var config = new SessionConfig(); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + assertEquals("in-memory", request.getMcpOAuthTokenStorage()); + } + + @Test + void testBuildCreateRequestForwardsExplicitMcpOAuthTokenStorage() { + var config = new SessionConfig().setMcpOAuthTokenStorage("persistent"); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + assertEquals("persistent", request.getMcpOAuthTokenStorage()); + } + + @Test + void testBuildCreateRequestDefaultsMcpOAuthTokenStorageWhenConfigIsNull() { + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(null); + assertEquals("in-memory", request.getMcpOAuthTokenStorage()); + } + // ========================================================================= // buildResumeRequest // ========================================================================= @@ -212,6 +232,26 @@ void testBuildResumeRequestSetsClientName() { assertEquals("my-app", request.getClientName()); } + @Test + void testBuildResumeRequestDefaultsMcpOAuthTokenStorageToInMemory() { + var config = new ResumeSessionConfig(); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-11", config); + assertEquals("in-memory", request.getMcpOAuthTokenStorage()); + } + + @Test + void testBuildResumeRequestForwardsExplicitMcpOAuthTokenStorage() { + var config = new ResumeSessionConfig().setMcpOAuthTokenStorage("persistent"); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-12", config); + assertEquals("persistent", request.getMcpOAuthTokenStorage()); + } + + @Test + void testBuildResumeRequestDefaultsMcpOAuthTokenStorageWhenConfigIsNull() { + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-13", null); + assertEquals("in-memory", request.getMcpOAuthTokenStorage()); + } + // ========================================================================= // configureSession (ResumeSessionConfig overload) // ========================================================================= From 80f65107452c6ba7b8789eba406f5aa4833af547 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 28 May 2026 10:32:55 -0700 Subject: [PATCH 08/13] Fix MCP OAuth test regressions Update the Python MCP OAuth request interception tests to forward keyword arguments introduced by the merged create_session path, and add focused .NET clone tests so McpOAuth coverage can be validated directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/Unit/CloneTests.cs | 26 ++++++++++++++++++++++++++ python/test_client.py | 16 ++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 8a7a40ede..f8475aa2a 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -387,4 +387,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/python/test_client.py b/python/test_client.py index 13ed5c3b5..3a624616b 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -982,9 +982,9 @@ async def test_create_session_defaults_mcp_oauth_token_storage_to_in_memory(self captured = {} original_request = client._client.request - async def mock_request(method, params): + async def mock_request(method, params, **kwargs): captured[method] = params - return await original_request(method, params) + return await original_request(method, params, **kwargs) client._client.request = mock_request await client.create_session( @@ -1003,9 +1003,9 @@ async def test_create_session_forwards_explicit_mcp_oauth_token_storage(self): captured = {} original_request = client._client.request - async def mock_request(method, params): + async def mock_request(method, params, **kwargs): captured[method] = params - return await original_request(method, params) + return await original_request(method, params, **kwargs) client._client.request = mock_request await client.create_session( @@ -1029,11 +1029,11 @@ async def test_resume_session_defaults_mcp_oauth_token_storage_to_in_memory(self captured = {} original_request = client._client.request - async def mock_request(method, params): + 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) + return await original_request(method, params, **kwargs) client._client.request = mock_request await client.resume_session( @@ -1057,11 +1057,11 @@ async def test_resume_session_forwards_explicit_mcp_oauth_token_storage(self): captured = {} original_request = client._client.request - async def mock_request(method, params): + 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) + return await original_request(method, params, **kwargs) client._client.request = mock_request await client.resume_session( From 87eff4c5da964fee133f1313ea8a361561096b00 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 28 May 2026 11:28:09 -0700 Subject: [PATCH 09/13] Move mcpOAuthTokenStorage default to empty (multitenant) mode only Previously, all SDKs unconditionally defaulted mcpOAuthTokenStorage to "in-memory". With the client mode system from PR #1428, the safe multitenant default now only applies in "empty" mode. In "copilot-cli" mode, the property is not sent, letting the runtime default to "persistent" (backward compatible). Changes across all 6 SDKs: - Node.js: configDefaultsForMode() sets default for empty mode only - Python: _mcp_oauth_token_storage_default() helper checks mode - Go: applyConfigDefaultsForMode/applyResumeDefaultsForMode set default - .NET: ApplyConfigDefaultsForMode() uses ??= for empty mode - Rust: Default impls return None; session.rs applies for empty mode - Java: CopilotClient applies default in empty mode blocks Tests updated to verify mode-specific behavior in all SDKs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 5 +- go/client.go | 12 +--- go/mode_empty.go | 6 ++ go/toolset_test.go | 27 +++++++ .../com/github/copilot/CopilotClient.java | 6 ++ .../github/copilot/SessionRequestBuilder.java | 10 +-- .../copilot/SessionRequestBuilderTest.java | 16 ++--- nodejs/src/client.ts | 6 +- nodejs/test/client.test.ts | 70 +++++++++++++------ python/copilot/_mode.py | 10 +++ python/copilot/client.py | 13 ++-- python/test_client.py | 57 ++++++++++++--- rust/src/session.rs | 6 ++ rust/src/types.rs | 8 +-- 14 files changed, 184 insertions(+), 68 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 5a49003ba..de6939ecf 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; } } @@ -867,7 +868,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.Streaming is true ? true : null, config.IncludeSubAgentStreamingEvents, config.McpServers, - config.McpOAuthTokenStorage ?? McpOAuthTokenStorageMode.InMemory, + config.McpOAuthTokenStorage, "direct", config.CustomAgents, config.DefaultAgent, @@ -1051,7 +1052,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Streaming is true ? true : null, config.IncludeSubAgentStreamingEvents, config.McpServers, - config.McpOAuthTokenStorage ?? McpOAuthTokenStorageMode.InMemory, + config.McpOAuthTokenStorage, "direct", config.CustomAgents, config.DefaultAgent, diff --git a/go/client.go b/go/client.go index b66f3d0d6..ceac25cf6 100644 --- a/go/client.go +++ b/go/client.go @@ -628,11 +628,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.ModelCapabilities = config.ModelCapabilities req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers - if config.MCPOAuthTokenStorage != "" { - req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage - } else { - req.MCPOAuthTokenStorage = "in-memory" - } + req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents req.DefaultAgent = config.DefaultAgent @@ -956,11 +952,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.ContinuePendingWork = Bool(true) } req.MCPServers = config.MCPServers - if config.MCPOAuthTokenStorage != "" { - req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage - } else { - req.MCPOAuthTokenStorage = "in-memory" - } + req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents req.DefaultAgent = config.DefaultAgent 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/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 4f55c76f4..50ecddd39 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -98,8 +98,6 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setRequestPermission(true); // Always send envValueMode=direct for MCP servers request.setEnvValueMode("direct"); - // Default to in-memory for safe multitenant behavior - request.setMcpOAuthTokenStorage("in-memory"); request.setSessionId(sessionId); if (config == null) { return request; @@ -126,8 +124,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess } config.getIncludeSubAgentStreamingEvents().ifPresent(request::setIncludeSubAgentStreamingEvents); request.setMcpServers(config.getMcpServers()); - request.setMcpOAuthTokenStorage( - config.getMcpOAuthTokenStorage() != null ? config.getMcpOAuthTokenStorage() : "in-memory"); + request.setMcpOAuthTokenStorage(config.getMcpOAuthTokenStorage()); request.setCustomAgents(config.getCustomAgents()); request.setDefaultAgent(config.getDefaultAgent()); request.setAgent(config.getAgent()); @@ -193,8 +190,6 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setRequestPermission(true); // Always send envValueMode=direct for MCP servers request.setEnvValueMode("direct"); - // Default to in-memory for safe multitenant behavior - request.setMcpOAuthTokenStorage("in-memory"); if (config == null) { return request; @@ -226,8 +221,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo } config.getIncludeSubAgentStreamingEvents().ifPresent(request::setIncludeSubAgentStreamingEvents); request.setMcpServers(config.getMcpServers()); - request.setMcpOAuthTokenStorage( - config.getMcpOAuthTokenStorage() != null ? config.getMcpOAuthTokenStorage() : "in-memory"); + request.setMcpOAuthTokenStorage(config.getMcpOAuthTokenStorage()); request.setCustomAgents(config.getCustomAgents()); request.setDefaultAgent(config.getDefaultAgent()); request.setAgent(config.getAgent()); diff --git a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index a6f01f9c9..cf3e329c4 100644 --- a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -105,10 +105,10 @@ void testBuildCreateRequestOmitsEnableSessionTelemetryWhenNotSet() { } @Test - void testBuildCreateRequestDefaultsMcpOAuthTokenStorageToInMemory() { + void testBuildCreateRequestPassesThroughNullMcpOAuthTokenStorage() { var config = new SessionConfig(); CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); - assertEquals("in-memory", request.getMcpOAuthTokenStorage()); + assertNull(request.getMcpOAuthTokenStorage()); } @Test @@ -119,9 +119,9 @@ void testBuildCreateRequestForwardsExplicitMcpOAuthTokenStorage() { } @Test - void testBuildCreateRequestDefaultsMcpOAuthTokenStorageWhenConfigIsNull() { + void testBuildCreateRequestNullConfigHasNullMcpOAuthTokenStorage() { CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(null); - assertEquals("in-memory", request.getMcpOAuthTokenStorage()); + assertNull(request.getMcpOAuthTokenStorage()); } // ========================================================================= @@ -233,10 +233,10 @@ void testBuildResumeRequestSetsClientName() { } @Test - void testBuildResumeRequestDefaultsMcpOAuthTokenStorageToInMemory() { + void testBuildResumeRequestPassesThroughNullMcpOAuthTokenStorage() { var config = new ResumeSessionConfig(); ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-11", config); - assertEquals("in-memory", request.getMcpOAuthTokenStorage()); + assertNull(request.getMcpOAuthTokenStorage()); } @Test @@ -247,9 +247,9 @@ void testBuildResumeRequestForwardsExplicitMcpOAuthTokenStorage() { } @Test - void testBuildResumeRequestDefaultsMcpOAuthTokenStorageWhenConfigIsNull() { + void testBuildResumeRequestNullConfigHasNullMcpOAuthTokenStorage() { ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-13", null); - assertEquals("in-memory", request.getMcpOAuthTokenStorage()); + assertNull(request.getMcpOAuthTokenStorage()); } // ========================================================================= diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 6c6849b92..05cdf9c03 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -891,7 +891,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 {}; } @@ -1104,7 +1104,7 @@ export class CopilotClient { streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, mcpServers: toWireMcpServers(config.mcpServers), - mcpOAuthTokenStorage: config.mcpOAuthTokenStorage ?? "in-memory", + mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", customAgents: toWireCustomAgents(config.customAgents), defaultAgent: config.defaultAgent, @@ -1274,7 +1274,7 @@ export class CopilotClient { streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, mcpServers: toWireMcpServers(config.mcpServers), - mcpOAuthTokenStorage: config.mcpOAuthTokenStorage ?? "in-memory", + mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", customAgents: toWireCustomAgents(config.customAgents), defaultAgent: config.defaultAgent, diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index d6a355323..8ea915d15 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -350,7 +350,25 @@ describe("CopilotClient", () => { spy.mockRestore(); }); - it("defaults mcpOAuthTokenStorage to 'in-memory' in session.create when not specified", async () => { + 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()); @@ -359,17 +377,24 @@ describe("CopilotClient", () => { await client.createSession({ onPermissionRequest: approveAll }); const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; - expect(payload.mcpOAuthTokenStorage).toBe("in-memory"); + expect(payload.mcpOAuthTokenStorage).toBeUndefined(); }); it("forwards explicit 'persistent' for mcpOAuthTokenStorage in session.create", async () => { - const client = new CopilotClient(); + 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"); + 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", }); @@ -377,45 +402,48 @@ describe("CopilotClient", () => { expect(payload.mcpOAuthTokenStorage).toBe("persistent"); }); - it("defaults mcpOAuthTokenStorage to 'in-memory' in session.resume when not specified", async () => { - const client = new CopilotClient(); + 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 session = await client.createSession({ onPermissionRequest: approveAll }); - const spy = vi - .spyOn((client as any).connection!, "sendRequest") - .mockImplementation(async (method: string, params: any) => { + 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.resumeSession(session.sessionId, { onPermissionRequest: approveAll }); + } + ); + 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"); - spy.mockRestore(); }); it("forwards explicit 'persistent' for mcpOAuthTokenStorage in session.resume", async () => { - const client = new CopilotClient(); + const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); onTestFinished(() => client.forceStop()); - const session = await client.createSession({ onPermissionRequest: approveAll }); - const spy = vi - .spyOn((client as any).connection!, "sendRequest") - .mockImplementation(async (method: string, params: any) => { + 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.resumeSession(session.sessionId, { + } + ); + 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"); - spy.mockRestore(); }); it("forwards continuePendingWork in session.resume request", async () => { diff --git a/python/copilot/_mode.py b/python/copilot/_mode.py index 23e392239..d12c86ec1 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: str | None, +) -> str | 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 5c0ce545c..2ab1458a1 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, @@ -1786,8 +1787,10 @@ async def create_session( # Add MCP servers configuration if provided if mcp_servers: payload["mcpServers"] = _mcp_servers_to_wire(mcp_servers) - # Default MCP OAuth token storage to in-memory for safe multitenant behavior - payload["mcpOAuthTokenStorage"] = mcp_oauth_token_storage or "in-memory" + # 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 @@ -2262,8 +2265,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) - # Default MCP OAuth token storage to in-memory for safe multitenant behavior - payload["mcpOAuthTokenStorage"] = mcp_oauth_token_storage or "in-memory" + # 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 3a624616b..d5136a73c 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -974,8 +974,12 @@ async def mock_request(method, params, **kwargs): class TestMcpOAuthTokenStorage: @pytest.mark.asyncio - async def test_create_session_defaults_mcp_oauth_token_storage_to_in_memory(self): - client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + 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" + ) await client.start() try: @@ -989,13 +993,16 @@ async def mock_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_forwards_explicit_mcp_oauth_token_storage(self): + 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() @@ -1010,6 +1017,30 @@ async def mock_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" + ) + 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" @@ -1017,13 +1048,18 @@ async def mock_request(method, params, **kwargs): await client.force_stop() @pytest.mark.asyncio - async def test_resume_session_defaults_mcp_oauth_token_storage_to_in_memory(self): - client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + 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" + ) await client.start() try: session = await client.create_session( - on_permission_request=PermissionHandler.approve_all + on_permission_request=PermissionHandler.approve_all, + available_tools=[], ) captured = {} @@ -1039,6 +1075,7 @@ async def mock_request(method, params, **kwargs): await client.resume_session( session.session_id, on_permission_request=PermissionHandler.approve_all, + available_tools=[], ) assert captured["session.resume"]["mcpOAuthTokenStorage"] == "in-memory" finally: @@ -1046,12 +1083,15 @@ async def mock_request(method, params, **kwargs): @pytest.mark.asyncio async def test_resume_session_forwards_explicit_mcp_oauth_token_storage(self): - client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), mode="empty" + ) await client.start() try: session = await client.create_session( - on_permission_request=PermissionHandler.approve_all + on_permission_request=PermissionHandler.approve_all, + available_tools=[], ) captured = {} @@ -1067,6 +1107,7 @@ async def mock_request(method, params, **kwargs): 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" diff --git a/rust/src/session.rs b/rust/src/session.rs index 93e48ac40..43b8d7cc1 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 38d1e391c..72fcc4cdb 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1366,7 +1366,7 @@ impl Default for SessionConfig { available_tools: None, excluded_tools: None, mcp_servers: None, - mcp_oauth_token_storage: Some("in-memory".into()), + mcp_oauth_token_storage: None, enable_config_discovery: None, skill_directories: None, instruction_directories: None, @@ -2257,7 +2257,7 @@ impl ResumeSessionConfig { available_tools: None, excluded_tools: None, mcp_servers: None, - mcp_oauth_token_storage: Some("in-memory".into()), + mcp_oauth_token_storage: None, enable_config_discovery: None, skill_directories: None, instruction_directories: None, @@ -3902,7 +3902,7 @@ mod tests { #[test] fn session_config_default_wire_flags_off_without_handlers() { let cfg = SessionConfig::default(); - assert_eq!(cfg.mcp_oauth_token_storage.as_deref(), Some("in-memory")); + 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. @@ -3920,7 +3920,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.as_deref(), Some("in-memory")); + assert_eq!(cfg.mcp_oauth_token_storage, None); let (wire, _runtime) = cfg .into_wire() .expect("default resume config has no duplicate handlers"); From 1f0df3e2dde8d0d557e181509325cba36fe5e201 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 28 May 2026 12:48:28 -0700 Subject: [PATCH 10/13] Fix stale doc comments for mcp_oauth_token_storage in Rust The doc comments incorrectly stated the default came from SessionConfig::default(). Updated to reflect that the in-memory default is applied at the client level when ClientMode::Empty is active. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/types.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/rust/src/types.rs b/rust/src/types.rs index 0785ce25f..d63ede83b 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1187,8 +1187,9 @@ pub struct SessionConfig { /// - `"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 `Some("in-memory")` via [`SessionConfig::default`] for safe - /// multitenant behavior. + /// 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, @@ -1843,7 +1844,8 @@ impl SessionConfig { /// - `"persistent"` — tokens stored in the OS keychain. /// - `"in-memory"` — tokens discarded when the session ends. /// - /// Defaults to `"in-memory"` via [`Self::default`]. + /// 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 From 2bae3865c115a7a41475605ed87246a36412e3bb Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 28 May 2026 12:54:07 -0700 Subject: [PATCH 11/13] Fix formatting in Node.js and Python test files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/client.test.ts | 32 ++++++++++++++++---------------- python/test_client.py | 16 ++++------------ 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 767aac537..959389fa4 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -435,13 +435,13 @@ describe("CopilotClient", () => { await client.start(); onTestFinished(() => client.forceStop()); - const spy = vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( - async (method: string, params: any) => { + 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; @@ -465,13 +465,13 @@ describe("CopilotClient", () => { await client.start(); onTestFinished(() => client.forceStop()); - const spy = vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( - async (method: string, params: any) => { + 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: [], @@ -487,14 +487,14 @@ describe("CopilotClient", () => { await client.start(); onTestFinished(() => client.forceStop()); - const spy = vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( - async (method: string, params: any) => { + 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: [] }); @@ -507,14 +507,14 @@ describe("CopilotClient", () => { await client.start(); onTestFinished(() => client.forceStop()); - const spy = vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( - async (method: string, params: any) => { + 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, diff --git a/python/test_client.py b/python/test_client.py index d3cffdf0a..f92d6f518 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1061,9 +1061,7 @@ class TestMcpOAuthTokenStorage: 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" - ) + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH), mode="empty") await client.start() try: @@ -1108,9 +1106,7 @@ async def mock_request(method, params, **kwargs): @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" - ) + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH), mode="empty") await client.start() try: @@ -1135,9 +1131,7 @@ async def mock_request(method, params, **kwargs): 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" - ) + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH), mode="empty") await client.start() try: @@ -1167,9 +1161,7 @@ async def mock_request(method, params, **kwargs): @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" - ) + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH), mode="empty") await client.start() try: From cf3fb916a97c795d1d9f1c7342cf362b964ce185 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 28 May 2026 13:03:13 -0700 Subject: [PATCH 12/13] Fix Python type annotation for _mcp_oauth_token_storage_default Use Literal type instead of str to match the parameter type in client.py, fixing the red-knot invalid-assignment diagnostic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/copilot/_mode.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/copilot/_mode.py b/python/copilot/_mode.py index d12c86ec1..928eefbec 100644 --- a/python/copilot/_mode.py +++ b/python/copilot/_mode.py @@ -184,8 +184,8 @@ def _enable_session_telemetry_default( def _mcp_oauth_token_storage_default( mode: CopilotClientMode | None, - supplied: str | None, -) -> str | 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" From 6794f58934eb4e75a6051ffeb44abbb47d025f3d Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 28 May 2026 13:18:40 -0700 Subject: [PATCH 13/13] Add base_directory to Python empty mode tests Empty mode requires base_directory to avoid falling back to ~/.copilot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/test_client.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/python/test_client.py b/python/test_client.py index f92d6f518..f023149b7 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1061,7 +1061,11 @@ class TestMcpOAuthTokenStorage: 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") + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + mode="empty", + base_directory="/tmp/copilot-test", + ) await client.start() try: @@ -1106,7 +1110,11 @@ async def mock_request(method, params, **kwargs): @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") + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + mode="empty", + base_directory="/tmp/copilot-test", + ) await client.start() try: @@ -1131,7 +1139,11 @@ async def mock_request(method, params, **kwargs): 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") + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + mode="empty", + base_directory="/tmp/copilot-test", + ) await client.start() try: @@ -1161,7 +1173,11 @@ async def mock_request(method, params, **kwargs): @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") + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=CLI_PATH), + mode="empty", + base_directory="/tmp/copilot-test", + ) await client.start() try: