From e5aa2f5c4efbbe547a22778d414a23a32f2e779b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 18 May 2026 12:40:39 -0700 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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):