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..d913841c8 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..0a13394f2 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -615,6 +615,70 @@ func TestResumeSessionRequest_InstructionDirectories(t *testing.T) { }) } +func TestCreateSessionRequest_MCPOAuthTokenStorage(t *testing.T) { + t.Run("includes mcpOAuthTokenStorage in JSON when set", func(t *testing.T) { + req := createSessionRequest{MCPOAuthTokenStorage: "in-memory"} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["mcpOAuthTokenStorage"] != "in-memory" { + t.Errorf("Expected mcpOAuthTokenStorage to be 'in-memory', got %v", m["mcpOAuthTokenStorage"]) + } + }) + + t.Run("omits mcpOAuthTokenStorage from JSON when empty", func(t *testing.T) { + req := createSessionRequest{} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if _, ok := m["mcpOAuthTokenStorage"]; ok { + t.Error("Expected mcpOAuthTokenStorage to be omitted when empty") + } + }) +} + +func TestResumeSessionRequest_MCPOAuthTokenStorage(t *testing.T) { + t.Run("includes mcpOAuthTokenStorage in JSON when set", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1", MCPOAuthTokenStorage: "persistent"} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["mcpOAuthTokenStorage"] != "persistent" { + t.Errorf("Expected mcpOAuthTokenStorage to be 'persistent', got %v", m["mcpOAuthTokenStorage"]) + } + }) + + t.Run("omits mcpOAuthTokenStorage from JSON when empty", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1"} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if _, ok := m["mcpOAuthTokenStorage"]; ok { + t.Error("Expected mcpOAuthTokenStorage to be omitted when empty") + } + }) +} + func TestOverridesBuiltInTool(t *testing.T) { t.Run("OverridesBuiltInTool is serialized in tool definition", func(t *testing.T) { tool := Tool{ diff --git a/go/types.go b/go/types.go index 68a1c38a3..87616fcd6 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/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(); 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/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): 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