diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 4a65780bd..6350c23db 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -603,6 +603,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, @@ -772,6 +773,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, @@ -1865,6 +1867,7 @@ internal record CreateSessionRequest( bool? Streaming, bool? IncludeSubAgentStreamingEvents, IDictionary? McpServers, + McpOAuthTokenStorageMode? McpOAuthTokenStorage, string? EnvValueMode, IList? CustomAgents, DefaultAgentConfig? DefaultAgent, @@ -1938,6 +1941,7 @@ internal record ResumeSessionRequest( bool? Streaming, bool? IncludeSubAgentStreamingEvents, IDictionary? McpServers, + McpOAuthTokenStorageMode? McpOAuthTokenStorage, string? EnvValueMode, IList? CustomAgents, DefaultAgentConfig? DefaultAgent, @@ -2030,6 +2034,7 @@ internal record HooksInvokeResponse( [JsonSerializable(typeof(ListSessionsResponse))] [JsonSerializable(typeof(GetSessionMetadataRequest))] [JsonSerializable(typeof(GetSessionMetadataResponse))] + [JsonSerializable(typeof(McpOAuthTokenStorageMode))] [JsonSerializable(typeof(ModelCapabilitiesOverride))] [JsonSerializable(typeof(ProviderConfig))] [JsonSerializable(typeof(ResumeSessionRequest))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 54c0f71b6..50ee71030 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1990,6 +1990,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. /// @@ -2273,6 +2288,7 @@ protected SessionConfigBase(SessionConfigBase? other) ? new Dictionary(dict, dict.Comparer) : new Dictionary(other.McpServers)) : null; + McpOAuthTokenStorage = other.McpOAuthTokenStorage; Model = other.Model; ModelCapabilities = other.ModelCapabilities; OnAutoModeSwitchRequest = other.OnAutoModeSwitchRequest; @@ -2417,6 +2433,12 @@ protected SessionConfigBase(SessionConfigBase? other) /// public IDictionary? McpServers { get; set; } + /// + /// Controls how MCP OAuth tokens are stored for this session. + /// Default: for safe multitenant behavior. + /// + public McpOAuthTokenStorageMode? McpOAuthTokenStorage { get; set; } + /// Custom agent configurations for the session. public IList? CustomAgents { get; set; } diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 184c13b18..8a7a40ede 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -76,6 +76,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 @@ -109,6 +110,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 ae89128a1..01796a00d 100644 --- a/go/client.go +++ b/go/client.go @@ -615,6 +615,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 @@ -839,6 +844,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 39358a72a..3877afc82 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -515,6 +515,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 193c673c5..ad7ef5269 100644 --- a/go/types.go +++ b/go/types.go @@ -925,6 +925,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). @@ -1189,6 +1194,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). @@ -1464,6 +1474,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"` @@ -1526,6 +1537,7 @@ type resumeSessionRequest struct { Streaming *bool `json:"streaming,omitempty"` IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` + MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` DefaultAgent *DefaultAgentConfig `json:"defaultAgent,omitempty"` diff --git a/java/src/main/java/com/github/copilot/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) // ========================================================================= diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 11e6131cb..b02599621 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -876,6 +876,7 @@ export class CopilotClient { streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, mcpServers: toWireMcpServers(config.mcpServers), + mcpOAuthTokenStorage: config.mcpOAuthTokenStorage ?? "in-memory", envValueMode: "direct", customAgents: toWireCustomAgents(config.customAgents), defaultAgent: config.defaultAgent, @@ -1018,6 +1019,7 @@ export class CopilotClient { streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, mcpServers: toWireMcpServers(config.mcpServers), + mcpOAuthTokenStorage: config.mcpOAuthTokenStorage ?? "in-memory", envValueMode: "direct", customAgents: toWireCustomAgents(config.customAgents), defaultAgent: config.defaultAgent, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 938a7f2fc..6e2fea4e0 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1670,6 +1670,15 @@ export interface SessionConfigBase { */ includeSubAgentStreamingEvents?: boolean; + /** + * Controls how MCP OAuth tokens are stored for this session. + * - `"persistent"` — tokens are stored in the OS keychain (shared across sessions) + * - `"in-memory"` — tokens are stored in memory and discarded when the session ends + * + * @default "in-memory" + */ + mcpOAuthTokenStorage?: "persistent" | "in-memory"; + /** * MCP server configurations for the session. * Keys are server names, values are server configurations. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index a4554550e..fa7e0ccdf 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -349,6 +349,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 4386adb08..d0e469643 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1532,6 +1532,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, @@ -1600,6 +1601,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. @@ -1742,6 +1747,8 @@ 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" payload["envValueMode"] = "direct" # Add custom agents configuration if provided @@ -1923,6 +1930,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, @@ -1992,6 +2000,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. @@ -2129,6 +2141,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_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" payload["envValueMode"] = "direct" if custom_agents: diff --git a/python/test_client.py b/python/test_client.py index 757322fa2..b4f14d3c8 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -956,6 +956,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(connection=RuntimeConnection.for_stdio(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(connection=RuntimeConnection.for_stdio(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(connection=RuntimeConnection.for_stdio(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(connection=RuntimeConnection.for_stdio(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 f454e33ed..f80915e6f 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1129,6 +1129,14 @@ pub struct SessionConfig { pub excluded_tools: Option>, /// MCP server configurations passed through to the CLI. pub mcp_servers: Option>, + /// Controls how MCP OAuth tokens are stored for this session. + /// + /// - `"persistent"` — tokens are stored in the OS keychain (shared across sessions). + /// - `"in-memory"` — tokens are stored in memory and discarded when the session ends. + /// + /// Defaults to `Some("in-memory")` via [`SessionConfig::default`] for safe + /// multitenant behavior. + pub mcp_oauth_token_storage: Option, /// When true, the CLI runs config discovery (MCP config files, skills, plugins). pub enable_config_discovery: Option, /// Skill directory paths passed through to the GitHub Copilot CLI. @@ -1257,6 +1265,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("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) @@ -1341,6 +1350,7 @@ impl Default for SessionConfig { available_tools: None, excluded_tools: None, mcp_servers: None, + mcp_oauth_token_storage: Some("in-memory".into()), enable_config_discovery: None, skill_directories: None, instruction_directories: None, @@ -1457,6 +1467,7 @@ impl SessionConfig { available_tools: self.available_tools, excluded_tools: self.excluded_tools, mcp_servers: self.mcp_servers, + mcp_oauth_token_storage: self.mcp_oauth_token_storage, env_value_mode: "direct", enable_config_discovery: self.enable_config_discovery, request_user_input, @@ -1704,6 +1715,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); @@ -1881,6 +1903,9 @@ pub struct ResumeSessionConfig { pub excluded_tools: Option>, /// Re-supply MCP servers so they remain available after app restart. pub mcp_servers: Option>, + /// Controls how MCP OAuth tokens are stored for this session. + /// See [`SessionConfig::mcp_oauth_token_storage`] for details. + pub mcp_oauth_token_storage: Option, /// Enable config discovery on resume. pub enable_config_discovery: Option, /// Skill directory paths passed through to the GitHub Copilot CLI on resume. @@ -1988,6 +2013,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("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) @@ -2109,6 +2135,7 @@ impl ResumeSessionConfig { available_tools: self.available_tools, excluded_tools: self.excluded_tools, mcp_servers: self.mcp_servers, + mcp_oauth_token_storage: self.mcp_oauth_token_storage, env_value_mode: "direct", enable_config_discovery: self.enable_config_discovery, request_user_input, @@ -2176,6 +2203,7 @@ impl ResumeSessionConfig { available_tools: None, excluded_tools: None, mcp_servers: None, + mcp_oauth_token_storage: Some("in-memory".into()), enable_config_discovery: None, skill_directories: None, instruction_directories: None, @@ -2392,6 +2420,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); @@ -3745,6 +3780,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")); // 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. @@ -3762,6 +3798,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")); let (wire, _runtime) = cfg .into_wire() .expect("default resume config has no duplicate handlers"); @@ -3861,6 +3898,7 @@ mod tests { .with_available_tools(["bash", "view"]) .with_excluded_tools(["dangerous"]) .with_mcp_servers(HashMap::new()) + .with_mcp_oauth_token_storage("persistent") .with_enable_config_discovery(true) .with_skill_directories([PathBuf::from("/tmp/skills")]) .with_disabled_skills(["broken-skill"]) @@ -3887,6 +3925,7 @@ mod tests { Some(&["dangerous".to_string()][..]) ); assert!(cfg.mcp_servers.is_some()); + assert_eq!(cfg.mcp_oauth_token_storage.as_deref(), Some("persistent")); assert_eq!(cfg.enable_config_discovery, Some(true)); assert_eq!( cfg.skill_directories.as_deref(), @@ -3919,6 +3958,7 @@ mod tests { .with_available_tools(["bash", "view"]) .with_excluded_tools(["dangerous"]) .with_mcp_servers(HashMap::new()) + .with_mcp_oauth_token_storage("persistent") .with_enable_config_discovery(true) .with_skill_directories([PathBuf::from("/tmp/skills")]) .with_disabled_skills(["broken-skill"]) @@ -3945,6 +3985,7 @@ mod tests { Some(&["dangerous".to_string()][..]) ); assert!(cfg.mcp_servers.is_some()); + assert_eq!(cfg.mcp_oauth_token_storage.as_deref(), Some("persistent")); assert_eq!(cfg.enable_config_discovery, Some(true)); assert_eq!( cfg.skill_directories.as_deref(), diff --git a/rust/src/wire.rs b/rust/src/wire.rs index b97aea261..633503ed4 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -69,6 +69,8 @@ pub(crate) struct SessionCreateWire { pub excluded_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_oauth_token_storage: Option, pub env_value_mode: &'static str, #[serde(skip_serializing_if = "Option::is_none")] pub enable_config_discovery: Option, @@ -145,6 +147,8 @@ pub(crate) struct SessionResumeWire { pub excluded_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_oauth_token_storage: Option, pub env_value_mode: &'static str, #[serde(skip_serializing_if = "Option::is_none")] pub enable_config_discovery: Option,