Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
config.Streaming is true ? true : null,
config.IncludeSubAgentStreamingEvents,
config.McpServers,
config.McpOAuthTokenStorage ?? McpOAuthTokenStorageMode.InMemory,
"direct",
config.CustomAgents,
config.DefaultAgent,
Expand Down Expand Up @@ -780,6 +781,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
config.Streaming is true ? true : null,
config.IncludeSubAgentStreamingEvents,
config.McpServers,
config.McpOAuthTokenStorage ?? McpOAuthTokenStorageMode.InMemory,
"direct",
config.CustomAgents,
config.DefaultAgent,
Expand Down Expand Up @@ -1986,6 +1988,7 @@ internal record CreateSessionRequest(
bool? Streaming,
bool? IncludeSubAgentStreamingEvents,
IDictionary<string, McpServerConfig>? McpServers,
McpOAuthTokenStorageMode? McpOAuthTokenStorage,
string? EnvValueMode,
IList<CustomAgentConfig>? CustomAgents,
DefaultAgentConfig? DefaultAgent,
Expand Down Expand Up @@ -2050,6 +2053,7 @@ internal record ResumeSessionRequest(
bool? Streaming,
bool? IncludeSubAgentStreamingEvents,
IDictionary<string, McpServerConfig>? McpServers,
McpOAuthTokenStorageMode? McpOAuthTokenStorage,
string? EnvValueMode,
IList<CustomAgentConfig>? CustomAgents,
DefaultAgentConfig? DefaultAgent,
Expand Down Expand Up @@ -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))]
Expand Down
29 changes: 29 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1804,6 +1804,21 @@ public enum McpHttpServerConfigOauthGrantType
ClientCredentials
}

/// <summary>
/// Controls how MCP OAuth tokens are stored for a session.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<McpOAuthTokenStorageMode>))]
public enum McpOAuthTokenStorageMode
{
/// <summary>Tokens are stored in the OS keychain, shared across sessions.</summary>
[JsonStringEnumMemberName("persistent")]
Persistent,

/// <summary>Tokens are stored in memory and discarded when the session ends.</summary>
[JsonStringEnumMemberName("in-memory")]
InMemory
}

/// <summary>
/// Abstract base class for MCP server configurations.
/// </summary>
Expand Down Expand Up @@ -2085,6 +2100,7 @@ protected SessionConfig(SessionConfig? other)
? new Dictionary<string, McpServerConfig>(dict, dict.Comparer)
: new Dictionary<string, McpServerConfig>(other.McpServers))
: null;
McpOAuthTokenStorage = other.McpOAuthTokenStorage;
Model = other.Model;
ModelCapabilities = other.ModelCapabilities;
OnAutoModeSwitch = other.OnAutoModeSwitch;
Expand Down Expand Up @@ -2261,6 +2277,12 @@ protected SessionConfig(SessionConfig? other)
/// </summary>
public IDictionary<string, McpServerConfig>? McpServers { get; set; }

/// <summary>
/// Controls how MCP OAuth tokens are stored for this session.
/// Default: <see cref="McpOAuthTokenStorageMode.InMemory"/> for safe multitenant behavior.
/// </summary>
public McpOAuthTokenStorageMode? McpOAuthTokenStorage { get; set; }

/// <summary>
/// Custom agent configurations for the session.
/// </summary>
Expand Down Expand Up @@ -2394,6 +2416,7 @@ protected ResumeSessionConfig(ResumeSessionConfig? other)
? new Dictionary<string, McpServerConfig>(dict, dict.Comparer)
: new Dictionary<string, McpServerConfig>(other.McpServers))
: null;
McpOAuthTokenStorage = other.McpOAuthTokenStorage;
Model = other.Model;
ModelCapabilities = other.ModelCapabilities;
OnAutoModeSwitch = other.OnAutoModeSwitch;
Expand Down Expand Up @@ -2587,6 +2610,12 @@ protected ResumeSessionConfig(ResumeSessionConfig? other)
/// </summary>
public IDictionary<string, McpServerConfig>? McpServers { get; set; }

/// <summary>
/// Controls how MCP OAuth tokens are stored for this session.
/// Default: <see cref="McpOAuthTokenStorageMode.InMemory"/> for safe multitenant behavior.
/// </summary>
public McpOAuthTokenStorageMode? McpOAuthTokenStorage { get; set; }

/// <summary>
/// Custom agent configurations for the session.
/// </summary>
Expand Down
2 changes: 2 additions & 0 deletions dotnet/test/Unit/CloneTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ public void SessionConfig_Clone_CopiesAllProperties()
EnableSessionTelemetry = false,
IncludeSubAgentStreamingEvents = false,
McpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig { Command = "echo" } },
McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent,
CustomAgents = [new CustomAgentConfig { Name = "agent1", Model = "claude-haiku-4.5" }],
Agent = "agent1",
Cloud = new CloudSessionOptions
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Comment thread
MackinnonBuck marked this conversation as resolved.
})
}

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{
Expand Down
12 changes: 12 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down
2 changes: 2 additions & 0 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,7 @@ export class CopilotClient {
streaming: config.streaming,
includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true,
mcpServers: config.mcpServers,
mcpOAuthTokenStorage: config.mcpOAuthTokenStorage ?? "in-memory",
Comment thread
MackinnonBuck marked this conversation as resolved.
envValueMode: "direct",
customAgents: config.customAgents,
defaultAgent: config.defaultAgent,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1567,6 +1576,7 @@ export type ResumeSessionConfig = Pick<
| "customAgents"
| "defaultAgent"
| "agent"
| "mcpOAuthTokenStorage"
| "skillDirectories"
| "instructionDirectories"
| "disabledSkills"
Expand Down
68 changes: 68 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading