From bf1592a4af745af7786c80a99c59f607d7e5f718 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 28 Aug 2026 11:00:04 +0100 Subject: [PATCH] feat: add managed MCP session support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/features/mcp.md | 75 +++++- dotnet/src/Client.cs | 13 + dotnet/src/Generated/Rpc.cs | 23 ++ dotnet/src/Generated/SessionEvents.cs | 10 +- dotnet/src/Session.cs | 76 ++++++ dotnet/src/Types.cs | 71 ++++++ .../test/Unit/ClientSessionLifetimeTests.cs | 119 +++++++++ dotnet/test/Unit/SerializationTests.cs | 35 +++ go/client.go | 24 ++ go/client_test.go | 70 ++++++ go/rpc/zrpc.go | 44 +++- go/rpc/zrpc_encoding.go | 19 ++ go/rpc/zsession_events.go | 2 + go/session.go | 70 ++++++ go/session_test.go | 144 +++++++++++ go/types.go | 42 ++++ go/zsession_events.go | 1 + .../copilot/generated/McpServerSource.java | 6 +- .../generated/McpServersLoadedServer.java | 4 +- .../generated/rpc/ManagedMcpServerConfig.java | 36 +++ .../copilot/generated/rpc/McpServer.java | 4 +- .../generated/rpc/McpServerSource.java | 6 +- .../generated/rpc/SessionOpenOptions.java | 4 +- .../com/github/copilot/CopilotClient.java | 73 +++--- .../com/github/copilot/CopilotSession.java | 87 +++++++ .../github/copilot/SessionRequestBuilder.java | 8 + .../copilot/rpc/CreateSessionRequest.java | 16 ++ .../copilot/rpc/McpHeadersRefreshHandler.java | 27 ++ .../rpc/McpHeadersRefreshInvocation.java | 36 +++ .../copilot/rpc/McpHeadersRefreshRequest.java | 21 ++ .../copilot/rpc/McpHeadersRefreshResult.java | 59 +++++ .../copilot/rpc/ResumeSessionConfig.java | 50 ++++ .../copilot/rpc/ResumeSessionRequest.java | 16 ++ .../com/github/copilot/rpc/SessionConfig.java | 50 ++++ .../McpAuthInterestRegistrationTest.java | 39 +++ .../copilot/McpHeadersRefreshHandlerTest.java | 139 ++++++++++ .../copilot/SessionRequestBuilderTest.java | 26 ++ .../rpc/GeneratedRpcRecordsCoverageTest.java | 2 +- nodejs/src/client.ts | 16 ++ nodejs/src/generated/rpc.ts | 55 ++++ nodejs/src/generated/session-events.ts | 10 +- nodejs/src/index.ts | 4 + nodejs/src/session.ts | 70 ++++++ nodejs/src/types.ts | 50 ++++ nodejs/test/client.test.ts | 156 ++++++++++++ python/copilot/__init__.py | 10 + python/copilot/client.py | 58 +++++ python/copilot/generated/rpc.py | 82 +++++- python/copilot/generated/session_events.py | 9 +- python/copilot/session.py | 137 ++++++++++ python/test_client.py | 172 ++++++++++++- rust/src/generated/api_types.rs | 60 +++++ rust/src/generated/session_events.rs | 8 +- rust/src/handler.rs | 91 ++++++- rust/src/session.rs | 159 +++++++++++- rust/src/types.rs | 114 ++++++++- rust/src/wire.rs | 7 +- rust/tests/e2e/rpc_tasks_and_handlers.rs | 1 + rust/tests/session_test.rs | 238 +++++++++++++++++- 59 files changed, 2973 insertions(+), 81 deletions(-) create mode 100644 java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedMcpServerConfig.java create mode 100644 java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshHandler.java create mode 100644 java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshInvocation.java create mode 100644 java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshRequest.java create mode 100644 java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshResult.java create mode 100644 java/sdk/src/test/java/com/github/copilot/McpHeadersRefreshHandlerTest.java diff --git a/docs/features/mcp.md b/docs/features/mcp.md index caac633275..31af134793 100644 --- a/docs/features/mcp.md +++ b/docs/features/mcp.md @@ -15,15 +15,86 @@ The Copilot SDK can integrate with **MCP servers** (Model Context Protocol) to e * Call external APIs * And much more -## Server types +## Server transports -The SDK supports two types of MCP servers: +The SDK supports two MCP transport families: | Type | Description | Use Case | |------|-------------|----------| | **Local/Stdio** | Runs as a subprocess, communicates via stdin/stdout | Local tools, file access, custom scripts | | **HTTP/SSE** | Remote server accessed via HTTP | Shared services, cloud-hosted tools | +Transport and configuration origin are separate concepts. A managed MCP server +uses the HTTP transport, but its configuration comes from a trusted host catalog +instead of user or workspace configuration. Session status and loaded-server +events report this distinction with `source: "managed"` and include the catalog +display name. + +## Managed MCP servers + +Managed MCP lets a trusted SDK host inject a catalog of non-secret hosted +servers for one session. The runtime keeps this catalog separate from +`mcpServers`, so existing local stdio, HTTP, SSE, and OAuth behavior remains +unchanged. + +The host supplies each server under a stable managed identity: + + + +```typescript +const session = await client.createSession({ + managedMcpServers: { + "github-enterprise": { + displayName: "GitHub Enterprise", + url: "https://mcp.example.com/", + tools: ["issues", "pull_requests"], + timeout: 30_000, + headersRefreshTtlMs: 60_000, + }, + }, + onMcpHeadersRefresh: async ({ serverName, serverUrl, reason }) => { + const credential = await broker.getCredential({ serverName, serverUrl, reason }); + return { + headers: { Authorization: credential.authorizationHeader }, + ttlMs: credential.expiresInMs, + }; + }, +}); +``` + +The corresponding configuration and callback names are: + +| SDK | Managed servers | Header refresh callback | +| --- | --- | --- | +| Node.js | `managedMcpServers` | `onMcpHeadersRefresh` | +| Python | `managed_mcp_servers` | `on_mcp_headers_refresh` | +| Go | `ManagedMCPServers` | `OnMCPHeadersRefresh` | +| .NET | `ManagedMcpServers` | `OnMcpHeadersRefresh` | +| Java | `setManagedMcpServers(...)` | `setOnMcpHeadersRefreshRequest(...)` | +| Rust | `with_managed_mcp_servers(...)` | `with_mcp_headers_handler(...)` | + +### Host responsibilities + +Managed MCP hosts must enforce these boundaries: + +* **Trusted catalog injection**: Only inject server identities, display metadata, + endpoints, and tool policy from a trusted catalog. Do not treat model output + or untrusted content as catalog configuration. +* **Memory-only credentials**: Keep access tokens and derived authorization + headers in memory. Do not place credentials in `managedMcpServers`, session + history, workspace state, or persistent MCP OAuth storage. +* **Expiry and revocation**: Set `ttlMs` to the remaining credential lifetime. + The runtime clamps it to `headersRefreshTtlMs`. Throw from the callback when + the broker denies access, fails, or reports revocation; the SDK forwards an + explicit broker error without converting it to a successful empty response. +* **Cold resume**: Re-supply both the managed catalog and the header refresh + callback when cold-resuming a session. Managed configuration and credentials + are not recovered from persisted session state. + +Returning no result from the callback reports that no dynamic headers are +available. Existing static `headers`, arbitrary HTTP servers, and MCP OAuth +handlers continue to use their current behavior. + ## Configuration ### Node.js / TypeScript diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index f2da0a48f7..fecebec08d 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -825,6 +825,7 @@ private CopilotSession InitializeSession( config.OnPermissionRequest, config.EnableManagedSettings is true || config.ManagedSettings is not null); session.RegisterMcpAuthHandler(config.OnMcpAuthRequest); + session.RegisterMcpHeadersRefreshHandler(config.OnMcpHeadersRefresh); session.RegisterCommands(config.Commands); session.RegisterElicitationHandler(config.OnElicitationRequest); session.RegisterExitPlanModeHandler(config.OnExitPlanModeRequest); @@ -1206,6 +1207,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.Streaming is true ? true : null, config.IncludeSubAgentStreamingEvents, config.McpServers, + config.ManagedMcpServers, config.McpOAuthTokenStorage, "direct", config.CustomAgents, @@ -1307,6 +1309,10 @@ public async Task CreateSessionAsync(SessionConfig config, Cance { await session.Rpc.EventLog.RegisterInterestAsync("mcp.oauth_required", cancellationToken); } + if (config.OnMcpHeadersRefresh is not null) + { + await session.Rpc.EventLog.RegisterInterestAsync("mcp.headers_refresh_required", cancellationToken); + } session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); @@ -1456,6 +1462,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Streaming is true ? true : null, config.IncludeSubAgentStreamingEvents, config.McpServers, + config.ManagedMcpServers, config.McpOAuthTokenStorage, "direct", config.CustomAgents, @@ -1514,6 +1521,10 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes { await session.Rpc.EventLog.RegisterInterestAsync("mcp.oauth_required", cancellationToken); } + if (config.OnMcpHeadersRefresh is not null) + { + await session.Rpc.EventLog.RegisterInterestAsync("mcp.headers_refresh_required", cancellationToken); + } await UpdateSessionOptionsForModeAsync(session, config, cancellationToken).ConfigureAwait(false); if (registrationId is not null) @@ -2884,6 +2895,7 @@ internal record CreateSessionRequest( bool? Streaming, bool? IncludeSubAgentStreamingEvents, IDictionary? McpServers, + [property: JsonPropertyName("managedMcpServers")] IDictionary? ManagedMcpServers, McpOAuthTokenStorageMode? McpOAuthTokenStorage, string? EnvValueMode, IList? CustomAgents, @@ -3011,6 +3023,7 @@ internal record ResumeSessionRequest( bool? Streaming, bool? IncludeSubAgentStreamingEvents, IDictionary? McpServers, + [property: JsonPropertyName("managedMcpServers")] IDictionary? ManagedMcpServers, McpOAuthTokenStorageMode? McpOAuthTokenStorage, string? EnvValueMode, IList? CustomAgents, diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index ba8b844d83..6607af8f28 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -9163,6 +9163,10 @@ public sealed class McpHostState [Experimental(Diagnostics.Experimental)] public sealed class McpServer { + /// Human-readable display name supplied by a managed server catalog. + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + /// Error message if the server failed to connect. [JsonPropertyName("error")] public string? Error { get; set; } @@ -9918,6 +9922,7 @@ public sealed class McpHeadersHandlePendingHeadersRefreshRequestResult UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)] [JsonDerivedType(typeof(McpHeadersHandlePendingHeadersRefreshRequestHeaders), "headers")] [JsonDerivedType(typeof(McpHeadersHandlePendingHeadersRefreshRequestNone), "none")] +[JsonDerivedType(typeof(McpHeadersHandlePendingHeadersRefreshRequestError), "error")] public partial class McpHeadersHandlePendingHeadersRefreshRequest { /// The type discriminator. @@ -9937,6 +9942,11 @@ public partial class McpHeadersHandlePendingHeadersRefreshRequestHeaders : McpHe /// Headers to overlay onto the MCP request. Dynamic headers override static config headers but do not replace SDK-managed request headers. [JsonPropertyName("headers")] public required IDictionary Headers { get; set; } + + /// Optional lifetime in milliseconds for these returned headers. The runtime clamps its configured cache lifetime to this value. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("ttlMs")] + public long? TtlMs { get; set; } } /// The none variant of . @@ -9948,6 +9958,19 @@ public partial class McpHeadersHandlePendingHeadersRefreshRequestNone : McpHeade public override string Kind => "none"; } +/// The error variant of . +[Experimental(Diagnostics.Experimental)] +public partial class McpHeadersHandlePendingHeadersRefreshRequestError : McpHeadersHandlePendingHeadersRefreshRequest +{ + /// + [JsonIgnore] + public override string Kind => "error"; + + /// Host credential broker failure, denial, or revocation reason. + [JsonPropertyName("message")] + public required string Message { get; set; } +} + /// MCP headers refresh request id and the host response. [Experimental(Diagnostics.Experimental)] internal sealed class McpHeadersHandlePendingHeadersRefreshRequestRequest diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index bb2f3f9979..eb5452fc4e 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -9399,6 +9399,11 @@ public sealed partial class CustomAgentsUpdatedAgent /// Nested data type for McpServersLoadedServer. public sealed partial class McpServersLoadedServer { + /// Human-readable display name supplied by a managed server catalog. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + /// Error message if the server failed to connect. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("error")] @@ -13855,7 +13860,7 @@ public override void Write(Utf8JsonWriter writer, SkillSource value, JsonSeriali } } -/// Configuration source: user, workspace, plugin, or builtin. +/// Configuration source: user, workspace, plugin, builtin, or managed. [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] public readonly struct McpServerSource : IEquatable @@ -13886,6 +13891,9 @@ public McpServerSource(string value) /// Server bundled with the runtime. public static McpServerSource Builtin { get; } = new("builtin"); + /// Server supplied by a trusted host-managed catalog. + public static McpServerSource Managed { get; } = new("managed"); + /// Returns a value indicating whether two instances are equivalent. public static bool operator ==(McpServerSource left, McpServerSource right) => left.Equals(right); diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 7d076d14b0..d1db689102 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -65,6 +65,7 @@ public sealed partial class CopilotSession : IAsyncDisposable private volatile Func>? _permissionHandler; private bool _managedSettingsEnabled; private volatile Func>? _mcpAuthHandler; + private volatile Func>? _mcpHeadersRefreshHandler; private volatile Func>? _userInputHandler; private volatile Func>? _elicitationHandler; private volatile Func>? _exitPlanModeHandler; @@ -590,6 +591,11 @@ internal void RegisterMcpAuthHandler(Func>? _mcpAuthHandler = handler; } + internal void RegisterMcpHeadersRefreshHandler(Func>? handler) + { + _mcpHeadersRefreshHandler = handler; + } + /// /// Handles a permission request from the Copilot CLI. /// @@ -699,6 +705,26 @@ private async Task HandleBroadcastEventAsync(SessionEvent sessionEvent) break; } + case McpHeadersRefreshRequiredEvent refreshEvent: + { + var data = refreshEvent.Data; + var handler = _mcpHeadersRefreshHandler; + if (string.IsNullOrEmpty(data.RequestId) || handler is null) + return; + + await ExecuteMcpHeadersRefreshAndRespondAsync( + data.RequestId, + new McpHeadersRefreshContext + { + SessionId = SessionId, + ServerName = data.ServerName, + ServerUrl = data.ServerUrl, + Reason = data.Reason + }, + handler); + break; + } + case CommandExecuteEvent cmdEvent: { var data = cmdEvent.Data; @@ -826,6 +852,56 @@ private async Task ExecuteMcpAuthAndRespondAsync( } } + private async Task ExecuteMcpHeadersRefreshAndRespondAsync( + string requestId, + McpHeadersRefreshContext context, + Func> handler) + { + McpHeadersHandlePendingHeadersRefreshRequest response; + try + { + var result = await handler(context); + response = result is null + ? new McpHeadersHandlePendingHeadersRefreshRequestNone() + : new McpHeadersHandlePendingHeadersRefreshRequestHeaders + { + Headers = result.Headers, + TtlMs = result.TtlMs + }; + } + catch (OperationCanceledException ex) + { + response = new McpHeadersHandlePendingHeadersRefreshRequestError + { + Message = ex.Message + }; + } + catch (Exception ex) when (IsRecoverableMcpAuthFailure(ex)) + { + response = new McpHeadersHandlePendingHeadersRefreshRequestError + { + Message = ex.Message + }; + } + + try + { + await Rpc.Mcp.Headers.HandlePendingHeadersRefreshRequestAsync(requestId, response); + } + catch (IOException) + { + // Connection lost — nothing we can do. + } + catch (ObjectDisposedException) + { + // Connection already disposed — nothing we can do. + } + catch (RemoteRpcException) + { + // The pending request may already be gone — nothing we can do. + } + } + private static bool IsRecoverableMcpAuthFailure(Exception exception) => exception is not OperationCanceledException and not OutOfMemoryException diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 6ed05e3064..6c3167284b 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1274,6 +1274,57 @@ public sealed class McpAuthResult public static McpAuthResult Cancel() => new() { Cancelled = true }; } +/// Context for a managed MCP dynamic-headers refresh callback. +[Experimental(Diagnostics.Experimental)] +public sealed class McpHeadersRefreshContext +{ + /// Identifier of the session that requested refreshed headers. + public string SessionId { get; set; } = string.Empty; + + /// Human-readable managed catalog display name. + public string ServerName { get; set; } = string.Empty; + + /// Hosted MCP streamable HTTP endpoint. + public string ServerUrl { get; set; } = string.Empty; + + /// Why the runtime invalidated or requested dynamic headers. + public McpHeadersRefreshRequiredReason Reason { get; set; } +} + +/// Dynamic headers and their optional credential-bounded cache lifetime. +[Experimental(Diagnostics.Experimental)] +public sealed class McpHeadersRefreshResult +{ + /// HTTP headers to overlay on requests to the managed MCP server. + public required IDictionary Headers { get; set; } + + /// Optional lifetime in milliseconds for the returned credentials. + public long? TtlMs { get; set; } +} + +/// +/// Non-secret hosted MCP server configuration supplied by a trusted managed catalog. +/// Credentials are provided only through . +/// +[Experimental(Diagnostics.Experimental)] +public sealed class ManagedMcpServerConfig +{ + /// Human-readable catalog display name. + public required string DisplayName { get; set; } + + /// Hosted MCP streamable HTTP endpoint. + public required string Url { get; set; } + + /// Tools to include. The runtime includes all tools when omitted. + public IList? Tools { get; set; } + + /// Timeout in milliseconds for tool discovery and tool calls. + public long? Timeout { get; set; } + + /// Maximum dynamic-header cache lifetime in milliseconds. + public long? HeadersRefreshTtlMs { get; set; } +} + // ============================================================================ // Session Capabilities // ============================================================================ @@ -3182,6 +3233,9 @@ protected SessionConfigBase(SessionConfigBase? other) ? new Dictionary(dict, dict.Comparer) : new Dictionary(other.McpServers)) : null; + ManagedMcpServers = other.ManagedMcpServers is not null + ? new Dictionary(other.ManagedMcpServers) + : null; McpOAuthTokenStorage = other.McpOAuthTokenStorage; Model = other.Model; ModelCapabilities = other.ModelCapabilities; @@ -3190,6 +3244,7 @@ protected SessionConfigBase(SessionConfigBase? other) OnEvent = other.OnEvent; OnExitPlanModeRequest = other.OnExitPlanModeRequest; OnMcpAuthRequest = other.OnMcpAuthRequest; + OnMcpHeadersRefresh = other.OnMcpHeadersRefresh; OnPermissionRequest = other.OnPermissionRequest; OnUserInputRequest = other.OnUserInputRequest; Provider = other.Provider; @@ -3554,6 +3609,13 @@ protected SessionConfigBase(SessionConfigBase? other) /// public IDictionary? McpServers { get; set; } + /// + /// Non-secret hosted MCP servers injected from a trusted managed catalog. + /// Keys are stable managed identities. Re-supply this map on cold resume. + /// + [Experimental(Diagnostics.Experimental)] + public IDictionary? ManagedMcpServers { get; set; } + /// /// Controls how MCP OAuth tokens are stored for this session. /// Default: for safe multitenant behavior. @@ -3785,6 +3847,15 @@ protected SessionConfigBase(SessionConfigBase? other) [Experimental(Diagnostics.Experimental)] [JsonIgnore] public Func>? OnMcpAuthRequest { get; set; } + + /// + /// Supplies short-lived HTTP headers for managed MCP servers. Returning + /// reports no headers; exceptions are sent to the + /// runtime as explicit credential-broker errors. + /// + [Experimental(Diagnostics.Experimental)] + [JsonIgnore] + public Func>? OnMcpHeadersRefresh { get; set; } } /// diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index b61546c650..6eb55f396d 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -814,6 +814,121 @@ public async Task McpAuth_Handler_Exception_Cancels_Pending_Request() Assert.Equal("cancelled", request.Params.GetProperty("result").GetProperty("kind").GetString()); } + [Theory] + [InlineData(null)] + [InlineData(5000L)] + public async Task McpHeadersRefresh_Handler_Sends_Headers_With_Optional_Ttl(long? ttlMs) + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + ManagedMcpServers = new Dictionary + { + ["github"] = new() + { + DisplayName = "GitHub", + Url = "https://example.com/mcp", + HeadersRefreshTtlMs = 60_000 + } + }, + OnMcpHeadersRefresh = context => + { + Assert.Equal("managed-session-name", context.ServerName); + Assert.Equal("https://example.com/mcp", context.ServerUrl); + Assert.Equal(McpHeadersRefreshRequiredReason.Startup, context.Reason); + return Task.FromResult(new() + { + Headers = new Dictionary { ["Authorization"] = "Bearer short-lived" }, + TtlMs = ttlMs + }); + } + }); + + DispatchEvent(session, new McpHeadersRefreshRequiredEvent + { + Data = new McpHeadersRefreshRequiredData + { + RequestId = "mcp-refresh-1", + ServerName = "managed-session-name", + ServerUrl = "https://example.com/mcp", + Reason = McpHeadersRefreshRequiredReason.Startup + } + }); + + var request = await WaitForRequestAsync(server, "session.mcp.headers.handlePendingHeadersRefreshRequest"); + var result = request.Params.GetProperty("result"); + Assert.Equal("headers", result.GetProperty("kind").GetString()); + Assert.Equal("Bearer short-lived", result.GetProperty("headers").GetProperty("Authorization").GetString()); + Assert.Equal(ttlMs.HasValue, result.TryGetProperty("ttlMs", out var ttl)); + if (ttlMs.HasValue) + Assert.Equal(ttlMs.Value, ttl.GetInt64()); + + var create = server.Requests.First(request => request.Method == "session.create"); + Assert.Equal("GitHub", create.Params.GetProperty("managedMcpServers").GetProperty("github").GetProperty("displayName").GetString()); + Assert.Contains( + server.Requests, + request => request.Method == "session.eventLog.registerInterest" + && request.Params.GetProperty("eventType").GetString() == "mcp.headers_refresh_required"); + } + + [Fact] + public async Task McpHeadersRefresh_Handler_Exception_Sends_Explicit_Broker_Error() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + OnMcpHeadersRefresh = _ => throw new ApplicationException("credential revoked") + }); + + DispatchEvent(session, new McpHeadersRefreshRequiredEvent + { + Data = new McpHeadersRefreshRequiredData + { + RequestId = "mcp-refresh-error", + ServerName = "GitHub", + ServerUrl = "https://example.com/mcp", + Reason = McpHeadersRefreshRequiredReason.AuthFailed + } + }); + + var request = await WaitForRequestAsync(server, "session.mcp.headers.handlePendingHeadersRefreshRequest"); + var result = request.Params.GetProperty("result"); + Assert.Equal("error", result.GetProperty("kind").GetString()); + Assert.Equal("credential revoked", result.GetProperty("message").GetString()); + } + + [Fact] + public async Task McpHeadersRefresh_Handler_Cancellation_Sends_Explicit_Broker_Error() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + OnMcpHeadersRefresh = _ => Task.FromCanceled(new CancellationToken(true)) + }); + + DispatchEvent(session, new McpHeadersRefreshRequiredEvent + { + Data = new McpHeadersRefreshRequiredData + { + RequestId = "mcp-refresh-cancelled", + ServerName = "GitHub", + ServerUrl = "https://example.com/mcp", + Reason = McpHeadersRefreshRequiredReason.AuthFailed + } + }); + + var request = await WaitForRequestAsync(server, "session.mcp.headers.handlePendingHeadersRefreshRequest"); + var result = request.Params.GetProperty("result"); + Assert.Equal("error", result.GetProperty("kind").GetString()); + Assert.False(string.IsNullOrWhiteSpace(result.GetProperty("message").GetString())); + } + [Fact] public async Task Generated_Session_Rpc_Throws_When_Session_Disposed() { @@ -1527,6 +1642,10 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel { ["success"] = true }, + "session.mcp.headers.handlePendingHeadersRefreshRequest" => new Dictionary + { + ["success"] = true + }, "session.permissions.handlePendingPermissionRequest" => new Dictionary { ["success"] = true diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 6edf168093..d2c3377ff8 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -17,6 +17,41 @@ namespace GitHub.Copilot.Test.Unit; /// public class SerializationTests { + [Fact] + public void SessionRequests_Serialize_ManagedMcpServers_On_Create_And_Resume() + { + var options = GetSerializerOptions(); + var managed = new Dictionary + { + ["github"] = new() + { + DisplayName = "GitHub", + Url = "https://example.com/mcp", + Tools = ["issues"], + Timeout = 30_000, + HeadersRefreshTtlMs = 60_000 + } + }; + + foreach (var requestName in new[] { "CreateSessionRequest", "ResumeSessionRequest" }) + { + var requestType = GetNestedType(typeof(CopilotClient), requestName); + var request = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("ManagedMcpServers", managed)); + var json = JsonSerializer.Serialize(request, requestType, options); + using var document = JsonDocument.Parse(json); + var server = document.RootElement + .GetProperty("managedMcpServers") + .GetProperty("github"); + + Assert.Equal("GitHub", server.GetProperty("displayName").GetString()); + Assert.Equal("https://example.com/mcp", server.GetProperty("url").GetString()); + Assert.Equal(60_000, server.GetProperty("headersRefreshTtlMs").GetInt64()); + } + } + [Fact] public void ProviderConfig_CanSerializeHeaders_WithSdkOptions() { diff --git a/go/client.go b/go/client.go index 4e44696a55..995f7595f6 100644 --- a/go/client.go +++ b/go/client.go @@ -855,6 +855,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.WorkingDirectory = config.WorkingDirectory req.AdditionalDirectories = config.AdditionalDirectories req.MCPServers = config.MCPServers + req.ManagedMCPServers = config.ManagedMCPServers req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents @@ -975,6 +976,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses s.registerTools(config.Tools) s.registerPermissionHandler(config.OnPermissionRequest) s.registerMCPAuthHandler(config.OnMCPAuthRequest) + s.registerMCPHeadersRefreshHandler(config.OnMCPHeadersRefresh) if config.OnUserInputRequest != nil { s.registerUserInputHandler(config.OnUserInputRequest) } @@ -1114,6 +1116,17 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses return nil, err } } + if config.OnMCPHeadersRefresh != nil { + if _, err := c.client.Request(ctx, "session.eventLog.registerInterest", map[string]any{ + "sessionId": session.SessionID, + "eventType": "mcp.headers_refresh_required", + }); err != nil { + c.sessionsMux.Lock() + delete(c.sessions, registeredSessionID) + c.sessionsMux.Unlock() + return nil, err + } + } session.workspacePath = response.WorkspacePath session.setCapabilities(response.Capabilities) @@ -1261,6 +1274,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, } req.ContinuePendingWork = config.ContinuePendingWork req.MCPServers = config.MCPServers + req.ManagedMCPServers = config.ManagedMCPServers req.MCPOAuthTokenStorage = config.MCPOAuthTokenStorage req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents @@ -1332,6 +1346,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, session.registerTools(config.Tools) session.registerPermissionHandler(config.OnPermissionRequest) session.registerMCPAuthHandler(config.OnMCPAuthRequest) + session.registerMCPHeadersRefreshHandler(config.OnMCPHeadersRefresh) if config.OnUserInputRequest != nil { session.registerUserInputHandler(config.OnUserInputRequest) } @@ -1416,6 +1431,15 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, return nil, err } } + if config.OnMCPHeadersRefresh != nil { + if _, err := c.client.Request(ctx, "session.eventLog.registerInterest", map[string]any{ + "sessionId": sessionID, + "eventType": "mcp.headers_refresh_required", + }); err != nil { + restoreReplacedSession() + return nil, err + } + } session.workspacePath = response.WorkspacePath session.setCapabilities(response.Capabilities) diff --git a/go/client_test.go b/go/client_test.go index c6ab0808cb..80706fbbd3 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -2139,6 +2139,76 @@ func TestClient_MCPAuthInterestRegistration(t *testing.T) { }) } +func TestClient_ManagedMCPCreateAndResume(t *testing.T) { + client, requests, cleanup := newInMemoryClient(t) + defer cleanup() + + ttl := int64(60_000) + managed := map[string]ManagedMCPServerConfig{ + "github": { + DisplayName: "GitHub", + URL: "https://example.com/mcp", + Tools: []string{"issues"}, + HeadersRefreshTtlMs: &ttl, + }, + } + handler := func(MCPHeadersRefreshRequest, MCPHeadersRefreshInvocation) (*MCPHeadersRefreshResult, error) { + return nil, nil + } + + session, err := client.CreateSession(t.Context(), &SessionConfig{ + ManagedMCPServers: managed, + OnMCPHeadersRefresh: handler, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session.Disconnect() + + create, ok := findRequest(requests.snapshot(), "session.create") + if !ok { + t.Fatal("missing session.create request") + } + managedWire, ok := create.Params["managedMcpServers"].(map[string]any) + if !ok { + t.Fatalf("unexpected managedMcpServers: %#v", create.Params["managedMcpServers"]) + } + github, ok := managedWire["github"].(map[string]any) + if !ok || github["displayName"] != "GitHub" || github["headersRefreshTtlMs"] != float64(60_000) { + t.Fatalf("unexpected managed server payload: %#v", managedWire) + } + if !hasEventInterest(requests.snapshot(), "mcp.headers_refresh_required") { + t.Fatal("missing managed MCP headers refresh interest on create") + } + + requests.clear() + resumed, err := client.ResumeSession(t.Context(), session.SessionID, &ResumeSessionConfig{ + ManagedMCPServers: managed, + OnMCPHeadersRefresh: handler, + }) + if err != nil { + t.Fatalf("ResumeSession failed: %v", err) + } + defer resumed.Disconnect() + + resume, ok := findRequest(requests.snapshot(), "session.resume") + if !ok || resume.Params["managedMcpServers"] == nil { + t.Fatalf("managed MCP servers missing from resume: %#v", resume.Params) + } + if !hasEventInterest(requests.snapshot(), "mcp.headers_refresh_required") { + t.Fatal("missing managed MCP headers refresh interest on resume") + } +} + +func hasEventInterest(requests []recordedRequest, eventType string) bool { + for _, request := range requests { + if request.Method == "session.eventLog.registerInterest" && request.Params["eventType"] == eventType { + return true + } + } + return false +} + func findRequest(requests []recordedRequest, method string) (recordedRequest, bool) { for _, r := range requests { if r.Method == method { diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 2b3943988d..e644a814d8 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -4630,6 +4630,23 @@ type LspInitializeRequest struct { WorkingDirectory *string `json:"workingDirectory,omitempty"` } +// Non-secret host-managed HTTP MCP server configuration. The containing map key is the +// stable managed identity; credentials are supplied dynamically by the host. +// Experimental: ManagedMCPServerConfig is part of an experimental API and may change or be +// removed. +type ManagedMCPServerConfig struct { + // Human-readable catalog display name. + DisplayName string `json:"displayName"` + // Maximum dynamic-header cache lifetime in milliseconds. + HeadersRefreshTtlMs *int64 `json:"headersRefreshTtlMs,omitempty"` + // Timeout in milliseconds for tool discovery and tool calls. + Timeout *int64 `json:"timeout,omitempty"` + // Tools to include. Defaults to all tools when omitted. + Tools []string `json:"tools,omitzero"` + // Hosted MCP streamable HTTP endpoint. + URL string `json:"url"` +} + // Validated device-managed settings discovered before a session exists. // Experimental: ManagedSettingsReadResult is part of an experimental API and may change or // be removed. @@ -5134,10 +5151,24 @@ func (r RawMCPHeadersHandlePendingHeadersRefreshRequestData) Kind() MCPHeadersHa return r.Discriminator } +type MCPHeadersHandlePendingHeadersRefreshRequestError struct { + // Host credential broker failure, denial, or revocation reason. + Message string `json:"message"` +} + +func (MCPHeadersHandlePendingHeadersRefreshRequestError) mcpHeadersHandlePendingHeadersRefreshRequest() { +} +func (MCPHeadersHandlePendingHeadersRefreshRequestError) Kind() MCPHeadersHandlePendingHeadersRefreshRequestKind { + return MCPHeadersHandlePendingHeadersRefreshRequestKindError +} + type MCPHeadersHandlePendingHeadersRefreshRequestHeaders struct { // Headers to overlay onto the MCP request. Dynamic headers override static config headers // but do not replace SDK-managed request headers. Headers map[string]string `json:"headers"` + // Optional lifetime in milliseconds for these returned headers. The runtime clamps its + // configured cache lifetime to this value. + TtlMs *int64 `json:"ttlMs,omitempty"` } func (MCPHeadersHandlePendingHeadersRefreshRequestHeaders) mcpHeadersHandlePendingHeadersRefreshRequest() { @@ -6328,6 +6359,8 @@ func (MCPServerConfigStdio) mcpSerializableServerConfig() {} // MCP server status entry, including config source/plugin source and any connection error. // Experimental: MCPServer is part of an experimental API and may change or be removed. type MCPServer struct { + // Human-readable display name supplied by a managed server catalog. + DisplayName *string `json:"displayName,omitempty"` // Error message if the server failed to connect Error *string `json:"error,omitempty"` // Server name (config key) @@ -11931,6 +11964,12 @@ type SessionOpenOptions struct { LogInteractiveShells *bool `json:"logInteractiveShells,omitempty"` // Identifier sent to LSP-style integrations. LspClientName *string `json:"lspClientName,omitempty"` + // Non-secret host-managed HTTP MCP servers keyed by stable managed identity. Managed + // provenance is runtime-established from this separate field and credentials are supplied + // through dynamic-header refresh. + // Experimental: ManagedMCPServers is part of an experimental API and may change or be + // removed. + ManagedMCPServers map[string]ManagedMCPServerConfig `json:"managedMcpServers,omitzero"` // Permissions-only enterprise policy injected by the SDK host at session create or resume. // Composes restrictively with self-fetched and device policy and is not persisted. ManagedSettings *SessionManagedSettings `json:"managedSettings,omitempty"` @@ -16947,6 +16986,7 @@ const ( type MCPHeadersHandlePendingHeadersRefreshRequestKind string const ( + MCPHeadersHandlePendingHeadersRefreshRequestKindError MCPHeadersHandlePendingHeadersRefreshRequestKind = "error" MCPHeadersHandlePendingHeadersRefreshRequestKindHeaders MCPHeadersHandlePendingHeadersRefreshRequestKind = "headers" MCPHeadersHandlePendingHeadersRefreshRequestKindNone MCPHeadersHandlePendingHeadersRefreshRequestKind = "none" ) @@ -17337,13 +17377,15 @@ const ( MCPServerConfigStdioTypeStdio MCPServerConfigStdioType = "stdio" ) -// Configuration source: user, workspace, plugin, or builtin +// Configuration source: user, workspace, plugin, builtin, or managed // Experimental: MCPServerSource is part of an experimental API and may change or be removed. type MCPServerSource string const ( // Server bundled with the runtime. MCPServerSourceBuiltin MCPServerSource = "builtin" + // Server supplied by a trusted host-managed catalog. + MCPServerSourceManaged MCPServerSource = "managed" // Server contributed by an installed plugin. MCPServerSourcePlugin MCPServerSource = "plugin" // Server configured in the user's global MCP configuration. diff --git a/go/rpc/zrpc_encoding.go b/go/rpc/zrpc_encoding.go index 78788660f5..0722865974 100644 --- a/go/rpc/zrpc_encoding.go +++ b/go/rpc/zrpc_encoding.go @@ -2222,6 +2222,12 @@ func unmarshalMCPHeadersHandlePendingHeadersRefreshRequest(data []byte) (MCPHead } switch raw.Kind { + case MCPHeadersHandlePendingHeadersRefreshRequestKindError: + var d MCPHeadersHandlePendingHeadersRefreshRequestError + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil case MCPHeadersHandlePendingHeadersRefreshRequestKindHeaders: var d MCPHeadersHandlePendingHeadersRefreshRequestHeaders if err := json.Unmarshal(data, &d); err != nil { @@ -2250,6 +2256,17 @@ func (r RawMCPHeadersHandlePendingHeadersRefreshRequestData) MarshalJSON() ([]by }) } +func (r MCPHeadersHandlePendingHeadersRefreshRequestError) MarshalJSON() ([]byte, error) { + type alias MCPHeadersHandlePendingHeadersRefreshRequestError + return json.Marshal(struct { + Kind MCPHeadersHandlePendingHeadersRefreshRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + func (r MCPHeadersHandlePendingHeadersRefreshRequestHeaders) MarshalJSON() ([]byte, error) { type alias MCPHeadersHandlePendingHeadersRefreshRequestHeaders return json.Marshal(struct { @@ -5397,6 +5414,7 @@ func (r *SessionOpenOptions) UnmarshalJSON(data []byte) error { IsExperimentalMode *bool `json:"isExperimentalMode,omitempty"` LogInteractiveShells *bool `json:"logInteractiveShells,omitempty"` LspClientName *string `json:"lspClientName,omitempty"` + ManagedMCPServers map[string]ManagedMCPServerConfig `json:"managedMcpServers,omitzero"` ManagedSettings *SessionManagedSettings `json:"managedSettings,omitempty"` MaxInlineBinaryBytes *int64 `json:"maxInlineBinaryBytes,omitempty"` Memory *MemoryConfiguration `json:"memory,omitempty"` @@ -5477,6 +5495,7 @@ func (r *SessionOpenOptions) UnmarshalJSON(data []byte) error { r.IsExperimentalMode = raw.IsExperimentalMode r.LogInteractiveShells = raw.LogInteractiveShells r.LspClientName = raw.LspClientName + r.ManagedMCPServers = raw.ManagedMCPServers r.ManagedSettings = raw.ManagedSettings r.MaxInlineBinaryBytes = raw.MaxInlineBinaryBytes r.Memory = raw.Memory diff --git a/go/rpc/zsession_events.go b/go/rpc/zsession_events.go index 2abfd75a66..fca45e10fa 100644 --- a/go/rpc/zsession_events.go +++ b/go/rpc/zsession_events.go @@ -2931,6 +2931,8 @@ type MCPOauthWwwAuthenticateParams struct { // A single MCP server status summary in `session.mcp_servers_loaded`, including name, status, source, transport, and plugin metadata. type MCPServersLoadedServer struct { + // Human-readable display name supplied by a managed server catalog. + DisplayName *string `json:"displayName,omitempty"` // Error message if the server failed to connect Error *string `json:"error,omitempty"` // Server name (config key) diff --git a/go/session.go b/go/session.go index 5d45d19ef2..229faf73cc 100644 --- a/go/session.go +++ b/go/session.go @@ -70,6 +70,8 @@ type Session struct { managedSettings bool mcpAuthHandler MCPAuthHandler mcpAuthMu sync.RWMutex + mcpHeadersRefreshHandler MCPHeadersRefreshHandler + mcpHeadersRefreshMu sync.RWMutex userInputHandler UserInputHandler userInputMux sync.RWMutex exitPlanModeHandler ExitPlanModeRequestHandler @@ -979,6 +981,18 @@ func (s *Session) getMCPAuthHandler() MCPAuthHandler { return s.mcpAuthHandler } +func (s *Session) registerMCPHeadersRefreshHandler(handler MCPHeadersRefreshHandler) { + s.mcpHeadersRefreshMu.Lock() + defer s.mcpHeadersRefreshMu.Unlock() + s.mcpHeadersRefreshHandler = handler +} + +func (s *Session) getMCPHeadersRefreshHandler() MCPHeadersRefreshHandler { + s.mcpHeadersRefreshMu.RLock() + defer s.mcpHeadersRefreshMu.RUnlock() + return s.mcpHeadersRefreshHandler +} + func (s *Session) handleMCPAuthRequest(request MCPAuthRequest) { handler := s.getMCPAuthHandler() if handler == nil { @@ -1506,6 +1520,21 @@ func (s *Session) handleBroadcastEvent(event SessionEvent) { } s.handleMCPAuthRequest(request) + case *MCPHeadersRefreshRequiredData: + handler := s.getMCPHeadersRefreshHandler() + if d.RequestID == "" || handler == nil { + return + } + s.executeMCPHeadersRefreshAndRespond( + d.RequestID, + MCPHeadersRefreshRequest{ + ServerName: d.ServerName, + ServerURL: d.ServerURL, + Reason: d.Reason, + }, + handler, + ) + case *CommandExecuteData: s.executeCommandAndRespond(d.RequestID, d.CommandName, d.Command, d.Args) @@ -1670,6 +1699,47 @@ func (s *Session) executePermissionAndRespond(requestID string, permissionReques }) } +func (s *Session) executeMCPHeadersRefreshAndRespond( + requestID string, + request MCPHeadersRefreshRequest, + handler MCPHeadersRefreshHandler, +) { + var wireResult rpc.MCPHeadersHandlePendingHeadersRefreshRequest + result, err := handler(request, MCPHeadersRefreshInvocation{SessionID: s.SessionID}) + switch { + case err != nil: + log.Printf( + "MCP headers refresh failed: session_id=%s server_name=%s error=%v", + s.SessionID, + request.ServerName, + err, + ) + wireResult = rpc.MCPHeadersHandlePendingHeadersRefreshRequestError{Message: err.Error()} + case result == nil: + wireResult = rpc.MCPHeadersHandlePendingHeadersRefreshRequestNone{} + default: + wireResult = rpc.MCPHeadersHandlePendingHeadersRefreshRequestHeaders{ + Headers: result.Headers, + TtlMs: result.TTLMS, + } + } + + if _, rpcErr := s.RPC.MCP.Headers().HandlePendingHeadersRefreshRequest( + context.Background(), + &rpc.MCPHeadersHandlePendingHeadersRefreshRequestRequest{ + RequestID: requestID, + Result: wireResult, + }, + ); rpcErr != nil { + log.Printf( + "failed to send MCP headers refresh response: session_id=%s request_id=%s error=%v", + s.SessionID, + requestID, + rpcErr, + ) + } +} + // GetEvents retrieves all events from this session's history. // // This returns the complete conversation history including user messages, diff --git a/go/session_test.go b/go/session_test.go index 74e212c418..547bb769dd 100644 --- a/go/session_test.go +++ b/go/session_test.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "io" "strconv" @@ -203,6 +204,149 @@ func TestSession_MCPAuthRequestSendsHostToken(t *testing.T) { } } +func TestSession_MCPHeadersRefreshResponses(t *testing.T) { + ttl := int64(5_000) + tests := []struct { + name string + result *MCPHeadersRefreshResult + err error + expected map[string]any + }{ + { + name: "headers without ttl", + result: &MCPHeadersRefreshResult{Headers: map[string]string{"Authorization": "Bearer first"}}, + expected: map[string]any{ + "kind": "headers", + "headers": map[string]any{"Authorization": "Bearer first"}, + }, + }, + { + name: "headers with ttl", + result: &MCPHeadersRefreshResult{ + Headers: map[string]string{"Authorization": "Bearer refreshed"}, + TTLMS: &ttl, + }, + expected: map[string]any{ + "kind": "headers", + "headers": map[string]any{"Authorization": "Bearer refreshed"}, + "ttlMs": float64(5_000), + }, + }, + { + name: "no headers", + expected: map[string]any{"kind": "none"}, + }, + { + name: "broker error", + err: errors.New("credential revoked"), + expected: map[string]any{"kind": "error", "message": "credential revoked"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + defer stdinR.Close() + defer stdinW.Close() + defer stdoutR.Close() + defer stdoutW.Close() + + client := jsonrpc2.NewClient(stdinW, stdoutR) + client.Start() + defer client.Stop() + + paramsCh := make(chan map[string]any, 1) + errCh := make(chan error, 1) + go func() { + frame, err := readTestJSONRPCFrame(stdinR) + if err != nil { + errCh <- err + return + } + var request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` + } + if err := json.Unmarshal(frame, &request); err != nil { + errCh <- err + return + } + if request.Method != "session.mcp.headers.handlePendingHeadersRefreshRequest" { + errCh <- fmt.Errorf("unexpected method %s", request.Method) + return + } + paramsCh <- request.Params + response, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": json.RawMessage(request.ID), + "result": map[string]any{"success": true}, + }) + if err != nil { + errCh <- err + return + } + _, err = fmt.Fprintf(stdoutW, "Content-Length: %d\r\n\r\n%s", len(response), response) + if err != nil { + errCh <- err + } + }() + + session := &Session{ + SessionID: "session-1", + client: client, + RPC: rpc.NewSessionRPC(client, "session-1"), + } + session.registerMCPHeadersRefreshHandler( + func(request MCPHeadersRefreshRequest, invocation MCPHeadersRefreshInvocation) (*MCPHeadersRefreshResult, error) { + if request.ServerName != "GitHub" || request.Reason != MCPHeadersRefreshRequiredReasonStartup { + t.Fatalf("unexpected request: %#v", request) + } + if invocation.SessionID != "session-1" { + t.Fatalf("unexpected invocation: %#v", invocation) + } + return tt.result, tt.err + }, + ) + session.handleBroadcastEvent(SessionEvent{ + Data: &MCPHeadersRefreshRequiredData{ + RequestID: "refresh-1", + ServerName: "GitHub", + ServerURL: "https://example.com/mcp", + Reason: MCPHeadersRefreshRequiredReasonStartup, + }, + }) + + select { + case params := <-paramsCh: + if params["requestId"] != "refresh-1" { + t.Fatalf("unexpected requestId: %v", params["requestId"]) + } + if !mapsEqual(params["result"], tt.expected) { + t.Fatalf("unexpected result: %#v", params["result"]) + } + case err := <-errCh: + t.Fatal(err) + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for MCP headers refresh response") + } + }) + } +} + +func mapsEqual(actual any, expected map[string]any) bool { + actualJSON, err := json.Marshal(actual) + if err != nil { + return false + } + expectedJSON, err := json.Marshal(expected) + if err != nil { + return false + } + return string(actualJSON) == string(expectedJSON) +} + func TestMCPAuthRequestAllowsMissingOptionalMetadata(t *testing.T) { request := MCPAuthRequest{RequestID: "oauth-request"} if request.ResourceMetadata != nil { diff --git a/go/types.go b/go/types.go index 1d98e06158..5f1ba06a55 100644 --- a/go/types.go +++ b/go/types.go @@ -500,6 +500,36 @@ type MCPAuthInvocation struct { // MCPAuthHandler handles MCP OAuth requests from the runtime. type MCPAuthHandler func(request MCPAuthRequest, invocation MCPAuthInvocation) (*MCPAuthResult, error) +// ManagedMCPServerConfig is a non-secret hosted MCP server from a trusted +// managed catalog. Credentials are supplied only through [MCPHeadersRefreshHandler]. +// +// Experimental: ManagedMCPServerConfig is part of an experimental API and may change. +type ManagedMCPServerConfig = rpc.ManagedMCPServerConfig + +// MCPHeadersRefreshRequest identifies a managed MCP server whose short-lived +// HTTP headers need refreshing. +type MCPHeadersRefreshRequest struct { + ServerName string + ServerURL string + Reason MCPHeadersRefreshRequiredReason +} + +// MCPHeadersRefreshResult contains dynamic headers and their optional +// credential-bounded cache lifetime. A nil result means no headers are available. +type MCPHeadersRefreshResult struct { + Headers map[string]string + TTLMS *int64 +} + +// MCPHeadersRefreshInvocation provides context for a managed MCP headers refresh. +type MCPHeadersRefreshInvocation struct { + SessionID string +} + +// MCPHeadersRefreshHandler supplies short-lived HTTP headers for managed MCP +// servers. Returning an error sends an explicit broker error to the runtime. +type MCPHeadersRefreshHandler func(request MCPHeadersRefreshRequest, invocation MCPHeadersRefreshInvocation) (*MCPHeadersRefreshResult, error) + // UserInputRequest represents a request for user input from the agent type UserInputRequest struct { Question string @@ -1337,6 +1367,8 @@ type SessionConfig struct { // When provided, the SDK can satisfy MCP server OAuth requests with host-provided // token data or cancellation. OnMCPAuthRequest MCPAuthHandler + // OnMCPHeadersRefresh supplies short-lived HTTP headers for ManagedMCPServers. + OnMCPHeadersRefresh MCPHeadersRefreshHandler // GitHubTokenProvider acquires session-scoped GitHub tokens on demand. It // cannot be combined with GitHubToken. GitHubTokenProvider GitHubTokenProvider @@ -1420,6 +1452,9 @@ type SessionConfig struct { ModelCapabilities *rpc.ModelCapabilitiesOverride // MCPServers configures MCP servers for the session MCPServers map[string]MCPServerConfig + // ManagedMCPServers contains non-secret hosted MCP servers keyed by stable + // managed identity. Re-supply this map when cold-resuming a session. + ManagedMCPServers map[string]ManagedMCPServerConfig // MCPOAuthTokenStorage controls how MCP OAuth tokens are stored for this session. // When empty, the runtime default ("in-memory") is used. MCPOAuthTokenStorage string @@ -1914,6 +1949,8 @@ type ResumeSessionConfig struct { // OnMCPAuthRequest is an optional handler for MCP OAuth requests from MCP servers. // See SessionConfig.OnMCPAuthRequest. OnMCPAuthRequest MCPAuthHandler + // OnMCPHeadersRefresh supplies short-lived HTTP headers for ManagedMCPServers. + OnMCPHeadersRefresh MCPHeadersRefreshHandler // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) OnUserInputRequest UserInputHandler // Hooks configures hook handlers for session lifecycle events @@ -1970,6 +2007,9 @@ type ResumeSessionConfig struct { IncludeSubAgentStreamingEvents *bool // MCPServers configures MCP servers for the session MCPServers map[string]MCPServerConfig + // ManagedMCPServers contains non-secret hosted MCP servers keyed by stable + // managed identity. Re-supply this map when cold-resuming a session. + ManagedMCPServers map[string]ManagedMCPServerConfig // MCPOAuthTokenStorage controls how MCP OAuth tokens are stored for this session. // When empty, the runtime default ("in-memory") is used. MCPOAuthTokenStorage string @@ -2518,6 +2558,7 @@ type createSessionRequest struct { IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` + ManagedMCPServers map[string]ManagedMCPServerConfig `json:"managedMcpServers,omitempty"` MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` @@ -2627,6 +2668,7 @@ type resumeSessionRequest struct { IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` EnableGitHubTelemetryForwarding *bool `json:"enableGitHubTelemetryForwarding,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` + ManagedMCPServers map[string]ManagedMCPServerConfig `json:"managedMcpServers,omitempty"` MCPOAuthTokenStorage string `json:"mcpOAuthTokenStorage,omitempty"` EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` diff --git a/go/zsession_events.go b/go/zsession_events.go index 11a1bd666f..f2b53fc658 100644 --- a/go/zsession_events.go +++ b/go/zsession_events.go @@ -523,6 +523,7 @@ const ( MCPOauthRequestReasonUpscope = rpc.MCPOauthRequestReasonUpscope MCPOauthRequiredStaticClientConfigGrantTypeClientCredentials = rpc.MCPOauthRequiredStaticClientConfigGrantTypeClientCredentials MCPServerSourceBuiltin = rpc.MCPServerSourceBuiltin + MCPServerSourceManaged = rpc.MCPServerSourceManaged MCPServerSourcePlugin = rpc.MCPServerSourcePlugin MCPServerSourceUser = rpc.MCPServerSourceUser MCPServerSourceWorkspace = rpc.MCPServerSourceWorkspace diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/McpServerSource.java b/java/sdk/src/generated/java/com/github/copilot/generated/McpServerSource.java index 63514743ab..5ea2c39f51 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/McpServerSource.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/McpServerSource.java @@ -10,7 +10,7 @@ import javax.annotation.processing.Generated; /** - * Configuration source: user, workspace, plugin, or builtin + * Configuration source: user, workspace, plugin, builtin, or managed * * @since 1.0.0 */ @@ -23,7 +23,9 @@ public enum McpServerSource { /** The {@code plugin} variant. */ PLUGIN("plugin"), /** The {@code builtin} variant. */ - BUILTIN("builtin"); + BUILTIN("builtin"), + /** The {@code managed} variant. */ + MANAGED("managed"); private final String value; McpServerSource(String value) { this.value = value; } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/McpServersLoadedServer.java b/java/sdk/src/generated/java/com/github/copilot/generated/McpServersLoadedServer.java index c4567f30fa..bc840d90bd 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/McpServersLoadedServer.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/McpServersLoadedServer.java @@ -34,6 +34,8 @@ public record McpServersLoadedServer( /** Name of the plugin that supplied the effective MCP server config, only when source is plugin */ @JsonProperty("pluginName") String pluginName, /** Version of the plugin that supplied the effective MCP server config, only when source is plugin */ - @JsonProperty("pluginVersion") String pluginVersion + @JsonProperty("pluginVersion") String pluginVersion, + /** Human-readable display name supplied by a managed server catalog. */ + @JsonProperty("displayName") String displayName ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedMcpServerConfig.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedMcpServerConfig.java new file mode 100644 index 0000000000..4a202063af --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/ManagedMcpServerConfig.java @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import javax.annotation.processing.Generated; + +/** + * Non-secret host-managed HTTP MCP server configuration. The containing map key is the stable managed identity; credentials are supplied dynamically by the host. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record ManagedMcpServerConfig( + /** Human-readable catalog display name. */ + @JsonProperty("displayName") String displayName, + /** Hosted MCP streamable HTTP endpoint. */ + @JsonProperty("url") String url, + /** Tools to include. Defaults to all tools when omitted. */ + @JsonProperty("tools") List tools, + /** Timeout in milliseconds for tool discovery and tool calls. */ + @JsonProperty("timeout") Long timeout, + /** Maximum dynamic-header cache lifetime in milliseconds. */ + @JsonProperty("headersRefreshTtlMs") Long headersRefreshTtlMs +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpServer.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpServer.java index 14a9118d0b..8251c1f1fb 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpServer.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpServer.java @@ -32,6 +32,8 @@ public record McpServer( /** Plugin version that provided this server, when source is plugin. */ @JsonProperty("sourcePluginVersion") String sourcePluginVersion, /** Error message if the server failed to connect */ - @JsonProperty("error") String error + @JsonProperty("error") String error, + /** Human-readable display name supplied by a managed server catalog. */ + @JsonProperty("displayName") String displayName ) { } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpServerSource.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpServerSource.java index f709df96dd..72b1503d59 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpServerSource.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/McpServerSource.java @@ -10,7 +10,7 @@ import javax.annotation.processing.Generated; /** - * Configuration source: user, workspace, plugin, or builtin + * Configuration source: user, workspace, plugin, builtin, or managed * * @since 1.0.0 */ @@ -23,7 +23,9 @@ public enum McpServerSource { /** The {@code plugin} variant. */ PLUGIN("plugin"), /** The {@code builtin} variant. */ - BUILTIN("builtin"); + BUILTIN("builtin"), + /** The {@code managed} variant. */ + MANAGED("managed"); private final String value; McpServerSource(String value) { this.value = value; } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionOpenOptions.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionOpenOptions.java index 34706a68e1..8421bb2666 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionOpenOptions.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionOpenOptions.java @@ -160,6 +160,8 @@ public record SessionOpenOptions( /** Memory configuration for this session. */ @JsonProperty("memory") MemoryConfiguration memory, /** Capabilities enabled for this session. */ - @JsonProperty("sessionCapabilities") List sessionCapabilities + @JsonProperty("sessionCapabilities") List sessionCapabilities, + /** Non-secret host-managed HTTP MCP servers keyed by stable managed identity. Managed provenance is runtime-established from this separate field and credentials are supplied through dynamic-header refresh. */ + @JsonProperty("managedMcpServers") Map managedMcpServers ) { } diff --git a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java index ea2b0b67dd..04c4bec4cc 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java @@ -1018,32 +1018,29 @@ public CompletableFuture createSession(SessionConfig config) { session.setGitHubTokenProviderRegistration(tokenRegistration); } registeredIdHolder[0] = returnedId; - CompletableFuture interest = config.getOnMcpAuthRequest() != null - ? session.getRpc().eventLog.registerInterest( - new SessionEventLogRegisterInterestParams(returnedId, "mcp.oauth_required")) - : CompletableFuture.completedFuture(null); + CompletableFuture interests = registerMcpEventInterests(session, returnedId, + config.getOnMcpAuthRequest() != null, config.getOnMcpHeadersRefreshRequest() != null); session.setWorkspacePath(response.workspacePath()); session.setCapabilities(response.capabilities()); session.setOpenCanvases(response.openCanvases()); - return interest.thenCompose(interestResult -> { - logMcpAuthInterestRegistration(interestResult); - return updateSessionOptionsForMode(session, config.getSkipCustomInstructions().orElse(null), - config.getCustomAgentsLocalOnly().orElse(null), - config.getCoauthorEnabled().orElse(null), - config.getManageScheduleEnabled().orElse(null), config.getIncludedBuiltinSkills()); - }).thenApply(v -> { - if (tokenRegistration != null) { - tokenRegistration.claim(session.getSessionId()); - } else { - gitHubTokenProviders.retire(session.getSessionId()); - } - LoggingHelpers.logTiming(LOG, Level.FINE, - "CopilotClient.createSession complete. Elapsed={Elapsed}, SessionId=" - + session.getSessionId(), - totalNanos); - return session; - }); + return interests.thenCompose(v -> updateSessionOptionsForMode(session, + config.getSkipCustomInstructions().orElse(null), + config.getCustomAgentsLocalOnly().orElse(null), + config.getCoauthorEnabled().orElse(null), + config.getManageScheduleEnabled().orElse(null), config.getIncludedBuiltinSkills())) + .thenApply(v -> { + if (tokenRegistration != null) { + tokenRegistration.claim(session.getSessionId()); + } else { + gitHubTokenProviders.retire(session.getSessionId()); + } + LoggingHelpers.logTiming(LOG, Level.FINE, + "CopilotClient.createSession complete. Elapsed={Elapsed}, SessionId=" + + session.getSessionId(), + totalNanos); + return session; + }); }).exceptionally(ex -> { if (registeredIdHolder[0] != null) { sessions.remove(registeredIdHolder[0]); @@ -1060,9 +1057,26 @@ public CompletableFuture createSession(SessionConfig config) { }); } - private static void logMcpAuthInterestRegistration(Object interestResult) { + private static CompletableFuture registerMcpEventInterests(CopilotSession session, String sessionId, + boolean auth, boolean headersRefresh) { + CompletableFuture interests = CompletableFuture.completedFuture(null); + if (auth) { + interests = interests.thenCompose(v -> session.getRpc().eventLog + .registerInterest(new SessionEventLogRegisterInterestParams(sessionId, "mcp.oauth_required")) + .thenAccept(result -> logMcpInterestRegistration(result, "OAuth"))); + } + if (headersRefresh) { + interests = interests.thenCompose(v -> session.getRpc().eventLog + .registerInterest( + new SessionEventLogRegisterInterestParams(sessionId, "mcp.headers_refresh_required")) + .thenAccept(result -> logMcpInterestRegistration(result, "headers refresh"))); + } + return interests; + } + + private static void logMcpInterestRegistration(Object interestResult, String eventName) { if (interestResult != null && LOG.isLoggable(Level.FINEST)) { - LOG.finest("MCP OAuth event interest registered"); + LOG.finest("MCP " + eventName + " event interest registered"); } } @@ -1188,14 +1202,9 @@ public CompletableFuture resumeSession(String sessionId, ResumeS rpcNanos); String returnedId = response.sessionId(); String interestSessionId = returnedId != null ? returnedId : sessionId; - CompletableFuture interest = config.getOnMcpAuthRequest() != null - ? session.getRpc().eventLog.registerInterest(new SessionEventLogRegisterInterestParams( - interestSessionId, "mcp.oauth_required")) - : CompletableFuture.completedFuture(null); - return interest.thenApply(interestResult -> { - logMcpAuthInterestRegistration(interestResult); - return response; - }); + return registerMcpEventInterests(session, interestSessionId, + config.getOnMcpAuthRequest() != null, config.getOnMcpHeadersRefreshRequest() != null) + .thenApply(v -> response); }).thenCompose(response -> { session.setWorkspacePath(response.workspacePath()); session.setCapabilities(response.capabilities()); diff --git a/java/sdk/src/main/java/com/github/copilot/CopilotSession.java b/java/sdk/src/main/java/com/github/copilot/CopilotSession.java index f3a35967d3..83beae4298 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotSession.java @@ -14,6 +14,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; +import java.util.concurrent.CompletionException; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -34,6 +35,7 @@ import com.github.copilot.generated.rpc.SessionLogParams; import com.github.copilot.generated.rpc.SessionLogLevel; import com.github.copilot.generated.rpc.SessionMcpOauthHandlePendingRequestParams; +import com.github.copilot.generated.rpc.SessionMcpHeadersHandlePendingHeadersRefreshRequestParams; import com.github.copilot.generated.rpc.ModelCapabilitiesOverride; import com.github.copilot.generated.rpc.ModelCapabilitiesOverrideLimits; import com.github.copilot.generated.rpc.ModelCapabilitiesOverrideSupports; @@ -50,6 +52,7 @@ import com.github.copilot.generated.CommandExecuteEvent; import com.github.copilot.generated.ElicitationRequestedEvent; import com.github.copilot.generated.ExternalToolRequestedEvent; +import com.github.copilot.generated.McpHeadersRefreshRequiredEvent; import com.github.copilot.generated.McpOauthRequiredEvent; import com.github.copilot.generated.PermissionRequestedEvent; import com.github.copilot.generated.SessionCanvasClosedEvent; @@ -87,6 +90,10 @@ import com.github.copilot.rpc.McpAuthInvocation; import com.github.copilot.rpc.McpAuthRequest; import com.github.copilot.rpc.McpAuthResult; +import com.github.copilot.rpc.McpHeadersRefreshHandler; +import com.github.copilot.rpc.McpHeadersRefreshInvocation; +import com.github.copilot.rpc.McpHeadersRefreshRequest; +import com.github.copilot.rpc.McpHeadersRefreshResult; import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.PermissionInvocation; import com.github.copilot.rpc.PermissionRequest; @@ -189,6 +196,7 @@ public final class CopilotSession implements AutoCloseable { private final AtomicReference permissionHandler = new AtomicReference<>(); private volatile boolean managedSettingsEnabled; private final AtomicReference mcpAuthHandler = new AtomicReference<>(); + private final AtomicReference mcpHeadersRefreshHandler = new AtomicReference<>(); private final AtomicReference userInputHandler = new AtomicReference<>(); private final AtomicReference elicitationHandler = new AtomicReference<>(); private final AtomicReference exitPlanModeHandler = new AtomicReference<>(); @@ -885,6 +893,17 @@ private void handleBroadcastEventAsync(SessionEvent event) { executeMcpAuthAndRespondAsync(new McpAuthRequest(data.requestId(), data.serverName(), data.serverUrl(), data.reason(), data.wwwAuthenticateParams(), data.resourceMetadata(), data.staticClientConfig()), handler); + } else if (event instanceof McpHeadersRefreshRequiredEvent headersEvent) { + var data = headersEvent.getData(); + if (data == null || data.requestId() == null) { + return; + } + McpHeadersRefreshHandler handler = mcpHeadersRefreshHandler.get(); + if (handler == null) { + return; + } + executeMcpHeadersRefreshAndRespondAsync(data.requestId(), + new McpHeadersRefreshRequest(data.serverName(), data.serverUrl(), data.reason()), handler); } else if (event instanceof CommandExecuteEvent cmdEvent) { var data = cmdEvent.getData(); if (data == null || data.requestId() == null || data.commandName() == null) { @@ -1135,6 +1154,70 @@ private void sendMcpAuthResponse(String requestId, McpAuthResult result) { } } + private void executeMcpHeadersRefreshAndRespondAsync(String requestId, McpHeadersRefreshRequest request, + McpHeadersRefreshHandler handler) { + Runnable task = () -> { + try { + var invocation = new McpHeadersRefreshInvocation().setSessionId(sessionId); + handler.handle(request, invocation) + .thenAccept(result -> sendMcpHeadersRefreshResponse(requestId, result)).exceptionally(ex -> { + sendMcpHeadersRefreshError(requestId, ex); + return null; + }); + } catch (Exception e) { + sendMcpHeadersRefreshError(requestId, e); + } + }; + try { + if (executor != null) { + CompletableFuture.runAsync(task, executor); + } else { + CompletableFuture.runAsync(task); + } + } catch (RejectedExecutionException e) { + LOG.log(Level.WARNING, + "Executor rejected MCP headers refresh task for requestId=" + requestId + "; running inline", e); + task.run(); + } + } + + private void sendMcpHeadersRefreshResponse(String requestId, McpHeadersRefreshResult result) { + Map response; + if (result == null || result.headers() == null) { + response = Map.of("kind", "none"); + } else { + var headersResponse = new java.util.HashMap(); + headersResponse.put("kind", "headers"); + headersResponse.put("headers", result.headers()); + if (result.ttlMs() != null) { + headersResponse.put("ttlMs", result.ttlMs()); + } + response = headersResponse; + } + sendMcpHeadersRefreshResponse(requestId, response); + } + + private void sendMcpHeadersRefreshError(String requestId, Throwable error) { + sendMcpHeadersRefreshResponse(requestId, Map.of("kind", "error", "message", errorMessage(error))); + } + + private void sendMcpHeadersRefreshResponse(String requestId, Map response) { + try { + getRpc().mcp.headers.handlePendingHeadersRefreshRequest( + new SessionMcpHeadersHandlePendingHeadersRefreshRequestParams(sessionId, requestId, response)); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending MCP headers refresh response for requestId=" + requestId, e); + } + } + + private static String errorMessage(Throwable error) { + Throwable current = error; + while (current instanceof CompletionException && current.getCause() != null) { + current = current.getCause(); + } + return current.getMessage() != null ? current.getMessage() : current.toString(); + } + /** * Registers custom tool handlers for this session. *

@@ -1407,6 +1490,10 @@ void registerMcpAuthHandler(McpAuthHandler handler) { mcpAuthHandler.set(handler); } + void registerMcpHeadersRefreshHandler(McpHeadersRefreshHandler handler) { + mcpHeadersRefreshHandler.set(handler); + } + /** * Handles a permission request from the Copilot CLI. *

diff --git a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java index 4254c04ec4..697512c877 100644 --- a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -147,6 +147,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess } config.getIncludeSubAgentStreamingEvents().ifPresent(request::setIncludeSubAgentStreamingEvents); request.setMcpServers(config.getMcpServers()); + request.setManagedMcpServers(config.getManagedMcpServers()); request.setMcpOAuthTokenStorage(config.getMcpOAuthTokenStorage()); request.setCustomAgents(config.getCustomAgents()); request.setCustomAgentsLocalOnly( @@ -300,6 +301,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo } config.getIncludeSubAgentStreamingEvents().ifPresent(request::setIncludeSubAgentStreamingEvents); request.setMcpServers(config.getMcpServers()); + request.setManagedMcpServers(config.getManagedMcpServers()); request.setMcpOAuthTokenStorage(config.getMcpOAuthTokenStorage()); request.setCustomAgents(config.getCustomAgents()); request.setCustomAgentsLocalOnly( @@ -383,6 +385,9 @@ static void configureSession(CopilotSession session, SessionConfig config) { if (config.getOnMcpAuthRequest() != null) { session.registerMcpAuthHandler(config.getOnMcpAuthRequest()); } + if (config.getOnMcpHeadersRefreshRequest() != null) { + session.registerMcpHeadersRefreshHandler(config.getOnMcpHeadersRefreshRequest()); + } if (config.getOnUserInputRequest() != null) { session.registerUserInputHandler(config.getOnUserInputRequest()); } @@ -435,6 +440,9 @@ static void configureSession(CopilotSession session, ResumeSessionConfig config) if (config.getOnMcpAuthRequest() != null) { session.registerMcpAuthHandler(config.getOnMcpAuthRequest()); } + if (config.getOnMcpHeadersRefreshRequest() != null) { + session.registerMcpHeadersRefreshHandler(config.getOnMcpHeadersRefreshRequest()); + } if (config.getOnUserInputRequest() != null) { session.registerUserInputHandler(config.getOnUserInputRequest()); } diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 403893987d..3ab3da0616 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.github.copilot.CopilotExperimental; +import com.github.copilot.generated.rpc.ManagedMcpServerConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; /** @@ -113,6 +114,9 @@ public final class CreateSessionRequest { @JsonProperty("mcpServers") private Map mcpServers; + @JsonProperty("managedMcpServers") + private Map managedMcpServers; + @JsonProperty("mcpOAuthTokenStorage") private String mcpOAuthTokenStorage; @@ -575,6 +579,18 @@ public void setMcpServers(Map mcpServers) { this.mcpServers = mcpServers; } + /** Gets managed MCP servers. @return the managed servers map */ + public Map getManagedMcpServers() { + return managedMcpServers == null ? null : Collections.unmodifiableMap(managedMcpServers); + } + + /** + * Sets managed MCP servers. @param managedMcpServers the managed servers map + */ + public void setManagedMcpServers(Map managedMcpServers) { + this.managedMcpServers = managedMcpServers; + } + /** Gets MCP OAuth token storage mode. @return the storage mode */ public String getMcpOAuthTokenStorage() { return mcpOAuthTokenStorage; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshHandler.java b/java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshHandler.java new file mode 100644 index 0000000000..cf357b6bd0 --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshHandler.java @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.concurrent.CompletableFuture; + +/** + * Supplies dynamic headers for a host-managed MCP server. + * + * @since 1.0.0 + */ +@FunctionalInterface +public interface McpHeadersRefreshHandler { + /** + * Handles a dynamic-header refresh request. + * + * @param request + * the managed MCP server details + * @param invocation + * the invocation context with session information + * @return a future resolving to headers or an explicit no-result response + */ + CompletableFuture handle(McpHeadersRefreshRequest request, + McpHeadersRefreshInvocation invocation); +} diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshInvocation.java b/java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshInvocation.java new file mode 100644 index 0000000000..f69b78aee2 --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshInvocation.java @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +/** + * Context for a managed MCP dynamic-header refresh invocation. + * + * @since 1.0.0 + */ +public class McpHeadersRefreshInvocation { + + private String sessionId; + + /** + * Gets the session ID. + * + * @return the session ID + */ + public String getSessionId() { + return sessionId; + } + + /** + * Sets the session ID. + * + * @param sessionId + * the session ID + * @return this instance for method chaining + */ + public McpHeadersRefreshInvocation setSessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } +} diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshRequest.java new file mode 100644 index 0000000000..254a04376d --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshRequest.java @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.github.copilot.generated.McpHeadersRefreshRequiredReason; + +/** + * Request to refresh dynamic headers for a host-managed MCP server. + * + * @param serverName + * display name of the managed MCP server + * @param serverUrl + * URL of the managed MCP server + * @param reason + * reason the headers must be refreshed + * @since 1.0.0 + */ +public record McpHeadersRefreshRequest(String serverName, String serverUrl, McpHeadersRefreshRequiredReason reason) { +} diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshResult.java b/java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshResult.java new file mode 100644 index 0000000000..a86737b76e --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/rpc/McpHeadersRefreshResult.java @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.Map; + +/** + * Dynamic headers returned for a host-managed MCP server. + * + * @param headers + * headers to use, or {@code null} for no result + * @param ttlMs + * optional cache lifetime in milliseconds + * @since 1.0.0 + */ +public record McpHeadersRefreshResult(Map headers, Long ttlMs) { + + /** + * Creates a defensive copy of the supplied headers. + */ + public McpHeadersRefreshResult { + headers = headers == null ? null : Map.copyOf(headers); + } + + /** + * Creates a result with headers and no handler-specific cache lifetime. + * + * @param headers + * headers to use + * @return a headers result + */ + public static McpHeadersRefreshResult withHeaders(Map headers) { + return new McpHeadersRefreshResult(headers, null); + } + + /** + * Creates a result with headers and a cache lifetime. + * + * @param headers + * headers to use + * @param ttlMs + * cache lifetime in milliseconds + * @return a headers result + */ + public static McpHeadersRefreshResult withHeaders(Map headers, long ttlMs) { + return new McpHeadersRefreshResult(headers, ttlMs); + } + + /** + * Creates an explicit no-result response. + * + * @return a no-result response + */ + public static McpHeadersRefreshResult none() { + return new McpHeadersRefreshResult(null, null); + } +} diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index a55c3454e7..f8a8ccef8f 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -16,6 +16,7 @@ import com.github.copilot.CopilotExperimental; import com.github.copilot.generated.SessionEvent; +import com.github.copilot.generated.rpc.ManagedMcpServerConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; /** @@ -66,6 +67,7 @@ public class ResumeSessionConfig { private ModelCapabilitiesOverride modelCapabilities; private PermissionHandler onPermissionRequest; private McpAuthHandler onMcpAuthRequest; + private McpHeadersRefreshHandler onMcpHeadersRefreshRequest; private UserInputHandler onUserInputRequest; private SessionHooks hooks; private String workingDirectory; @@ -84,6 +86,7 @@ public class ResumeSessionConfig { private boolean streaming; private Boolean includeSubAgentStreamingEvents; private Map mcpServers; + private Map managedMcpServers; private String mcpOAuthTokenStorage; private List customAgents; private DefaultAgentConfig defaultAgent; @@ -837,6 +840,28 @@ public ResumeSessionConfig setOnMcpAuthRequest(McpAuthHandler onMcpAuthRequest) return this; } + /** + * Gets the managed MCP dynamic-header refresh handler. + * + * @return the handler, or {@code null} if not set + */ + @JsonIgnore + public McpHeadersRefreshHandler getOnMcpHeadersRefreshRequest() { + return onMcpHeadersRefreshRequest; + } + + /** + * Sets the managed MCP dynamic-header refresh handler. + * + * @param onMcpHeadersRefreshRequest + * the handler + * @return this config instance for method chaining + */ + public ResumeSessionConfig setOnMcpHeadersRefreshRequest(McpHeadersRefreshHandler onMcpHeadersRefreshRequest) { + this.onMcpHeadersRefreshRequest = onMcpHeadersRefreshRequest; + return this; + } + /** * Gets the user input request handler. * @@ -1369,6 +1394,27 @@ public ResumeSessionConfig setMcpServers(Map mcpServers return this; } + /** + * Gets host-managed HTTP MCP server configurations. + * + * @return the managed MCP servers map + */ + public Map getManagedMcpServers() { + return managedMcpServers == null ? null : Collections.unmodifiableMap(managedMcpServers); + } + + /** + * Sets host-managed HTTP MCP server configurations for a cold resume. + * + * @param managedMcpServers + * non-secret server configurations keyed by stable managed identity + * @return this config instance for method chaining + */ + public ResumeSessionConfig setManagedMcpServers(Map managedMcpServers) { + this.managedMcpServers = managedMcpServers; + return this; + } + /** * Gets the MCP OAuth token storage mode. * @@ -2074,6 +2120,9 @@ public ResumeSessionConfig clone() { copy.streaming = this.streaming; copy.includeSubAgentStreamingEvents = this.includeSubAgentStreamingEvents; copy.mcpServers = this.mcpServers != null ? new java.util.HashMap<>(this.mcpServers) : null; + copy.managedMcpServers = this.managedMcpServers != null + ? new java.util.HashMap<>(this.managedMcpServers) + : null; copy.customAgents = this.customAgents != null ? new ArrayList<>(this.customAgents) : null; copy.defaultAgent = this.defaultAgent; copy.agent = this.agent; @@ -2095,6 +2144,7 @@ public ResumeSessionConfig clone() { copy.commands = this.commands != null ? new ArrayList<>(this.commands) : null; copy.onElicitationRequest = this.onElicitationRequest; copy.onMcpAuthRequest = this.onMcpAuthRequest; + copy.onMcpHeadersRefreshRequest = this.onMcpHeadersRefreshRequest; copy.onExitPlanMode = this.onExitPlanMode; copy.onAutoModeSwitch = this.onAutoModeSwitch; copy.enableMcpApps = this.enableMcpApps; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 9b8e897fda..fbb431223f 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.github.copilot.CopilotExperimental; +import com.github.copilot.generated.rpc.ManagedMcpServerConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; /** @@ -156,6 +157,9 @@ public final class ResumeSessionRequest { @JsonProperty("mcpServers") private Map mcpServers; + @JsonProperty("managedMcpServers") + private Map managedMcpServers; + @JsonProperty("mcpOAuthTokenStorage") private String mcpOAuthTokenStorage; @@ -801,6 +805,18 @@ public void setMcpServers(Map mcpServers) { this.mcpServers = mcpServers; } + /** Gets managed MCP servers. @return the managed servers map */ + public Map getManagedMcpServers() { + return managedMcpServers == null ? null : Collections.unmodifiableMap(managedMcpServers); + } + + /** + * Sets managed MCP servers. @param managedMcpServers the managed servers map + */ + public void setManagedMcpServers(Map managedMcpServers) { + this.managedMcpServers = managedMcpServers; + } + /** Gets MCP OAuth token storage mode. @return the storage mode */ public String getMcpOAuthTokenStorage() { return mcpOAuthTokenStorage; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java index 9f6ddb5efa..bcb5f3ed94 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -16,6 +16,7 @@ import com.github.copilot.CopilotExperimental; import com.github.copilot.generated.SessionEvent; +import com.github.copilot.generated.rpc.ManagedMcpServerConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; /** @@ -66,6 +67,7 @@ public class SessionConfig { private Boolean manageScheduleEnabled; private PermissionHandler onPermissionRequest; private McpAuthHandler onMcpAuthRequest; + private McpHeadersRefreshHandler onMcpHeadersRefreshRequest; private UserInputHandler onUserInputRequest; private SessionHooks hooks; private String workingDirectory; @@ -73,6 +75,7 @@ public class SessionConfig { private boolean streaming; private Boolean includeSubAgentStreamingEvents; private Map mcpServers; + private Map managedMcpServers; private String mcpOAuthTokenStorage; private List customAgents; private DefaultAgentConfig defaultAgent; @@ -882,6 +885,28 @@ public SessionConfig setOnMcpAuthRequest(McpAuthHandler onMcpAuthRequest) { return this; } + /** + * Gets the managed MCP dynamic-header refresh handler. + * + * @return the handler, or {@code null} if not set + */ + @JsonIgnore + public McpHeadersRefreshHandler getOnMcpHeadersRefreshRequest() { + return onMcpHeadersRefreshRequest; + } + + /** + * Sets the managed MCP dynamic-header refresh handler. + * + * @param onMcpHeadersRefreshRequest + * the handler + * @return this config instance for method chaining + */ + public SessionConfig setOnMcpHeadersRefreshRequest(McpHeadersRefreshHandler onMcpHeadersRefreshRequest) { + this.onMcpHeadersRefreshRequest = onMcpHeadersRefreshRequest; + return this; + } + /** * Gets the user input request handler. * @@ -1021,6 +1046,27 @@ public SessionConfig setMcpServers(Map mcpServers) { return this; } + /** + * Gets host-managed HTTP MCP server configurations. + * + * @return the managed MCP servers map + */ + public Map getManagedMcpServers() { + return managedMcpServers == null ? null : Collections.unmodifiableMap(managedMcpServers); + } + + /** + * Sets host-managed HTTP MCP server configurations. + * + * @param managedMcpServers + * non-secret server configurations keyed by stable managed identity + * @return this config instance for method chaining + */ + public SessionConfig setManagedMcpServers(Map managedMcpServers) { + this.managedMcpServers = managedMcpServers; + return this; + } + /** * Gets the MCP OAuth token storage mode. * @@ -2203,6 +2249,9 @@ public SessionConfig clone() { copy.streaming = this.streaming; copy.includeSubAgentStreamingEvents = this.includeSubAgentStreamingEvents; copy.mcpServers = this.mcpServers != null ? new java.util.HashMap<>(this.mcpServers) : null; + copy.managedMcpServers = this.managedMcpServers != null + ? new java.util.HashMap<>(this.managedMcpServers) + : null; copy.customAgents = this.customAgents != null ? new ArrayList<>(this.customAgents) : null; copy.defaultAgent = this.defaultAgent; copy.agent = this.agent; @@ -2235,6 +2284,7 @@ public SessionConfig clone() { copy.commands = this.commands != null ? new ArrayList<>(this.commands) : null; copy.onElicitationRequest = this.onElicitationRequest; copy.onMcpAuthRequest = this.onMcpAuthRequest; + copy.onMcpHeadersRefreshRequest = this.onMcpHeadersRefreshRequest; copy.onExitPlanMode = this.onExitPlanMode; copy.onAutoModeSwitch = this.onAutoModeSwitch; copy.enableMcpApps = this.enableMcpApps; diff --git a/java/sdk/src/test/java/com/github/copilot/McpAuthInterestRegistrationTest.java b/java/sdk/src/test/java/com/github/copilot/McpAuthInterestRegistrationTest.java index 06ac08a2a4..192962c046 100644 --- a/java/sdk/src/test/java/com/github/copilot/McpAuthInterestRegistrationTest.java +++ b/java/sdk/src/test/java/com/github/copilot/McpAuthInterestRegistrationTest.java @@ -23,6 +23,7 @@ import com.github.copilot.rpc.CloudSessionRepository; import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.McpAuthResult; +import com.github.copilot.rpc.McpHeadersRefreshResult; import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.ResumeSessionConfig; import com.github.copilot.rpc.SessionConfig; @@ -176,6 +177,44 @@ void resumeSessionRegistersMcpAuthInterestOnlyWhenHandlerConfigured() throws Exc } } + @Test + void createAndResumeRegisterManagedMcpHeadersRefreshInterest() throws Exception { + try (var server = new RecordingRuntime(); + var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url()))) { + try (var session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnMcpHeadersRefreshRequest((request, invocation) -> java.util.concurrent.CompletableFuture + .completedFuture(McpHeadersRefreshResult.none()))) + .get()) { + assertNotNull(session); + } + + List createRequests = server.requests().stream() + .filter(request -> "session.create".equals(request.method()) + || "session.eventLog.registerInterest".equals(request.method())) + .toList(); + assertEquals("session.create", createRequests.get(0).method()); + assertEquals("session.eventLog.registerInterest", createRequests.get(1).method()); + assertEquals("mcp.headers_refresh_required", createRequests.get(1).params().path("eventType").asText()); + + server.clearRequests(); + + try (var session = client.resumeSession("managed-session", + new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnMcpHeadersRefreshRequest( + (request, invocation) -> java.util.concurrent.CompletableFuture + .completedFuture(McpHeadersRefreshResult.none()))) + .get()) { + assertNotNull(session); + } + + List resumeRequests = server.requests(); + assertEquals("session.resume", resumeRequests.get(0).method()); + assertEquals("session.eventLog.registerInterest", resumeRequests.get(1).method()); + assertEquals("mcp.headers_refresh_required", resumeRequests.get(1).params().path("eventType").asText()); + } + } + private static void assertNoMcpAuthInterest(List requests) { assertFalse(requests.stream().anyMatch(request -> "session.eventLog.registerInterest".equals(request.method()) && "mcp.oauth_required".equals(request.params().path("eventType").asText()))); diff --git a/java/sdk/src/test/java/com/github/copilot/McpHeadersRefreshHandlerTest.java b/java/sdk/src/test/java/com/github/copilot/McpHeadersRefreshHandlerTest.java new file mode 100644 index 0000000000..d9a97a550f --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/McpHeadersRefreshHandlerTest.java @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.copilot.generated.McpHeadersRefreshRequiredEvent; +import com.github.copilot.generated.McpHeadersRefreshRequiredReason; +import com.github.copilot.generated.rpc.SessionMcpHeadersHandlePendingHeadersRefreshRequestResult; +import com.github.copilot.rpc.McpHeadersRefreshHandler; +import com.github.copilot.rpc.McpHeadersRefreshResult; +import com.github.copilot.rpc.ResumeSessionConfig; +import com.github.copilot.rpc.SessionConfig; + +class McpHeadersRefreshHandlerTest { + + private static final String METHOD = "session.mcp.headers.handlePendingHeadersRefreshRequest"; + + @Test + void createHandlerDispatchesHeadersWithoutTtl() { + McpHeadersRefreshHandler handler = (request, invocation) -> { + assertEquals("managed-server", request.serverName()); + assertEquals("https://mcp.example.com", request.serverUrl()); + assertEquals(McpHeadersRefreshRequiredReason.STARTUP, request.reason()); + assertEquals("session-1", invocation.getSessionId()); + return CompletableFuture + .completedFuture(McpHeadersRefreshResult.withHeaders(Map.of("Authorization", "Bearer dynamic"))); + }; + + JsonNode params = dispatch(new SessionConfig().setOnMcpHeadersRefreshRequest(handler)); + + assertEquals("headers", params.path("result").path("kind").asText()); + assertEquals("Bearer dynamic", params.path("result").path("headers").path("Authorization").asText()); + assertFalse(params.path("result").has("ttlMs")); + } + + @Test + void resumeHandlerDispatchesHeadersWithTtl() { + McpHeadersRefreshHandler handler = (request, invocation) -> CompletableFuture + .completedFuture(McpHeadersRefreshResult.withHeaders(Map.of("X-Dynamic", "value"), 60_000L)); + + JsonNode params = dispatch(new ResumeSessionConfig().setOnMcpHeadersRefreshRequest(handler)); + + assertEquals("headers", params.path("result").path("kind").asText()); + assertEquals("value", params.path("result").path("headers").path("X-Dynamic").asText()); + assertEquals(60_000L, params.path("result").path("ttlMs").asLong()); + } + + @Test + void handlerCanReturnExplicitNone() { + McpHeadersRefreshHandler handler = (request, invocation) -> CompletableFuture + .completedFuture(McpHeadersRefreshResult.none()); + + JsonNode params = dispatch(new SessionConfig().setOnMcpHeadersRefreshRequest(handler)); + + assertEquals("none", params.path("result").path("kind").asText()); + assertEquals(1, params.path("result").size()); + } + + @Test + void handlerFailureReturnsExplicitErrorMessage() { + McpHeadersRefreshHandler handler = (request, invocation) -> CompletableFuture + .failedFuture(new IllegalStateException("header provider unavailable")); + + JsonNode params = dispatch(new SessionConfig().setOnMcpHeadersRefreshRequest(handler)); + + assertEquals("error", params.path("result").path("kind").asText()); + assertEquals("header provider unavailable", params.path("result").path("message").asText()); + } + + @Test + void handlerExceptionReturnsExplicitErrorMessage() { + McpHeadersRefreshHandler handler = (request, invocation) -> { + throw new IllegalArgumentException("invalid header configuration"); + }; + + JsonNode params = dispatch(new SessionConfig().setOnMcpHeadersRefreshRequest(handler)); + + assertEquals("error", params.path("result").path("kind").asText()); + assertEquals("invalid header configuration", params.path("result").path("message").asText()); + } + + private JsonNode dispatch(SessionConfig config) { + var rpc = rpc(); + var session = new CopilotSession("session-1", rpc); + session.setExecutor(Runnable::run); + SessionRequestBuilder.configureSession(session, config); + return dispatchAndCapture(session, rpc); + } + + private JsonNode dispatch(ResumeSessionConfig config) { + var rpc = rpc(); + var session = new CopilotSession("session-1", rpc); + session.setExecutor(Runnable::run); + SessionRequestBuilder.configureSession(session, config); + return dispatchAndCapture(session, rpc); + } + + private JsonRpcClient rpc() { + var rpc = mock(JsonRpcClient.class); + when(rpc.invoke(eq(METHOD), any(), eq(SessionMcpHeadersHandlePendingHeadersRefreshRequestResult.class))) + .thenReturn(CompletableFuture + .completedFuture(new SessionMcpHeadersHandlePendingHeadersRefreshRequestResult(true))); + return rpc; + } + + private JsonNode dispatchAndCapture(CopilotSession session, JsonRpcClient rpc) { + var event = new McpHeadersRefreshRequiredEvent(); + event.setData(new McpHeadersRefreshRequiredEvent.McpHeadersRefreshRequiredEventData("headers-request", + "managed-server", "https://mcp.example.com", McpHeadersRefreshRequiredReason.STARTUP)); + + session.dispatchEvent(event); + + var paramsCaptor = ArgumentCaptor.forClass(Object.class); + verify(rpc).invoke(eq(METHOD), paramsCaptor.capture(), + eq(SessionMcpHeadersHandlePendingHeadersRefreshRequestResult.class)); + JsonNode params = JsonRpcClient.getObjectMapper().valueToTree(paramsCaptor.getValue()); + assertEquals("session-1", params.path("sessionId").asText()); + assertEquals("headers-request", params.path("requestId").asText()); + assertNull(params.get("unexpected")); + return params; + } +} diff --git a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 9d76d18ee2..0f0460c595 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; +import com.github.copilot.generated.rpc.ManagedMcpServerConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; import com.github.copilot.rpc.AutoModeSwitchResponse; import com.github.copilot.rpc.CloudSessionOptions; @@ -1096,4 +1097,29 @@ void githubMcpToolConfigIsMappedAndSerializedForCreateAndResume() throws Excepti mapper.writeValueAsString(SessionRequestBuilder.buildCreateRequest(new SessionConfig(), "session-2")) .contains("\"githubMcpToolConfig\"")); } + + @Test + void managedMcpServersAreMappedAndSerializedForCreateAndColdResume() throws Exception { + var managedServer = new ManagedMcpServerConfig("Managed catalog server", "https://mcp.example.com", + List.of("search"), 15_000L, 300_000L); + var managedServers = Map.of("catalog-server", managedServer); + + var createRequest = SessionRequestBuilder + .buildCreateRequest(new SessionConfig().setManagedMcpServers(managedServers), "session-1"); + var resumeRequest = SessionRequestBuilder.buildResumeRequest("session-1", + new ResumeSessionConfig().setManagedMcpServers(managedServers)); + + assertEquals(managedServers, createRequest.getManagedMcpServers()); + assertEquals(managedServers, resumeRequest.getManagedMcpServers()); + + var mapper = JsonRpcClient.getObjectMapper(); + var createJson = mapper.readTree(mapper.writeValueAsBytes(createRequest)); + var resumeJson = mapper.readTree(mapper.writeValueAsBytes(resumeRequest)); + assertEquals("Managed catalog server", + createJson.path("managedMcpServers").path("catalog-server").path("displayName").asText()); + assertEquals(300_000L, + createJson.path("managedMcpServers").path("catalog-server").path("headersRefreshTtlMs").asLong()); + assertEquals("https://mcp.example.com", + resumeJson.path("managedMcpServers").path("catalog-server").path("url").asText()); + } } diff --git a/java/sdk/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java b/java/sdk/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java index 602089d012..034b5be792 100644 --- a/java/sdk/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java +++ b/java/sdk/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java @@ -637,7 +637,7 @@ void sessionLogResult_record() { @Test void sessionMcpListResult_nested() { - var server = new McpServer("my-mcp", McpServerStatus.CONNECTED, McpServerSource.USER, null, null, null); + var server = new McpServer("my-mcp", McpServerStatus.CONNECTED, McpServerSource.USER, null, null, null, null); var result = new SessionMcpListResult(List.of(server), null); assertEquals(1, result.servers().size()); assertEquals("my-mcp", result.servers().get(0).name()); diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9b853aa597..679cfd1153 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1565,6 +1565,7 @@ export class CopilotClient { this.onGetTraceContext, { mcpAuthHandler: config.onMcpAuthRequest, + mcpHeadersRefreshHandler: config.onMcpHeadersRefresh, managedSettingsEnabled: config.enableManagedSettings === true || config.managedSettings !== undefined, @@ -1693,6 +1694,7 @@ export class CopilotClient { ? { enableGitHubTelemetryForwarding: true } : {}), mcpServers: toWireMcpServers(config.mcpServers), + managedMcpServers: config.managedMcpServers, mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", customAgents: toWireCustomAgents(config.customAgents), @@ -1755,6 +1757,12 @@ export class CopilotClient { eventType: "mcp.oauth_required", }); } + if (config.onMcpHeadersRefresh) { + await this.connection!.sendRequest("session.eventLog.registerInterest", { + sessionId: returnedSessionId, + eventType: "mcp.headers_refresh_required", + }); + } session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); @@ -1833,6 +1841,7 @@ export class CopilotClient { this.onGetTraceContext, { mcpAuthHandler: config.onMcpAuthRequest, + mcpHeadersRefreshHandler: config.onMcpHeadersRefresh, managedSettingsEnabled: config.enableManagedSettings === true || config.managedSettings !== undefined, } @@ -1971,6 +1980,7 @@ export class CopilotClient { ? { enableGitHubTelemetryForwarding: true } : {}), mcpServers: toWireMcpServers(config.mcpServers), + managedMcpServers: config.managedMcpServers, mcpOAuthTokenStorage: config.mcpOAuthTokenStorage, envValueMode: "direct", customAgents: toWireCustomAgents(config.customAgents), @@ -2033,6 +2043,12 @@ export class CopilotClient { eventType: "mcp.oauth_required", }); } + if (config.onMcpHeadersRefresh) { + await this.connection!.sendRequest("session.eventLog.registerInterest", { + sessionId, + eventType: "mcp.headers_refresh_required", + }); + } await this.updateSessionOptionsForMode(session, config); this.commitGitHubTokenProvider(sessionId, gitHubTokenProviderRegistrationId); diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 9e7304bde2..8ced8be652 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -1694,6 +1694,10 @@ export type McpHeadersHandlePendingHeadersRefreshRequest = headers: { [k: string]: string | undefined; }; + /** + * Optional lifetime in milliseconds for these returned headers. The runtime clamps its configured cache lifetime to this value. + */ + ttlMs?: number; /** * Headers-refresh response variant discriminator. */ @@ -1704,6 +1708,16 @@ export type McpHeadersHandlePendingHeadersRefreshRequest = * Headers-refresh response variant discriminator. */ kind: "none"; + } + | { + /** + * Host credential broker failure, denial, or revocation reason. + */ + message: string; + /** + * Headers-refresh response variant discriminator. + */ + kind: "error"; }; /** * One eligible way to run the server, represented as a tagged package or remote variant so package identity and endpoint states cannot contradict the install method. @@ -11564,6 +11578,10 @@ export interface McpServer { * Error message if the server failed to connect */ error?: string; + /** + * Human-readable display name supplied by a managed server catalog. + */ + displayName?: string; } /** * In-process MCP server configuration used by embedded SDK clients. @@ -18293,6 +18311,14 @@ export interface SessionOpenOptions { * Capabilities enabled for this session. */ sessionCapabilities?: SessionCapability[]; + /** + * Non-secret host-managed HTTP MCP servers keyed by stable managed identity. Managed provenance is runtime-established from this separate field and credentials are supplied through dynamic-header refresh. + * + * @experimental + */ + managedMcpServers?: { + [k: string]: ManagedMcpServerConfig | undefined; + }; } /** * Per-session settings for built-in shell tools. @@ -18439,6 +18465,35 @@ export interface SessionOpenOptionsAdditionalContentExclusionPolicyRuleSource { */ type: string; } +/** + * Non-secret host-managed HTTP MCP server configuration. The containing map key is the stable managed identity; credentials are supplied dynamically by the host. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ManagedMcpServerConfig". + */ +/** @experimental */ +export interface ManagedMcpServerConfig { + /** + * Human-readable catalog display name. + */ + displayName: string; + /** + * Hosted MCP streamable HTTP endpoint. + */ + url: string; + /** + * Tools to include. Defaults to all tools when omitted. + */ + tools?: string[]; + /** + * Timeout in milliseconds for tool discovery and tool calls. + */ + timeout?: number; + /** + * Maximum dynamic-header cache lifetime in milliseconds. + */ + headersRefreshTtlMs?: number; +} /** * Parameters for creating a new local session. * diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 473a2dbe16..c2d51f1249 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -914,7 +914,7 @@ export type SkillSource = /** Skill bundled with the runtime. */ | "builtin"; /** - * Configuration source: user, workspace, plugin, or builtin + * Configuration source: user, workspace, plugin, builtin, or managed */ export type McpServerSource = /** Server configured in the user's global MCP configuration. */ @@ -924,7 +924,9 @@ export type McpServerSource = /** Server contributed by an installed plugin. */ | "plugin" /** Server bundled with the runtime. */ - | "builtin"; + | "builtin" + /** Server supplied by a trusted host-managed catalog. */ + | "managed"; /** * Connection status: connected, failed, needs-auth, pending, disabled, stopped, or not_configured */ @@ -9935,6 +9937,10 @@ export interface McpServersLoadedData { * A single MCP server status summary in `session.mcp_servers_loaded`, including name, status, source, transport, and plugin metadata. */ export interface McpServersLoadedServer { + /** + * Human-readable display name supplied by a managed server catalog. + */ + displayName?: string; /** * Error message if the server failed to connect */ diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 9d55ab1d10..054017332b 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -111,6 +111,10 @@ export type { MCPStdioServerConfig, MCPHTTPServerConfig, MCPServerConfig, + ManagedMcpServerConfig, + McpHeadersRefreshHandler, + McpHeadersRefreshRequest, + McpHeadersRefreshResult, DefaultAgentConfig, BearerTokenProvider, MessageOptions, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 65ff00921c..f1efc51a10 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -15,6 +15,7 @@ import type { ClientSessionApiHandlers, CanvasActionInvokeResult, CurrentToolMetadata, + McpHeadersHandlePendingHeadersRefreshRequest, McpOauthPendingRequestResponse, FactoryLogLine, FactoryRunResult as WireFactoryRunResult, @@ -40,6 +41,9 @@ import type { MessageOptions, McpAuthHandler, McpAuthRequest, + McpHeadersRefreshHandler, + McpHeadersRefreshRequest, + McpHeadersRefreshResult, PermissionHandler, PermissionRequest, PermissionRequestResult, @@ -157,6 +161,17 @@ function isOpenCanvasInstance(value: unknown): value is OpenCanvasInstance { ); } +function isMcpHeadersRefreshResult( + value: McpHeadersRefreshResult | Record +): value is McpHeadersRefreshResult { + const headers = (value as { headers?: unknown }).headers; + return ( + typeof headers === "object" && + headers !== null && + Object.values(headers).every((header) => typeof header === "string") + ); +} + const FACTORY_LOG_FLUSH_DELAY_MS = 10; const MAX_FACTORY_FANOUT_ITEMS = 4096; @@ -426,6 +441,7 @@ export class CopilotSession { private factoryAbortControllers = new Map>(); private permissionHandler?: PermissionHandler; private mcpAuthHandler?: McpAuthHandler; + private mcpHeadersRefreshHandler?: McpHeadersRefreshHandler; private userInputHandler?: UserInputHandler; private elicitationHandler?: ElicitationHandler; private exitPlanModeHandler?: ExitPlanModeHandler; @@ -620,12 +636,14 @@ export class CopilotSession { traceContextProvider?: TraceContextProvider, options?: { mcpAuthHandler?: McpAuthHandler; + mcpHeadersRefreshHandler?: McpHeadersRefreshHandler; managedSettingsEnabled?: boolean; onDisconnected?: () => void; } ) { this.traceContextProvider = traceContextProvider; this.mcpAuthHandler = options?.mcpAuthHandler; + this.mcpHeadersRefreshHandler = options?.mcpHeadersRefreshHandler; this.managedSettingsEnabled = options?.managedSettingsEnabled === true; this.onDisconnected = options?.onDisconnected; } @@ -1009,6 +1027,16 @@ export class CopilotSession { return; } void this._executeMcpAuthAndRespond(data); + } else if (event.type === "mcp.headers_refresh_required") { + const { requestId, serverName, serverUrl, reason } = event.data; + if (!requestId || !this.mcpHeadersRefreshHandler) { + return; + } + void this._executeMcpHeadersRefreshAndRespond(requestId, { + serverName, + serverUrl, + reason, + }); } else if (event.type === "command.execute") { const { requestId, commandName, command, args } = event.data as { requestId: string; @@ -1230,6 +1258,48 @@ export class CopilotSession { } } + /** + * Executes a managed MCP headers refresh handler and sends the result via RPC. + * @internal + */ + private async _executeMcpHeadersRefreshAndRespond( + requestId: string, + request: McpHeadersRefreshRequest + ): Promise { + let result: McpHeadersHandlePendingHeadersRefreshRequest; + try { + const response = await this.mcpHeadersRefreshHandler!(request, { + sessionId: this.sessionId, + }); + if (response === undefined) { + result = { kind: "none" }; + } else if (isMcpHeadersRefreshResult(response)) { + result = { + kind: "headers", + headers: response.headers, + ...(response.ttlMs === undefined ? {} : { ttlMs: response.ttlMs }), + }; + } else { + result = { kind: "headers", headers: response }; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`MCP headers refresh failed for '${request.serverName}': ${message}`); + result = { kind: "error", message }; + } + + try { + await this.rpc.mcp.headers.handlePendingHeadersRefreshRequest({ + requestId, + result, + }); + } catch (rpcError) { + if (!(rpcError instanceof ConnectionError || rpcError instanceof ResponseError)) { + throw rpcError; + } + } + } + /** * Executes a command handler and sends the result back via RPC. * @internal diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 616e15a467..5e13274b7a 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -14,6 +14,7 @@ import type { PermissionRequest as GeneratedPermissionRequest, PermissionRequestedData as GeneratedPermissionRequestedData, PermissionRequestedEvent as GeneratedPermissionRequestedEvent, + McpHeadersRefreshRequiredReason, ReasoningSummary, SessionLimitsConfig, SessionEvent as GeneratedSessionEvent, @@ -24,6 +25,7 @@ import type { GitHubTokenAcquireRequest, GitHubTokenAcquireResult, GitHubTelemetryNotification, + ManagedMcpServerConfig, ModelBillingTokenPrices, OpenCanvasInstance, RemoteSessionMode, @@ -32,6 +34,7 @@ import type { import type { ToolSet } from "./toolSet.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; export type { CurrentToolMetadata } from "./generated/rpc.js"; +export type { ManagedMcpServerConfig } from "./generated/rpc.js"; export type { GitHubTokenAcquireReason, GitHubTokenAcquireResult, @@ -2031,6 +2034,38 @@ export type McpAuthHandler = ( | undefined | Promise; +/** MCP server whose short-lived HTTP headers must be refreshed by the host. */ +export interface McpHeadersRefreshRequest { + /** Human-readable managed catalog display name. */ + serverName: string; + /** Hosted MCP streamable HTTP endpoint. */ + serverUrl: string; + /** Why the runtime invalidated or requested dynamic headers. */ + reason: McpHeadersRefreshRequiredReason; +} + +/** Dynamic headers and their optional credential-bounded cache lifetime. */ +export interface McpHeadersRefreshResult { + headers: Record; + ttlMs?: number; +} + +/** + * Callback invoked when a managed MCP server needs fresh HTTP headers. + * + * Return a bare header map or a structured result with `ttlMs`. Returning + * `undefined` reports that no headers are available. Throwing reports an + * explicit credential-broker error to the runtime. + */ +export type McpHeadersRefreshHandler = ( + request: McpHeadersRefreshRequest, + context: { sessionId: string } +) => + | McpHeadersRefreshResult + | Record + | undefined + | Promise | undefined>; + /** * Stable extension identity for session participants that provide canvases. */ @@ -2554,6 +2589,12 @@ export interface SessionConfigBase { */ onMcpAuthRequest?: McpAuthHandler; + /** + * Supplies short-lived HTTP headers for managed MCP servers. + * Re-register this handler when cold-resuming a session. + */ + onMcpHeadersRefresh?: McpHeadersRefreshHandler; + /** * Handler for user input requests from the agent. * When provided, enables the ask_user tool allowing the agent to ask questions. @@ -2669,6 +2710,15 @@ export interface SessionConfigBase { */ mcpServers?: Record; + /** + * Non-secret hosted MCP servers injected from a trusted managed catalog. + * Keys are stable managed identities. Credentials must be supplied through + * {@link onMcpHeadersRefresh}, never stored in this configuration. + * + * @experimental + */ + managedMcpServers?: Record; + /** * Custom agent configurations for the session. */ diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 3ffda2fa71..a38804da1c 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -272,6 +272,162 @@ describe("CopilotClient", () => { }); }); + it.each([ + { + name: "bare headers without ttlMs", + response: { Authorization: "Bearer short-lived" }, + expected: { + kind: "headers", + headers: { Authorization: "Bearer short-lived" }, + }, + }, + { + name: "structured headers with ttlMs", + response: { + headers: { Authorization: "Bearer short-lived" }, + ttlMs: 5_000, + }, + expected: { + kind: "headers", + headers: { Authorization: "Bearer short-lived" }, + ttlMs: 5_000, + }, + }, + ])("responds to managed MCP header refresh with $name", async ({ response, expected }) => { + const sendRequest = vi.fn(async () => ({ success: true })); + const session = new CopilotSession( + "session-1", + { sendRequest } as any, + undefined, + undefined, + { mcpHeadersRefreshHandler: async () => response } + ); + + await (session as any)._executeMcpHeadersRefreshAndRespond("refresh-request", { + serverName: "Managed GitHub", + serverUrl: "https://example.com/mcp", + reason: "startup", + }); + + expect(sendRequest).toHaveBeenCalledWith( + "session.mcp.headers.handlePendingHeadersRefreshRequest", + { + sessionId: "session-1", + requestId: "refresh-request", + result: expected, + } + ); + }); + + it("reports no managed MCP headers when the handler returns undefined", async () => { + const sendRequest = vi.fn(async () => ({ success: true })); + const session = new CopilotSession( + "session-1", + { sendRequest } as any, + undefined, + undefined, + { mcpHeadersRefreshHandler: () => undefined } + ); + + await (session as any)._executeMcpHeadersRefreshAndRespond("refresh-request", { + serverName: "Managed GitHub", + serverUrl: "https://example.com/mcp", + reason: "ttl-expired", + }); + + expect(sendRequest).toHaveBeenCalledWith( + "session.mcp.headers.handlePendingHeadersRefreshRequest", + { + sessionId: "session-1", + requestId: "refresh-request", + result: { kind: "none" }, + } + ); + }); + + it("propagates managed MCP credential broker failures explicitly", async () => { + const sendRequest = vi.fn(async () => ({ success: true })); + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + const session = new CopilotSession( + "session-1", + { sendRequest } as any, + undefined, + undefined, + { + mcpHeadersRefreshHandler: () => { + throw new Error("credential revoked"); + }, + } + ); + + await (session as any)._executeMcpHeadersRefreshAndRespond("refresh-request", { + serverName: "Managed GitHub", + serverUrl: "https://example.com/mcp", + reason: "auth-failed", + }); + + expect(sendRequest).toHaveBeenCalledWith( + "session.mcp.headers.handlePendingHeadersRefreshRequest", + { + sessionId: "session-1", + requestId: "refresh-request", + result: { kind: "error", message: "credential revoked" }, + } + ); + warning.mockRestore(); + }); + + it("forwards managed MCP servers and refresh interest on create and resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create" || method === "session.resume") { + return { sessionId: params.sessionId }; + } + if (method === "session.eventLog.registerInterest") { + return { id: "interest-1" }; + } + throw new Error(`Unexpected method: ${method}`); + }); + const managedMcpServers = { + github: { + displayName: "GitHub", + url: "https://api.example.com/mcp", + tools: ["issues"], + timeout: 30_000, + headersRefreshTtlMs: 60_000, + }, + }; + const onMcpHeadersRefresh = () => ({ Authorization: "Bearer short-lived" }); + + const session = await client.createSession({ + managedMcpServers, + onMcpHeadersRefresh, + }); + await client.resumeSession(session.sessionId, { + managedMcpServers, + onMcpHeadersRefresh, + }); + + for (const method of ["session.create", "session.resume"]) { + expect(spy.mock.calls.find(([called]) => called === method)![1]).toMatchObject({ + managedMcpServers, + }); + } + expect(spy).toHaveBeenCalledTimes(4); + expect( + spy.mock.calls.filter( + ([method, params]) => + method === "session.eventLog.registerInterest" && + params.eventType === "mcp.headers_refresh_required" + ) + ).toHaveLength(2); + }); + it("forwards GitHub MCP tool config on create and resume", async () => { const client = new CopilotClient(); await client.start(); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 608dacf253..6735d8dcc4 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -134,6 +134,7 @@ InfiniteSessionConfig, InputOptions, LargeToolOutputConfig, + ManagedMCPServerConfig, McpAuthContext, McpAuthHandler, McpAuthRequest, @@ -141,6 +142,10 @@ McpAuthStaticClientConfig, McpAuthToken, McpAuthWwwAuthenticateParams, + McpHeadersRefreshContext, + McpHeadersRefreshHandler, + McpHeadersRefreshRequest, + McpHeadersRefreshResult, MCPHTTPServerConfig, MCPServerConfig, MCPStdioServerConfig, @@ -300,6 +305,11 @@ "McpAuthStaticClientConfig", "McpAuthToken", "McpAuthWwwAuthenticateParams", + "McpHeadersRefreshContext", + "McpHeadersRefreshHandler", + "McpHeadersRefreshRequest", + "McpHeadersRefreshResult", + "ManagedMCPServerConfig", "ManagedSettings", "ManagedSettingsPermissions", "ModelBilling", diff --git a/python/copilot/client.py b/python/copilot/client.py index 271fad626c..e9bdc034c9 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -102,7 +102,9 @@ GitHubMcpToolConfig, InfiniteSessionConfig, LargeToolOutputConfig, + ManagedMCPServerConfig, McpAuthHandler, + McpHeadersRefreshHandler, MCPServerConfig, MemoryConfiguration, ModelCapabilitiesOverride, @@ -417,6 +419,26 @@ def _mcp_servers_to_wire( return wire +def _managed_mcp_servers_to_wire( + servers: dict[str, ManagedMCPServerConfig], +) -> dict[str, Any]: + """Convert managed MCP configuration keys to the JSON-RPC wire casing.""" + wire: dict[str, Any] = {} + for name, config in servers.items(): + server: dict[str, Any] = { + "displayName": config["display_name"], + "url": config["url"], + } + if "tools" in config: + server["tools"] = config["tools"] + if "timeout" in config: + server["timeout"] = config["timeout"] + if "headers_refresh_ttl_ms" in config: + server["headersRefreshTtlMs"] = config["headers_refresh_ttl_ms"] + wire[name] = server + return wire + + def _large_output_to_wire(config: Mapping[str, Any]) -> dict[str, Any]: """Convert a ``LargeToolOutputConfig`` mapping to wire format.""" wire: dict[str, Any] = {} @@ -2229,6 +2251,7 @@ async def create_session( streaming: bool | None = None, include_sub_agent_streaming_events: bool | None = None, mcp_servers: dict[str, MCPServerConfig] | None = None, + managed_mcp_servers: dict[str, ManagedMCPServerConfig] | None = None, mcp_oauth_token_storage: Literal["persistent", "in-memory"] | None = None, embedding_cache_storage: Literal["persistent", "in-memory"] | None = None, custom_agents: list[CustomAgentConfig] | None = None, @@ -2256,6 +2279,7 @@ async def create_session( commands: list[CommandDefinition] | None = None, on_elicitation_request: ElicitationHandler | None = None, on_mcp_auth_request: McpAuthHandler | None = None, + on_mcp_headers_refresh: McpHeadersRefreshHandler | None = None, enable_mcp_apps: bool = False, on_exit_plan_mode_request: ExitPlanModeHandler | None = None, on_auto_mode_switch_request: AutoModeSwitchHandler | None = None, @@ -2353,6 +2377,12 @@ 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. + managed_mcp_servers: Non-secret hosted MCP servers from a trusted + managed catalog, keyed by stable managed identity. Re-supply on + cold resume. + on_mcp_headers_refresh: Supplies short-lived HTTP headers for managed + MCP servers. Exceptions are reported to the runtime as explicit + credential-broker errors. 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). @@ -2650,6 +2680,8 @@ async def create_session( # Add MCP servers configuration if provided if mcp_servers: payload["mcpServers"] = _mcp_servers_to_wire(mcp_servers) + if managed_mcp_servers is not None: + payload["managedMcpServers"] = _managed_mcp_servers_to_wire(managed_mcp_servers) # Mode "empty" defaults MCP OAuth token storage to in-memory; caller wins. mcp_oauth_token_storage = _mcp_oauth_token_storage_default(mode, mcp_oauth_token_storage) if mcp_oauth_token_storage is not None: @@ -2820,6 +2852,7 @@ def _initialize_session(sid: str) -> CopilotSession: s._register_commands(commands) s._register_permission_handler(on_permission_request) s._register_mcp_auth_handler(on_mcp_auth_request) + s._register_mcp_headers_refresh_handler(on_mcp_headers_refresh) if on_user_input_request: s._register_user_input_handler(on_user_input_request) if on_elicitation_request: @@ -2912,6 +2945,14 @@ def _register_inline(raw_response: Any) -> None: "session.eventLog.registerInterest", {"sessionId": session.session_id, "eventType": "mcp.oauth_required"}, ) + if on_mcp_headers_refresh is not None: + await self._client.request( + "session.eventLog.registerInterest", + { + "sessionId": session.session_id, + "eventType": "mcp.headers_refresh_required", + }, + ) session._workspace_path = response.get("workspacePath") capabilities = response.get("capabilities") session._set_capabilities(capabilities) @@ -2990,6 +3031,7 @@ async def resume_session( streaming: bool | None = None, include_sub_agent_streaming_events: bool | None = None, mcp_servers: dict[str, MCPServerConfig] | None = None, + managed_mcp_servers: dict[str, ManagedMCPServerConfig] | None = None, mcp_oauth_token_storage: Literal["persistent", "in-memory"] | None = None, embedding_cache_storage: Literal["persistent", "in-memory"] | None = None, custom_agents: list[CustomAgentConfig] | None = None, @@ -3017,6 +3059,7 @@ async def resume_session( commands: list[CommandDefinition] | None = None, on_elicitation_request: ElicitationHandler | None = None, on_mcp_auth_request: McpAuthHandler | None = None, + on_mcp_headers_refresh: McpHeadersRefreshHandler | None = None, enable_mcp_apps: bool = False, on_exit_plan_mode_request: ExitPlanModeHandler | None = None, on_auto_mode_switch_request: AutoModeSwitchHandler | None = None, @@ -3116,6 +3159,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. + managed_mcp_servers: Non-secret hosted MCP servers from a trusted + managed catalog. Re-supply them on cold resume. + on_mcp_headers_refresh: Supplies short-lived HTTP headers for managed + MCP servers. 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). @@ -3409,6 +3456,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) + if managed_mcp_servers is not None: + payload["managedMcpServers"] = _managed_mcp_servers_to_wire(managed_mcp_servers) # Mode "empty" defaults MCP OAuth token storage to in-memory; caller wins. mcp_oauth_token_storage = _mcp_oauth_token_storage_default(mode, mcp_oauth_token_storage) if mcp_oauth_token_storage is not None: @@ -3516,6 +3565,7 @@ async def resume_session( session._register_commands(commands) session._register_permission_handler(on_permission_request) session._register_mcp_auth_handler(on_mcp_auth_request) + session._register_mcp_headers_refresh_handler(on_mcp_headers_refresh) if on_user_input_request: session._register_user_input_handler(on_user_input_request) if on_elicitation_request: @@ -3581,6 +3631,14 @@ async def resume_session( "session.eventLog.registerInterest", {"sessionId": session.session_id, "eventType": "mcp.oauth_required"}, ) + if on_mcp_headers_refresh is not None: + await self._client.request( + "session.eventLog.registerInterest", + { + "sessionId": session.session_id, + "eventType": "mcp.headers_refresh_required", + }, + ) except BaseException as exc: with self._sessions_lock: self._sessions.pop(session_id, None) diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 242e4041ed..6c8732e9bb 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -4464,6 +4464,49 @@ def to_dict(self) -> dict: result["workingDirectory"] = from_union([from_str, from_none], self.working_directory) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ManagedMCPServerConfig: + """Non-secret host-managed HTTP MCP server configuration. The containing map key is the + stable managed identity; credentials are supplied dynamically by the host. + """ + display_name: str + """Human-readable catalog display name.""" + + url: str + """Hosted MCP streamable HTTP endpoint.""" + + headers_refresh_ttl_ms: int | None = None + """Maximum dynamic-header cache lifetime in milliseconds.""" + + timeout: int | None = None + """Timeout in milliseconds for tool discovery and tool calls.""" + + tools: list[str] | None = None + """Tools to include. Defaults to all tools when omitted.""" + + @staticmethod + def from_dict(obj: Any) -> 'ManagedMCPServerConfig': + assert isinstance(obj, dict) + display_name = from_str(obj.get("displayName")) + url = from_str(obj.get("url")) + headers_refresh_ttl_ms = from_union([from_int, from_none], obj.get("headersRefreshTtlMs")) + timeout = from_union([from_int, from_none], obj.get("timeout")) + tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("tools")) + return ManagedMCPServerConfig(display_name, url, headers_refresh_ttl_ms, timeout, tools) + + def to_dict(self) -> dict: + result: dict = {} + result["displayName"] = from_str(self.display_name) + result["url"] = from_str(self.url) + if self.headers_refresh_ttl_ms is not None: + result["headersRefreshTtlMs"] = from_union([from_int, from_none], self.headers_refresh_ttl_ms) + if self.timeout is not None: + result["timeout"] = from_union([from_int, from_none], self.timeout) + if self.tools is not None: + result["tools"] = from_union([lambda x: from_list(from_str, x), from_none], self.tools) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class ManagedSettingsReadResult: @@ -5182,6 +5225,7 @@ def to_dict(self) -> dict: return result class MCPHeadersHandlePendingHeadersRefreshRequestKind(Enum): + ERROR = "error" HEADERS = "headers" NONE = "none" @@ -18525,19 +18569,31 @@ class MCPHeadersHandlePendingHeadersRefreshRequest: """Headers to overlay onto the MCP request. Dynamic headers override static config headers but do not replace SDK-managed request headers. """ + ttl_ms: int | None = None + """Optional lifetime in milliseconds for these returned headers. The runtime clamps its + configured cache lifetime to this value. + """ + message: str | None = None + """Host credential broker failure, denial, or revocation reason.""" @staticmethod def from_dict(obj: Any) -> 'MCPHeadersHandlePendingHeadersRefreshRequest': assert isinstance(obj, dict) kind = MCPHeadersHandlePendingHeadersRefreshRequestKind(obj.get("kind")) headers = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("headers")) - return MCPHeadersHandlePendingHeadersRefreshRequest(kind, headers) + ttl_ms = from_union([from_int, from_none], obj.get("ttlMs")) + message = from_union([from_str, from_none], obj.get("message")) + return MCPHeadersHandlePendingHeadersRefreshRequest(kind, headers, ttl_ms, message) def to_dict(self) -> dict: result: dict = {} result["kind"] = to_enum(MCPHeadersHandlePendingHeadersRefreshRequestKind, self.kind) if self.headers is not None: result["headers"] = from_union([lambda x: from_dict(from_str, x), from_none], self.headers) + if self.ttl_ms is not None: + result["ttlMs"] = from_union([from_int, from_none], self.ttl_ms) + if self.message is not None: + result["message"] = from_union([from_str, from_none], self.message) return result # Experimental: this type is part of an experimental API and may change or be removed. @@ -21235,6 +21291,9 @@ class MCPServer: """Connection status: connected, failed, needs-auth, pending, disabled, stopped, or not_configured """ + display_name: str | None = None + """Human-readable display name supplied by a managed server catalog.""" + error: str | None = None """Error message if the server failed to connect""" @@ -21252,16 +21311,19 @@ def from_dict(obj: Any) -> 'MCPServer': assert isinstance(obj, dict) name = from_str(obj.get("name")) status = McpServerStatus(obj.get("status")) + display_name = from_union([from_str, from_none], obj.get("displayName")) error = from_union([from_str, from_none], obj.get("error")) source = from_union([McpServerSource, from_none], obj.get("source")) source_plugin = from_union([from_str, from_none], obj.get("sourcePlugin")) source_plugin_version = from_union([from_str, from_none], obj.get("sourcePluginVersion")) - return MCPServer(name, status, error, source, source_plugin, source_plugin_version) + return MCPServer(name, status, display_name, error, source, source_plugin, source_plugin_version) def to_dict(self) -> dict: result: dict = {} result["name"] = from_str(self.name) result["status"] = to_enum(McpServerStatus, self.status) + if self.display_name is not None: + result["displayName"] = from_union([from_str, from_none], self.display_name) if self.error is not None: result["error"] = from_union([from_str, from_none], self.error) if self.source is not None: @@ -30782,6 +30844,11 @@ class SessionOpenOptions: lsp_client_name: str | None = None """Identifier sent to LSP-style integrations.""" + managed_mcp_servers: dict[str, ManagedMCPServerConfig] | None = None + """Non-secret host-managed HTTP MCP servers keyed by stable managed identity. Managed + provenance is runtime-established from this separate field and credentials are supplied + through dynamic-header refresh. + """ managed_settings: SessionManagedSettings | None = None """Permissions-only enterprise policy injected by the SDK host at session create or resume. Composes restrictively with self-fetched and device policy and is not persisted. @@ -30920,6 +30987,7 @@ def from_dict(obj: Any) -> 'SessionOpenOptions': is_experimental_mode = from_union([from_bool, from_none], obj.get("isExperimentalMode")) log_interactive_shells = from_union([from_bool, from_none], obj.get("logInteractiveShells")) lsp_client_name = from_union([from_str, from_none], obj.get("lspClientName")) + managed_mcp_servers = from_union([lambda x: from_dict(ManagedMCPServerConfig.from_dict, x), from_none], obj.get("managedMcpServers")) managed_settings = from_union([SessionManagedSettings.from_dict, from_none], obj.get("managedSettings")) max_inline_binary_bytes = from_union([from_int, from_none], obj.get("maxInlineBinaryBytes")) memory = from_union([MemoryConfiguration.from_dict, from_none], obj.get("memory")) @@ -30949,7 +31017,7 @@ def from_dict(obj: Any) -> 'SessionOpenOptions': verbosity = from_union([Verbosity, from_none], obj.get("verbosity")) working_directory = from_union([from_str, from_none], obj.get("workingDirectory")) working_directory_context = from_union([SessionContext.from_dict, from_none], obj.get("workingDirectoryContext")) - return SessionOpenOptions(additional_content_exclusion_policies, additional_directories, agent_context, allow_all_mcp_server_instructions, ask_user_disabled, auth_info, available_tools, capi, client_kind, client_name, coauthor_enabled, config_dir, continue_on_auto_mode, copilot_url, custom_agents_local_only, detached_from_spawning_parent_engagement_id, detached_from_spawning_parent_session_id, disabled_instruction_sources, disabled_mcp_servers, disabled_skills, enable_citations, enable_file_change_tracking, enable_managed_settings, enable_on_demand_instruction_discovery, enable_script_safety, enable_streaming, env_value_mode, events_log_directory, events_log_includes_subagents, excluded_builtin_agents, excluded_tools, exp_assignments, feature_flags, included_builtin_agents, included_builtin_skills, installed_plugins, integration_id, is_experimental_mode, log_interactive_shells, lsp_client_name, managed_settings, max_inline_binary_bytes, memory, model, model_capabilities_overrides, models, name, provider, providers, reasoning_effort, reasoning_summary, remote_defaulted_on, remote_exporting, remote_steerable, running_in_interactive_mode, sandbox_config, sandbox_config_source, session_capabilities, session_id, session_limits, shell, shell_init_profile, shell_process_flags, skill_directories, skip_custom_instructions, trajectory_file, verbosity, working_directory, working_directory_context) + return SessionOpenOptions(additional_content_exclusion_policies, additional_directories, agent_context, allow_all_mcp_server_instructions, ask_user_disabled, auth_info, available_tools, capi, client_kind, client_name, coauthor_enabled, config_dir, continue_on_auto_mode, copilot_url, custom_agents_local_only, detached_from_spawning_parent_engagement_id, detached_from_spawning_parent_session_id, disabled_instruction_sources, disabled_mcp_servers, disabled_skills, enable_citations, enable_file_change_tracking, enable_managed_settings, enable_on_demand_instruction_discovery, enable_script_safety, enable_streaming, env_value_mode, events_log_directory, events_log_includes_subagents, excluded_builtin_agents, excluded_tools, exp_assignments, feature_flags, included_builtin_agents, included_builtin_skills, installed_plugins, integration_id, is_experimental_mode, log_interactive_shells, lsp_client_name, managed_mcp_servers, managed_settings, max_inline_binary_bytes, memory, model, model_capabilities_overrides, models, name, provider, providers, reasoning_effort, reasoning_summary, remote_defaulted_on, remote_exporting, remote_steerable, running_in_interactive_mode, sandbox_config, sandbox_config_source, session_capabilities, session_id, session_limits, shell, shell_init_profile, shell_process_flags, skill_directories, skip_custom_instructions, trajectory_file, verbosity, working_directory, working_directory_context) def to_dict(self) -> dict: result: dict = {} @@ -31033,6 +31101,8 @@ def to_dict(self) -> dict: result["logInteractiveShells"] = from_union([from_bool, from_none], self.log_interactive_shells) if self.lsp_client_name is not None: result["lspClientName"] = from_union([from_str, from_none], self.lsp_client_name) + if self.managed_mcp_servers is not None: + result["managedMcpServers"] = from_union([lambda x: from_dict(lambda x: to_class(ManagedMCPServerConfig, x), x), from_none], self.managed_mcp_servers) if self.managed_settings is not None: result["managedSettings"] = from_union([lambda x: to_class(SessionManagedSettings, x), from_none], self.managed_settings) if self.max_inline_binary_bytes is not None: @@ -35267,6 +35337,7 @@ class RPC: log_request: LogRequest log_result: LogResult lsp_initialize_request: LspInitializeRequest + managed_mcp_server_config: ManagedMCPServerConfig managed_settings_read_result: ManagedSettingsReadResult marketplace_add_result: MarketplaceAddResult marketplace_browse_result: MarketplaceBrowseResult @@ -36447,6 +36518,7 @@ def from_dict(obj: Any) -> 'RPC': log_request = LogRequest.from_dict(obj.get("LogRequest")) log_result = LogResult.from_dict(obj.get("LogResult")) lsp_initialize_request = LspInitializeRequest.from_dict(obj.get("LspInitializeRequest")) + managed_mcp_server_config = ManagedMCPServerConfig.from_dict(obj.get("ManagedMcpServerConfig")) managed_settings_read_result = ManagedSettingsReadResult.from_dict(obj.get("ManagedSettingsReadResult")) marketplace_add_result = MarketplaceAddResult.from_dict(obj.get("MarketplaceAddResult")) marketplace_browse_result = MarketplaceBrowseResult.from_dict(obj.get("MarketplaceBrowseResult")) @@ -37291,7 +37363,7 @@ def from_dict(obj: Any) -> 'RPC': subagent_settings = from_union([SubagentSettings.from_dict, from_none], obj.get("SubagentSettings")) task_progress = from_union([TaskProgress.from_dict, from_none], obj.get("TaskProgress")) workspace_summary = from_union([WorkspaceSummary.from_dict, from_none], obj.get("WorkspaceSummary")) - return RPC(abort_request, abort_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, adaptive_thinking_support, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_list_request, agent_registry_live_target_entry, agent_registry_live_target_entry_attention_kind, agent_registry_live_target_entry_kind, agent_registry_live_target_entry_last_terminal_event, agent_registry_live_target_entry_status, agent_registry_log_capture, agent_registry_log_capture_open_error_reason, agent_registry_spawn_error, agent_registry_spawn_permission_mode, agent_registry_spawn_registry_timeout, agent_registry_spawn_request, agent_registry_spawn_result, agent_registry_spawn_spawned, agent_registry_spawn_validation_error, agent_registry_spawn_validation_error_field, agent_registry_spawn_validation_error_reason, agent_reload_result, agents_discover_request, agent_select_request, agent_select_result, agent_set_prompt_request, agents_get_discovery_paths_request, api_key_auth_info, auth_identity, auth_info, auth_info_type, auth_validation_error, auth_validation_errors, built_in_model_catalog, built_in_model_catalog_entry, builtin_tool_descriptor, builtin_tool_format, builtin_tool_format_type, builtin_tool_input_schema, builtin_tool_input_schema_type, builtin_tool_safe_for_telemetry, builtin_tool_safe_telemetry_fields, cancel_user_requested_shell_command_result, canvas_action, canvas_action_invoke_request, canvas_action_invoke_result, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, canvas_provider_register_request, canvas_provider_unregister_request, canvas_session_context, capi_session_options, card_digest, card_digest_algorithm, card_digest_value, catalog_ai_skill_candidate, catalog_ai_skill_candidate_provenance, catalog_authentication_required_error, catalog_authentication_required_reason, catalog_candidate, catalog_candidate_kind, catalog_candidate_source, catalog_candidate_source_embedded, catalog_candidate_source_url, catalog_capability, catalog_capability_id, catalog_client_contract, catalog_contract_violation_error, catalog_contract_violation_reason, catalog_handle_rejected_error, catalog_handle_rejection_reason, catalog_handle_type, catalog_invalid_request_error, catalog_invalid_request_field, catalog_malformed_card_error, catalog_malformed_card_reason, catalog_mcp_server_candidate, catalog_mcp_server_candidate_provenance, catalog_mcp_server_installability, catalog_media_type, catalog_negotiated_contract, catalog_negotiation_refused_error, catalog_negotiation_refused_reason, catalog_network_failure_error, catalog_network_failure_reason, catalog_not_installable_error, catalog_not_installable_reason, catalog_policy_rejected_error, catalog_search_request, catalog_search_result, catalog_search_succeeded, catalog_unavailable_error, catalog_unavailable_reason, catalog_unavailable_transport_error, catalog_unavailable_transport_reason, catalog_unsafe_retrieval_error, catalog_unsafe_retrieval_reason, catalog_unsupported_kind_error, command_list, commands_finalize_invocation_effect_request, commands_finalize_invocation_effect_result, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invocation_effect_outcome, commands_invocation_origin, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, completions_get_trigger_characters_result, completions_request_request, completions_request_result, configure_session_extensions_params, connect_client_info, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_exclusion_check_paths_request, content_exclusion_check_paths_result, content_exclusion_path_check, content_filter_mode, context_heaviest_message, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, current_tool_metadata, debug_collect_logs_collected_entry, debug_collect_logs_destination, debug_collect_logs_entry, debug_collect_logs_entry_kind, debug_collect_logs_include, debug_collect_logs_redaction, debug_collect_logs_request, debug_collect_logs_result, debug_collect_logs_result_kind, debug_collect_logs_skipped_entry, debug_collect_logs_source, discovered_canvas, discovered_extension, discovered_extension_mode, discovered_extension_plugin, discovered_extensions, discovered_extensions_disable_request, discovered_extensions_enable_request, discovered_extension_source, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_direction, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_launch_profile, extension_launch_provider_resolve_request, extension_launch_provider_resolve_result, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_shell_exit, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, factory_abort_request, factory_ack_result, factory_agent_options, factory_agent_request, factory_agent_result, factory_agent_summary, factory_cancel_request, factory_current_phase, factory_declared_limits, factory_durable_operation, factory_execute_request, factory_execute_result, factory_get_run_progress_request, factory_get_run_request, factory_journal_get_request, factory_journal_get_result, factory_journal_put_request, factory_list_runs_request, factory_list_runs_result, factory_log_line, factory_log_line_kind, factory_log_request, factory_phase_observation, factory_phase_status, factory_progress_line, factory_progress_page, factory_resume_request, factory_resume_result, factory_run_consumed, factory_run_detail, factory_run_failure, factory_run_failure_kind, factory_run_limits, factory_run_request, factory_run_result, factory_run_status, factory_run_summary, factory_run_terminal, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, git_hub_telemetry_client_info, git_hub_telemetry_event, git_hub_telemetry_notification, git_hub_token_acquire_reason, git_hub_token_acquire_request, git_hub_token_acquire_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_clear_context_request, history_clear_context_result, history_compact_context_window, history_compact_request, history_compact_result, history_file_restore_skip_reason, history_list_rewind_points_result, history_preview_rewind_request, history_preview_rewind_result, history_rewind_change_type, history_rewind_file_preview, history_rewind_mode, history_rewind_outcome, history_rewind_point, history_rewind_request, history_rewind_result, history_rewind_unavailable_reason, history_skipped_file_restore, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, hook_invoke_request, hook_invoke_response, hook_type, installed_plugin, installed_plugin_info, installed_plugin_source, installed_plugin_source_git_hub, installed_plugin_source_local, installed_plugin_source_url, instruction_discovery_path, instruction_discovery_path_kind, instruction_discovery_path_list, instruction_discovery_path_location, instructions_discover_request, instructions_get_discovery_paths_request, instructions_get_sources_result, instruction_source, instruction_source_location, instruction_source_type, interrupt_main_turn_request, interrupt_main_turn_result, llm_inference_headers, llm_inference_http_request_chunk_request, llm_inference_http_request_chunk_result, llm_inference_http_request_start_request, llm_inference_http_request_start_result, llm_inference_http_request_start_transport, llm_inference_http_response_chunk_error, llm_inference_http_response_chunk_request, llm_inference_http_response_chunk_result, llm_inference_http_response_start_request, llm_inference_http_response_start_result, llm_inference_set_provider_result, local_session_metadata_value, log_request, log_result, lsp_initialize_request, managed_settings_read_result, marketplace_add_result, marketplace_browse_result, marketplace_info, marketplace_list_result, marketplace_plugin_info, marketplace_refresh_entry, marketplace_refresh_result, marketplace_remove_result, mcp_allowed_server, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_configure_git_hub_request, mcp_configure_git_hub_result, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_elicitation_form_mode, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_failed_server, mcp_filtered_server, mcp_headers_handle_pending_headers_refresh_request, mcp_headers_handle_pending_headers_refresh_request_request, mcp_headers_handle_pending_headers_refresh_request_result, mcp_host_state, mcp_install_plan, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_authentication_state_changed_request, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_oauth_probe_needs_auth_reason, mcp_oauth_probe_request, mcp_oauth_probe_result, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_plan_configuration_change, mcp_plan_configuration_operation, mcp_plan_enum_value_type, mcp_plan_install_planned, mcp_plan_install_request, mcp_plan_install_result, mcp_plan_install_source, mcp_plan_install_source_candidate, mcp_plan_install_source_candidate_kind, mcp_plan_install_source_card, mcp_plan_install_source_card_kind, mcp_plan_package_install_method, mcp_plan_package_transport, mcp_plan_policy_decision, mcp_plan_policy_result, mcp_plan_policy_source, mcp_plan_provenance, mcp_plan_remote_install_method, mcp_plan_remote_transport, mcp_plan_required_value, mcp_plan_required_value_enum, mcp_plan_required_value_enum_kind, mcp_plan_required_value_scalar, mcp_plan_required_value_scalar_kind, mcp_plan_resource_identity, mcp_plan_scalar_value_type, mcp_plan_scope, mcp_plan_secret_placeholder, mcp_plan_secret_reference, mcp_plan_target, mcp_plan_transport_choice, mcp_plan_transport_choice_package, mcp_plan_transport_choice_remote, mcp_plan_value_category, mcp_register_external_client_request, mcp_reload_config, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_resource, mcp_resource_annotations, mcp_resource_content, mcp_resource_icon, mcp_resources_list_request, mcp_resources_list_result, mcp_resources_list_templates_request, mcp_resources_list_templates_result, mcp_resources_read_request, mcp_resources_read_result, mcp_resource_template, mcp_restart_server_request, mcp_safe_for_telemetry, mcp_safe_for_telemetry_fields, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_serializable_server_config, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_card_embedded, mcp_server_card_embedded_kind, mcp_server_card_media_type, mcp_server_card_reference, mcp_server_card_url, mcp_server_card_url_kind, mcp_server_config, mcp_server_config_defer_tools, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_memory, mcp_server_config_memory_type, mcp_server_config_stdio, mcp_server_config_stdio_type, mcp_server_failure_info, mcp_server_list, mcp_server_needs_auth_info, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_start_server_request, mcp_start_servers_result, mcp_stop_server_request, mcp_task_metadata, mcp_tools, mcp_tool_ui, mcp_tool_ui_visibility, mcp_unregister_external_client_request, memory_configuration, metadata_context_attribution_result, metadata_context_heaviest_messages_request, metadata_context_heaviest_messages_result, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_apply_startup_overlay_request, model_billing, model_billing_promo, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_list_request, model_message, model_picker_category, model_picker_persistence_request, model_picker_price_category, model_picker_settings_context, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_confirmation, model_switch_to_request, model_switch_to_result, model_warning_text, mode_set_request, mode_set_result, move_mcp_loading_to_background_result, named_provider_config, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_additional_content_exclusion_policy, options_update_additional_content_exclusion_policy_rule, options_update_additional_content_exclusion_policy_rule_source, options_update_additional_content_exclusion_policy_scope, options_update_context_tier, options_update_env_value_mode, options_update_reasoning_summary, options_update_tool_filter_precedence, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_env_access, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_factory, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_env_access, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_factory, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_context, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_outcome, permission_decision_reject, permission_decision_request, permission_decision_source, permission_decision_surface, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_mode_source, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_response_capability, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_get_mode_request, permissions_get_mode_result, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_env_access, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_factory, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_mode_request, permissions_set_mode_result, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_read_sql_todos_result, plan_read_sql_todos_with_dependencies_result, plan_sql_todo_dependency, plan_sql_todos_row, plan_update_request, plugin, plugin_install_result, plugin_list, plugin_list_result, plugins_builtin_set_request, plugins_disable_request, plugins_enable_request, plugins_install_request, plugins_marketplaces_add_request, plugins_marketplaces_browse_request, plugins_marketplaces_refresh_request, plugins_marketplaces_remove_request, plugins_reload_request, plugins_uninstall_request, plugins_update_request, plugin_update_all_entry, plugin_update_all_result, plugin_update_result, protocol_external_tool_defer, protocol_external_tool_definition, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_token_acquire_request, provider_token_acquire_result, push_attachment, push_attachment_blob, push_attachment_directory, push_attachment_file, push_attachment_file_line_range, push_attachment_git_hub_actions_job, push_attachment_git_hub_commit, push_attachment_git_hub_file, push_attachment_git_hub_file_diff, push_attachment_git_hub_file_diff_side, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_git_hub_release, push_attachment_git_hub_repository, push_attachment_git_hub_snippet, push_attachment_git_hub_tree_comparison, push_attachment_git_hub_tree_comparison_side, push_attachment_git_hub_url, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, push_git_hub_repo_ref, queue_begin_deferred_idle_drain_request, queue_begin_deferred_idle_drain_result, queue_consume_system_notifications_request, queued_command_handled, queued_command_not_handled, queued_command_result, queue_defer_session_idle_request, queue_duplicate_at_request, queue_duplicate_at_result, queue_enqueue_resume_pending_result, queue_finish_deferred_idle_drain_request, queue_finish_deferred_idle_drain_result, queue_has_pending_result, queue_insert_at_request, queue_insert_at_result, queue_insert_message, queue_move_item_request, queue_move_item_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_at_request, queue_remove_at_result, queue_remove_most_recent_result, queue_send_now_request, queue_send_now_result, queue_set_drain_paused_request, queue_snapshot_result, queue_update_text_request, queue_update_text_result, register_event_interest_params, register_event_interest_result, register_extension_tools_params, register_extension_tools_result, release_event_interest_params, remote_control_config, remote_control_config_existing_mc_session, remote_control_status, remote_control_status_active, remote_control_status_connecting, remote_control_status_error, remote_control_status_off, remote_control_status_result, remote_control_stop_result, remote_control_transfer_result, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_host_status, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, run_options, sandbox_config, sandbox_config_auth, sandbox_config_source, sandbox_config_user_policy, sandbox_config_user_policy_experimental, sandbox_config_user_policy_experimental_seatbelt, sandbox_config_user_policy_filesystem, sandbox_config_user_policy_network, sandbox_config_user_policy_network_proxy, sandbox_config_user_policy_seatbelt, schedule_add_at_request, schedule_add_cron_request, schedule_add_request, schedule_add_result, schedule_add_self_paced_request, schedule_entry, schedule_has_self_paced_result, schedule_list, schedule_rearm_self_paced_request, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachments_to_message_params, send_message_item, send_messages_request, send_messages_result, send_mode, send_request, send_result, send_system_notification_request, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_agent_list_request, session_auth_login_request, session_auth_logout_user_request, session_auth_status, session_auth_switch_request, session_bulk_delete_result, session_cancel_all_background_agents_result, session_capability, session_commands_list_request, session_completion_item, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_sqlite_transaction_error, session_fs_sqlite_transaction_error_class, session_fs_sqlite_transaction_request, session_fs_sqlite_transaction_result, session_fs_sqlite_transaction_statement, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_git_hub_auth_get_all_auth_available_result, session_git_hub_auth_logout_result, session_git_hub_auth_logout_user_result, session_history_compact_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_git_hub, session_installed_plugin_source_local, session_installed_plugin_source_url, session_limit_prediction_baseline_data, session_limit_prediction_client_type, session_limit_prediction_details, session_limit_prediction_predict_request, session_limit_prediction_request, session_limit_prediction_result, session_limit_prediction_source, session_limit_prediction_tier, session_limit_prediction_tier_option, session_limit_prediction_unavailable_reason, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_managed_permissions, session_managed_settings, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, session_model_list_request, session_model_price_category, session_open_options, session_open_options_additional_content_exclusion_policy, session_open_options_additional_content_exclusion_policy_rule, session_open_options_additional_content_exclusion_policy_rule_source, session_open_options_additional_content_exclusion_policy_scope, session_open_options_env_value_mode, session_open_options_reasoning_summary, session_open_params, session_open_result, session_plugins_reload_request, session_provider_get_endpoint_request, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_delete_request, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, session_settings_built_in_tool_availability_snapshot, session_settings_evaluate_predicate_request, session_settings_evaluate_predicate_result, session_settings_job_snapshot, session_settings_model_snapshot, session_settings_online_evaluation_snapshot, session_settings_predicate_name, session_settings_repo_snapshot, session_settings_snapshot, session_settings_validation_snapshot, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_board_entry_count_request, sessions_get_board_entry_count_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_metadata_request, sessions_get_metadata_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_non_empty_session_ids_request, sessions_list_non_empty_session_ids_result, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_open_attach, sessions_open_cloud, sessions_open_create, sessions_open_handoff, sessions_open_handoff_task_type, sessions_open_progress, sessions_open_progress_status, sessions_open_progress_step, sessions_open_remote, sessions_open_resume, sessions_open_resume_last, sessions_open_status, session_source, sessions_prune_old_request, sessions_register_extension_tools_on_session_options, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, sessions_set_remote_control_steering_request, sessions_start_remote_control_request, sessions_stop_remote_control_request, sessions_transfer_remote_control_request, session_telemetry_engagement, session_update_options_params, session_update_options_result, session_visibility_status, session_working_directory_context, session_working_directory_context_host_type, settable_auth_info, settable_token_auth_info, shell_cancel_user_requested_request, shell_credentials, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_init_profile, shell_init_script, shell_init_script_shell, shell_kill_request, shell_kill_result, shell_kill_signal, shell_options, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_request, skills_config_set_skill_disabled_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_add_timeline_entry_result, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_choice, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_model_picker_dialog, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_set_model_result, slash_command_set_plan_model_result, slash_command_show_dialog_result, slash_command_text_result, slash_command_timeline_entry, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, task_complete_data, task_completion_decision, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, token_provider_auth_info, tool, tool_list, tool_result, tool_result_expanded, tool_result_new_message, tool_result_type, tools_execute_request, tools_get_builtin_descriptors_request, tools_get_builtin_descriptors_result, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_set_request, tools_set_result, tools_shell_descriptor_config, tools_task_complete_event_data_request, tools_update_subagent_settings_result, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_ephemeral_query_request, ui_ephemeral_query_result, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_session_limits_exhausted_request, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_session_limits_exhausted_response, ui_session_limits_exhausted_response_action, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, update_subagent_settings_request, usage_get_metrics_result, usage_metrics_agent_metric, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, user_requested_shell_command_result, user_setting_metadata, user_settings_get_result, user_settings_set_request, user_settings_set_result, visibility_get_result, visibility_set_request, visibility_set_result, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_add_summary_request, workspaces_add_summary_result, workspaces_autopilot_objective_exists_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_delete_autopilot_objective_result, workspaces_diff_request, workspaces_ensure_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_autopilot_objective_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspaces_truncate_summaries_request, workspace_summary_host_type, workspaces_update_metadata_request, workspaces_workspace_details_host_type, workspaces_write_autopilot_objective_request, workspaces_write_autopilot_objective_result, session_auth_info_result, session_context_attribution, session_context_info, subagent_settings, task_progress, workspace_summary) + return RPC(abort_request, abort_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, adaptive_thinking_support, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_list_request, agent_registry_live_target_entry, agent_registry_live_target_entry_attention_kind, agent_registry_live_target_entry_kind, agent_registry_live_target_entry_last_terminal_event, agent_registry_live_target_entry_status, agent_registry_log_capture, agent_registry_log_capture_open_error_reason, agent_registry_spawn_error, agent_registry_spawn_permission_mode, agent_registry_spawn_registry_timeout, agent_registry_spawn_request, agent_registry_spawn_result, agent_registry_spawn_spawned, agent_registry_spawn_validation_error, agent_registry_spawn_validation_error_field, agent_registry_spawn_validation_error_reason, agent_reload_result, agents_discover_request, agent_select_request, agent_select_result, agent_set_prompt_request, agents_get_discovery_paths_request, api_key_auth_info, auth_identity, auth_info, auth_info_type, auth_validation_error, auth_validation_errors, built_in_model_catalog, built_in_model_catalog_entry, builtin_tool_descriptor, builtin_tool_format, builtin_tool_format_type, builtin_tool_input_schema, builtin_tool_input_schema_type, builtin_tool_safe_for_telemetry, builtin_tool_safe_telemetry_fields, cancel_user_requested_shell_command_result, canvas_action, canvas_action_invoke_request, canvas_action_invoke_result, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, canvas_provider_register_request, canvas_provider_unregister_request, canvas_session_context, capi_session_options, card_digest, card_digest_algorithm, card_digest_value, catalog_ai_skill_candidate, catalog_ai_skill_candidate_provenance, catalog_authentication_required_error, catalog_authentication_required_reason, catalog_candidate, catalog_candidate_kind, catalog_candidate_source, catalog_candidate_source_embedded, catalog_candidate_source_url, catalog_capability, catalog_capability_id, catalog_client_contract, catalog_contract_violation_error, catalog_contract_violation_reason, catalog_handle_rejected_error, catalog_handle_rejection_reason, catalog_handle_type, catalog_invalid_request_error, catalog_invalid_request_field, catalog_malformed_card_error, catalog_malformed_card_reason, catalog_mcp_server_candidate, catalog_mcp_server_candidate_provenance, catalog_mcp_server_installability, catalog_media_type, catalog_negotiated_contract, catalog_negotiation_refused_error, catalog_negotiation_refused_reason, catalog_network_failure_error, catalog_network_failure_reason, catalog_not_installable_error, catalog_not_installable_reason, catalog_policy_rejected_error, catalog_search_request, catalog_search_result, catalog_search_succeeded, catalog_unavailable_error, catalog_unavailable_reason, catalog_unavailable_transport_error, catalog_unavailable_transport_reason, catalog_unsafe_retrieval_error, catalog_unsafe_retrieval_reason, catalog_unsupported_kind_error, command_list, commands_finalize_invocation_effect_request, commands_finalize_invocation_effect_result, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invocation_effect_outcome, commands_invocation_origin, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, completions_get_trigger_characters_result, completions_request_request, completions_request_result, configure_session_extensions_params, connect_client_info, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_exclusion_check_paths_request, content_exclusion_check_paths_result, content_exclusion_path_check, content_filter_mode, context_heaviest_message, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, current_tool_metadata, debug_collect_logs_collected_entry, debug_collect_logs_destination, debug_collect_logs_entry, debug_collect_logs_entry_kind, debug_collect_logs_include, debug_collect_logs_redaction, debug_collect_logs_request, debug_collect_logs_result, debug_collect_logs_result_kind, debug_collect_logs_skipped_entry, debug_collect_logs_source, discovered_canvas, discovered_extension, discovered_extension_mode, discovered_extension_plugin, discovered_extensions, discovered_extensions_disable_request, discovered_extensions_enable_request, discovered_extension_source, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_direction, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_launch_profile, extension_launch_provider_resolve_request, extension_launch_provider_resolve_result, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_shell_exit, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, factory_abort_request, factory_ack_result, factory_agent_options, factory_agent_request, factory_agent_result, factory_agent_summary, factory_cancel_request, factory_current_phase, factory_declared_limits, factory_durable_operation, factory_execute_request, factory_execute_result, factory_get_run_progress_request, factory_get_run_request, factory_journal_get_request, factory_journal_get_result, factory_journal_put_request, factory_list_runs_request, factory_list_runs_result, factory_log_line, factory_log_line_kind, factory_log_request, factory_phase_observation, factory_phase_status, factory_progress_line, factory_progress_page, factory_resume_request, factory_resume_result, factory_run_consumed, factory_run_detail, factory_run_failure, factory_run_failure_kind, factory_run_limits, factory_run_request, factory_run_result, factory_run_status, factory_run_summary, factory_run_terminal, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, git_hub_telemetry_client_info, git_hub_telemetry_event, git_hub_telemetry_notification, git_hub_token_acquire_reason, git_hub_token_acquire_request, git_hub_token_acquire_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_clear_context_request, history_clear_context_result, history_compact_context_window, history_compact_request, history_compact_result, history_file_restore_skip_reason, history_list_rewind_points_result, history_preview_rewind_request, history_preview_rewind_result, history_rewind_change_type, history_rewind_file_preview, history_rewind_mode, history_rewind_outcome, history_rewind_point, history_rewind_request, history_rewind_result, history_rewind_unavailable_reason, history_skipped_file_restore, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, hook_invoke_request, hook_invoke_response, hook_type, installed_plugin, installed_plugin_info, installed_plugin_source, installed_plugin_source_git_hub, installed_plugin_source_local, installed_plugin_source_url, instruction_discovery_path, instruction_discovery_path_kind, instruction_discovery_path_list, instruction_discovery_path_location, instructions_discover_request, instructions_get_discovery_paths_request, instructions_get_sources_result, instruction_source, instruction_source_location, instruction_source_type, interrupt_main_turn_request, interrupt_main_turn_result, llm_inference_headers, llm_inference_http_request_chunk_request, llm_inference_http_request_chunk_result, llm_inference_http_request_start_request, llm_inference_http_request_start_result, llm_inference_http_request_start_transport, llm_inference_http_response_chunk_error, llm_inference_http_response_chunk_request, llm_inference_http_response_chunk_result, llm_inference_http_response_start_request, llm_inference_http_response_start_result, llm_inference_set_provider_result, local_session_metadata_value, log_request, log_result, lsp_initialize_request, managed_mcp_server_config, managed_settings_read_result, marketplace_add_result, marketplace_browse_result, marketplace_info, marketplace_list_result, marketplace_plugin_info, marketplace_refresh_entry, marketplace_refresh_result, marketplace_remove_result, mcp_allowed_server, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_configure_git_hub_request, mcp_configure_git_hub_result, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_elicitation_form_mode, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_failed_server, mcp_filtered_server, mcp_headers_handle_pending_headers_refresh_request, mcp_headers_handle_pending_headers_refresh_request_request, mcp_headers_handle_pending_headers_refresh_request_result, mcp_host_state, mcp_install_plan, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_authentication_state_changed_request, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_oauth_probe_needs_auth_reason, mcp_oauth_probe_request, mcp_oauth_probe_result, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_plan_configuration_change, mcp_plan_configuration_operation, mcp_plan_enum_value_type, mcp_plan_install_planned, mcp_plan_install_request, mcp_plan_install_result, mcp_plan_install_source, mcp_plan_install_source_candidate, mcp_plan_install_source_candidate_kind, mcp_plan_install_source_card, mcp_plan_install_source_card_kind, mcp_plan_package_install_method, mcp_plan_package_transport, mcp_plan_policy_decision, mcp_plan_policy_result, mcp_plan_policy_source, mcp_plan_provenance, mcp_plan_remote_install_method, mcp_plan_remote_transport, mcp_plan_required_value, mcp_plan_required_value_enum, mcp_plan_required_value_enum_kind, mcp_plan_required_value_scalar, mcp_plan_required_value_scalar_kind, mcp_plan_resource_identity, mcp_plan_scalar_value_type, mcp_plan_scope, mcp_plan_secret_placeholder, mcp_plan_secret_reference, mcp_plan_target, mcp_plan_transport_choice, mcp_plan_transport_choice_package, mcp_plan_transport_choice_remote, mcp_plan_value_category, mcp_register_external_client_request, mcp_reload_config, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_resource, mcp_resource_annotations, mcp_resource_content, mcp_resource_icon, mcp_resources_list_request, mcp_resources_list_result, mcp_resources_list_templates_request, mcp_resources_list_templates_result, mcp_resources_read_request, mcp_resources_read_result, mcp_resource_template, mcp_restart_server_request, mcp_safe_for_telemetry, mcp_safe_for_telemetry_fields, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_serializable_server_config, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_card_embedded, mcp_server_card_embedded_kind, mcp_server_card_media_type, mcp_server_card_reference, mcp_server_card_url, mcp_server_card_url_kind, mcp_server_config, mcp_server_config_defer_tools, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_memory, mcp_server_config_memory_type, mcp_server_config_stdio, mcp_server_config_stdio_type, mcp_server_failure_info, mcp_server_list, mcp_server_needs_auth_info, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_start_server_request, mcp_start_servers_result, mcp_stop_server_request, mcp_task_metadata, mcp_tools, mcp_tool_ui, mcp_tool_ui_visibility, mcp_unregister_external_client_request, memory_configuration, metadata_context_attribution_result, metadata_context_heaviest_messages_request, metadata_context_heaviest_messages_result, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_apply_startup_overlay_request, model_billing, model_billing_promo, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_list_request, model_message, model_picker_category, model_picker_persistence_request, model_picker_price_category, model_picker_settings_context, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_confirmation, model_switch_to_request, model_switch_to_result, model_warning_text, mode_set_request, mode_set_result, move_mcp_loading_to_background_result, named_provider_config, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_additional_content_exclusion_policy, options_update_additional_content_exclusion_policy_rule, options_update_additional_content_exclusion_policy_rule_source, options_update_additional_content_exclusion_policy_scope, options_update_context_tier, options_update_env_value_mode, options_update_reasoning_summary, options_update_tool_filter_precedence, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_env_access, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_factory, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_env_access, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_factory, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_context, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_outcome, permission_decision_reject, permission_decision_request, permission_decision_source, permission_decision_surface, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_mode_source, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_response_capability, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_get_mode_request, permissions_get_mode_result, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_env_access, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_factory, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_mode_request, permissions_set_mode_result, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_read_sql_todos_result, plan_read_sql_todos_with_dependencies_result, plan_sql_todo_dependency, plan_sql_todos_row, plan_update_request, plugin, plugin_install_result, plugin_list, plugin_list_result, plugins_builtin_set_request, plugins_disable_request, plugins_enable_request, plugins_install_request, plugins_marketplaces_add_request, plugins_marketplaces_browse_request, plugins_marketplaces_refresh_request, plugins_marketplaces_remove_request, plugins_reload_request, plugins_uninstall_request, plugins_update_request, plugin_update_all_entry, plugin_update_all_result, plugin_update_result, protocol_external_tool_defer, protocol_external_tool_definition, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_token_acquire_request, provider_token_acquire_result, push_attachment, push_attachment_blob, push_attachment_directory, push_attachment_file, push_attachment_file_line_range, push_attachment_git_hub_actions_job, push_attachment_git_hub_commit, push_attachment_git_hub_file, push_attachment_git_hub_file_diff, push_attachment_git_hub_file_diff_side, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_git_hub_release, push_attachment_git_hub_repository, push_attachment_git_hub_snippet, push_attachment_git_hub_tree_comparison, push_attachment_git_hub_tree_comparison_side, push_attachment_git_hub_url, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, push_git_hub_repo_ref, queue_begin_deferred_idle_drain_request, queue_begin_deferred_idle_drain_result, queue_consume_system_notifications_request, queued_command_handled, queued_command_not_handled, queued_command_result, queue_defer_session_idle_request, queue_duplicate_at_request, queue_duplicate_at_result, queue_enqueue_resume_pending_result, queue_finish_deferred_idle_drain_request, queue_finish_deferred_idle_drain_result, queue_has_pending_result, queue_insert_at_request, queue_insert_at_result, queue_insert_message, queue_move_item_request, queue_move_item_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_at_request, queue_remove_at_result, queue_remove_most_recent_result, queue_send_now_request, queue_send_now_result, queue_set_drain_paused_request, queue_snapshot_result, queue_update_text_request, queue_update_text_result, register_event_interest_params, register_event_interest_result, register_extension_tools_params, register_extension_tools_result, release_event_interest_params, remote_control_config, remote_control_config_existing_mc_session, remote_control_status, remote_control_status_active, remote_control_status_connecting, remote_control_status_error, remote_control_status_off, remote_control_status_result, remote_control_stop_result, remote_control_transfer_result, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_host_status, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, run_options, sandbox_config, sandbox_config_auth, sandbox_config_source, sandbox_config_user_policy, sandbox_config_user_policy_experimental, sandbox_config_user_policy_experimental_seatbelt, sandbox_config_user_policy_filesystem, sandbox_config_user_policy_network, sandbox_config_user_policy_network_proxy, sandbox_config_user_policy_seatbelt, schedule_add_at_request, schedule_add_cron_request, schedule_add_request, schedule_add_result, schedule_add_self_paced_request, schedule_entry, schedule_has_self_paced_result, schedule_list, schedule_rearm_self_paced_request, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachments_to_message_params, send_message_item, send_messages_request, send_messages_result, send_mode, send_request, send_result, send_system_notification_request, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_agent_list_request, session_auth_login_request, session_auth_logout_user_request, session_auth_status, session_auth_switch_request, session_bulk_delete_result, session_cancel_all_background_agents_result, session_capability, session_commands_list_request, session_completion_item, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_sqlite_transaction_error, session_fs_sqlite_transaction_error_class, session_fs_sqlite_transaction_request, session_fs_sqlite_transaction_result, session_fs_sqlite_transaction_statement, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_git_hub_auth_get_all_auth_available_result, session_git_hub_auth_logout_result, session_git_hub_auth_logout_user_result, session_history_compact_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_git_hub, session_installed_plugin_source_local, session_installed_plugin_source_url, session_limit_prediction_baseline_data, session_limit_prediction_client_type, session_limit_prediction_details, session_limit_prediction_predict_request, session_limit_prediction_request, session_limit_prediction_result, session_limit_prediction_source, session_limit_prediction_tier, session_limit_prediction_tier_option, session_limit_prediction_unavailable_reason, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_managed_permissions, session_managed_settings, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, session_model_list_request, session_model_price_category, session_open_options, session_open_options_additional_content_exclusion_policy, session_open_options_additional_content_exclusion_policy_rule, session_open_options_additional_content_exclusion_policy_rule_source, session_open_options_additional_content_exclusion_policy_scope, session_open_options_env_value_mode, session_open_options_reasoning_summary, session_open_params, session_open_result, session_plugins_reload_request, session_provider_get_endpoint_request, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_delete_request, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, session_settings_built_in_tool_availability_snapshot, session_settings_evaluate_predicate_request, session_settings_evaluate_predicate_result, session_settings_job_snapshot, session_settings_model_snapshot, session_settings_online_evaluation_snapshot, session_settings_predicate_name, session_settings_repo_snapshot, session_settings_snapshot, session_settings_validation_snapshot, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_board_entry_count_request, sessions_get_board_entry_count_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_metadata_request, sessions_get_metadata_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_non_empty_session_ids_request, sessions_list_non_empty_session_ids_result, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_open_attach, sessions_open_cloud, sessions_open_create, sessions_open_handoff, sessions_open_handoff_task_type, sessions_open_progress, sessions_open_progress_status, sessions_open_progress_step, sessions_open_remote, sessions_open_resume, sessions_open_resume_last, sessions_open_status, session_source, sessions_prune_old_request, sessions_register_extension_tools_on_session_options, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, sessions_set_remote_control_steering_request, sessions_start_remote_control_request, sessions_stop_remote_control_request, sessions_transfer_remote_control_request, session_telemetry_engagement, session_update_options_params, session_update_options_result, session_visibility_status, session_working_directory_context, session_working_directory_context_host_type, settable_auth_info, settable_token_auth_info, shell_cancel_user_requested_request, shell_credentials, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_init_profile, shell_init_script, shell_init_script_shell, shell_kill_request, shell_kill_result, shell_kill_signal, shell_options, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_request, skills_config_set_skill_disabled_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_add_timeline_entry_result, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_choice, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_model_picker_dialog, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_set_model_result, slash_command_set_plan_model_result, slash_command_show_dialog_result, slash_command_text_result, slash_command_timeline_entry, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, task_complete_data, task_completion_decision, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, token_provider_auth_info, tool, tool_list, tool_result, tool_result_expanded, tool_result_new_message, tool_result_type, tools_execute_request, tools_get_builtin_descriptors_request, tools_get_builtin_descriptors_result, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_set_request, tools_set_result, tools_shell_descriptor_config, tools_task_complete_event_data_request, tools_update_subagent_settings_result, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_ephemeral_query_request, ui_ephemeral_query_result, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_session_limits_exhausted_request, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_session_limits_exhausted_response, ui_session_limits_exhausted_response_action, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, update_subagent_settings_request, usage_get_metrics_result, usage_metrics_agent_metric, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, user_requested_shell_command_result, user_setting_metadata, user_settings_get_result, user_settings_set_request, user_settings_set_result, visibility_get_result, visibility_set_request, visibility_set_result, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_add_summary_request, workspaces_add_summary_result, workspaces_autopilot_objective_exists_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_delete_autopilot_objective_result, workspaces_diff_request, workspaces_ensure_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_autopilot_objective_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspaces_truncate_summaries_request, workspace_summary_host_type, workspaces_update_metadata_request, workspaces_workspace_details_host_type, workspaces_write_autopilot_objective_request, workspaces_write_autopilot_objective_result, session_auth_info_result, session_context_attribution, session_context_info, subagent_settings, task_progress, workspace_summary) def to_dict(self) -> dict: result: dict = {} @@ -37627,6 +37699,7 @@ def to_dict(self) -> dict: result["LogRequest"] = to_class(LogRequest, self.log_request) result["LogResult"] = to_class(LogResult, self.log_result) result["LspInitializeRequest"] = to_class(LspInitializeRequest, self.lsp_initialize_request) + result["ManagedMcpServerConfig"] = to_class(ManagedMCPServerConfig, self.managed_mcp_server_config) result["ManagedSettingsReadResult"] = to_class(ManagedSettingsReadResult, self.managed_settings_read_result) result["MarketplaceAddResult"] = to_class(MarketplaceAddResult, self.marketplace_add_result) result["MarketplaceBrowseResult"] = to_class(MarketplaceBrowseResult, self.marketplace_browse_result) @@ -42226,6 +42299,7 @@ async def handle_git_hub_token_get_token(params: dict) -> dict | None: "MCPToolUIVisibility", "MCPTools", "MCPUnregisterExternalClientRequest", + "ManagedMCPServerConfig", "ManagedSettingsReadResult", "MarketplaceAddResult", "MarketplaceBrowseResult", diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index d4af5f2735..7db3cbd21d 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -4413,6 +4413,7 @@ class McpServersLoadedServer: "A single MCP server status summary in `session.mcp_servers_loaded`, including name, status, source, transport, and plugin metadata." name: str status: McpServerStatus + display_name: str | None = None error: str | None = None plugin_name: str | None = None plugin_version: str | None = None @@ -4424,6 +4425,7 @@ def from_dict(obj: Any) -> "McpServersLoadedServer": assert isinstance(obj, dict) name = from_str(obj.get("name")) status = parse_enum(McpServerStatus, obj.get("status")) + display_name = from_union([from_none, from_str], obj.get("displayName")) error = from_union([from_none, from_str], obj.get("error")) plugin_name = from_union([from_none, from_str], obj.get("pluginName")) plugin_version = from_union([from_none, from_str], obj.get("pluginVersion")) @@ -4432,6 +4434,7 @@ def from_dict(obj: Any) -> "McpServersLoadedServer": return McpServersLoadedServer( name=name, status=status, + display_name=display_name, error=error, plugin_name=plugin_name, plugin_version=plugin_version, @@ -4443,6 +4446,8 @@ def to_dict(self) -> dict: result: dict = {} result["name"] = from_str(self.name) result["status"] = to_enum(McpServerStatus, self.status) + if self.display_name is not None: + result["displayName"] = from_union([from_none, from_str], self.display_name) if self.error is not None: result["error"] = from_union([from_none, from_str], self.error) if self.plugin_name is not None: @@ -11048,7 +11053,7 @@ class McpOauthRequestReason(Enum): class McpServerSource(Enum): - "Configuration source: user, workspace, plugin, or builtin" + "Configuration source: user, workspace, plugin, builtin, or managed" # Server configured in the user's global MCP configuration. USER = "user" # Server configured by the current workspace. @@ -11057,6 +11062,8 @@ class McpServerSource(Enum): PLUGIN = "plugin" # Server bundled with the runtime. BUILTIN = "builtin" + # Server supplied by a trusted host-managed catalog. + MANAGED = "managed" class McpServerStatus(Enum): diff --git a/python/copilot/session.py b/python/copilot/session.py index 78afdde139..2ee1e93032 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -37,6 +37,9 @@ GitHubTokenAcquireResultKind, HandlePendingToolCallRequest, LogRequest, + MCPHeadersHandlePendingHeadersRefreshRequest, + MCPHeadersHandlePendingHeadersRefreshRequestKind, + MCPHeadersHandlePendingHeadersRefreshRequestRequest, MCPOauthHandlePendingRequest, MCPOauthPendingRequestResponse, ModelSwitchToRequest, @@ -70,6 +73,7 @@ CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, + McpHeadersRefreshRequiredData, McpOauthRequiredData, PermissionRequest, PermissionRequestedData, @@ -498,6 +502,41 @@ class McpAuthContext(TypedDict): ] +class McpHeadersRefreshRequest(TypedDict): + """Managed MCP server whose short-lived HTTP headers need refreshing.""" + + server_name: str + server_url: str + reason: Literal["startup", "ttl-expired", "auth-failed"] + + +class McpHeadersRefreshResult(TypedDict): + """Dynamic headers and their optional credential-bounded cache lifetime.""" + + headers: dict[str, str] + ttl_ms: NotRequired[int] + + +class McpHeadersRefreshContext(TypedDict): + """Context for a managed MCP headers refresh handler invocation.""" + + session_id: str + + +McpHeadersRefreshHandlerResult = McpHeadersRefreshResult | dict[str, str] | None +McpHeadersRefreshHandler = Callable[ + [McpHeadersRefreshRequest, McpHeadersRefreshContext], + McpHeadersRefreshHandlerResult | Awaitable[McpHeadersRefreshHandlerResult], +] + + +def _is_mcp_headers_refresh_result( + value: McpHeadersRefreshResult | dict[str, str], +) -> bool: + headers = value.get("headers") + return isinstance(headers, dict) and all(isinstance(header, str) for header in headers.values()) + + # ============================================================================ # User Input Request Types # ============================================================================ @@ -1164,6 +1203,16 @@ class MCPHTTPServerConfig(TypedDict, total=False): MCPServerConfig = MCPStdioServerConfig | MCPHTTPServerConfig +class ManagedMCPServerConfig(TypedDict, total=False): + """Non-secret hosted MCP server from a trusted managed catalog.""" + + display_name: Required[str] + url: Required[str] + tools: list[str] + timeout: int + headers_refresh_ttl_ms: int + + class GitHubMcpToolConfig(TypedDict, total=False): """Configuration for the built-in GitHub MCP server. @@ -1578,6 +1627,8 @@ def __init__( self._permission_handler_lock = threading.Lock() self._mcp_auth_handler: McpAuthHandler | None = None self._mcp_auth_handler_lock = threading.Lock() + self._mcp_headers_refresh_handler: McpHeadersRefreshHandler | None = None + self._mcp_headers_refresh_handler_lock = threading.Lock() self._user_input_handler: UserInputHandler | None = None self._user_input_handler_lock = threading.Lock() self._exit_plan_mode_handler: ExitPlanModeHandler | None = None @@ -2030,6 +2081,23 @@ def _handle_broadcast_event(self, event: SessionEvent) -> None: request["staticClientConfig"] = static_client_config asyncio.ensure_future(self._execute_mcp_auth_and_respond(request, handler)) + case McpHeadersRefreshRequiredData() as data: + with self._mcp_headers_refresh_handler_lock: + handler = self._mcp_headers_refresh_handler + if not data.request_id or not handler: + return + asyncio.ensure_future( + self._execute_mcp_headers_refresh_and_respond( + data.request_id, + { + "server_name": data.server_name, + "server_url": data.server_url, + "reason": data.reason.value, + }, + handler, + ) + ) + case CommandExecuteData() as data: request_id = data.request_id command_name = data.command_name @@ -2320,6 +2388,68 @@ async def _execute_mcp_auth_and_respond( except (JsonRpcError, ProcessExitedError, OSError): pass # Connection lost or RPC error — nothing we can do + async def _execute_mcp_headers_refresh_and_respond( + self, + request_id: str, + request: McpHeadersRefreshRequest, + handler: McpHeadersRefreshHandler, + ) -> None: + """Execute a managed MCP headers refresh handler and respond via RPC.""" + try: + maybe_result = handler(request, {"session_id": self.session_id}) + if inspect.isawaitable(maybe_result): + result = cast(McpHeadersRefreshHandlerResult, await maybe_result) + else: + result = maybe_result + + if result is None: + rpc_result = MCPHeadersHandlePendingHeadersRefreshRequest( + kind=MCPHeadersHandlePendingHeadersRefreshRequestKind.NONE + ) + elif _is_mcp_headers_refresh_result(result): + structured = cast(McpHeadersRefreshResult, result) + rpc_result = MCPHeadersHandlePendingHeadersRefreshRequest( + kind=MCPHeadersHandlePendingHeadersRefreshRequestKind.HEADERS, + headers=structured["headers"], + ttl_ms=structured.get("ttl_ms"), + ) + else: + rpc_result = MCPHeadersHandlePendingHeadersRefreshRequest( + kind=MCPHeadersHandlePendingHeadersRefreshRequestKind.HEADERS, + headers=cast(dict[str, str], result), + ) + except asyncio.CancelledError as exc: + message = str(exc) or "MCP headers refresh cancelled" + logger.warning( + "MCP headers refresh cancelled for %r: %s", + request["server_name"], + message, + ) + rpc_result = MCPHeadersHandlePendingHeadersRefreshRequest( + kind=MCPHeadersHandlePendingHeadersRefreshRequestKind.ERROR, + message=message, + ) + except Exception as exc: + logger.warning( + "MCP headers refresh failed for %r: %s", + request["server_name"], + exc, + ) + rpc_result = MCPHeadersHandlePendingHeadersRefreshRequest( + kind=MCPHeadersHandlePendingHeadersRefreshRequestKind.ERROR, + message=str(exc), + ) + + try: + await self.rpc.mcp.headers.handle_pending_headers_refresh_request( + MCPHeadersHandlePendingHeadersRefreshRequestRequest( + request_id=request_id, + result=rpc_result, + ) + ) + except (JsonRpcError, ProcessExitedError, OSError): + pass + async def _execute_command_and_respond( self, request_id: str, @@ -2509,6 +2639,13 @@ def _register_mcp_auth_handler(self, handler: McpAuthHandler | None) -> None: with self._mcp_auth_handler_lock: self._mcp_auth_handler = handler + def _register_mcp_headers_refresh_handler( + self, handler: McpHeadersRefreshHandler | None + ) -> None: + """Register the managed MCP dynamic-headers handler for this session.""" + with self._mcp_headers_refresh_handler_lock: + self._mcp_headers_refresh_handler = handler + def _register_exit_plan_mode_handler(self, handler: ExitPlanModeHandler | None) -> None: """Register the exit-plan-mode handler for this session.""" with self._exit_plan_mode_handler_lock: diff --git a/python/test_client.py b/python/test_client.py index a33f0ecd60..ed2151ea40 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -19,6 +19,7 @@ CopilotClient, DisableBypassPermissionsModes, ExtensionInfo, + ManagedMCPServerConfig, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, RuntimeConnection, @@ -38,8 +39,10 @@ ModelLimits, ModelSupports, ) -from copilot.session import PermissionHandler +from copilot.session import CopilotSession, PermissionHandler from copilot.session_events import ( + McpHeadersRefreshRequiredData, + McpHeadersRefreshRequiredReason, McpOauthRequestReason, McpOauthRequiredData, McpOauthRequiredStaticClientConfig, @@ -552,6 +555,173 @@ def handle_mcp_auth_request(request, invocation): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_managed_mcp_headers_refresh_dispatches_ttl_and_broker_errors(self): + transport = Mock() + transport.request = AsyncMock(return_value={"success": True}) + session = CopilotSession("managed-session", transport) + responses = [ + {"Authorization": "Bearer short-lived"}, + { + "headers": {"Authorization": "Bearer refreshed"}, + "ttl_ms": 5_000, + }, + None, + ] + + async def handle_refresh(request, context): + assert request == { + "server_name": "GitHub", + "server_url": "https://example.com/mcp", + "reason": "startup", + } + assert context == {"session_id": "managed-session"} + return responses.pop(0) + + session._register_mcp_headers_refresh_handler(handle_refresh) + event = SessionEvent( + data=McpHeadersRefreshRequiredData( + request_id="refresh-1", + server_name="GitHub", + server_url="https://example.com/mcp", + reason=McpHeadersRefreshRequiredReason.STARTUP, + ), + id="evt-refresh", + timestamp="2026-01-01T00:00:00Z", + type=SessionEventType.MCP_HEADERS_REFRESH_REQUIRED, + ephemeral=True, + parent_id=None, + ) + session._dispatch_event(event) + for _ in range(200): + if transport.request.await_count == 1: + break + await asyncio.sleep(0.005) + + await session._execute_mcp_headers_refresh_and_respond( + "refresh-2", + { + "server_name": "GitHub", + "server_url": "https://example.com/mcp", + "reason": "startup", + }, + handle_refresh, + ) + await session._execute_mcp_headers_refresh_and_respond( + "refresh-3", + { + "server_name": "GitHub", + "server_url": "https://example.com/mcp", + "reason": "startup", + }, + handle_refresh, + ) + + def fail_refresh(_request, _context): + raise RuntimeError("credential revoked") + + await session._execute_mcp_headers_refresh_and_respond( + "refresh-4", + { + "server_name": "GitHub", + "server_url": "https://example.com/mcp", + "reason": "auth-failed", + }, + fail_refresh, + ) + await session._execute_mcp_headers_refresh_and_respond( + "refresh-5", + { + "server_name": "GitHub", + "server_url": "https://example.com/mcp", + "reason": "auth-failed", + }, + lambda _request, _context: (_ for _ in ()).throw(asyncio.CancelledError()), + ) + + results = [call.args[1]["result"] for call in transport.request.await_args_list] + assert results == [ + { + "kind": "headers", + "headers": {"Authorization": "Bearer short-lived"}, + }, + { + "kind": "headers", + "headers": {"Authorization": "Bearer refreshed"}, + "ttlMs": 5_000, + }, + {"kind": "none"}, + {"kind": "error", "message": "credential revoked"}, + {"kind": "error", "message": "MCP headers refresh cancelled"}, + ] + + @pytest.mark.asyncio + async def test_create_and_resume_forward_managed_mcp_servers(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured: list[tuple[str, dict]] = [] + + async def mock_request(method, params, **kwargs): + captured.append((method, params)) + if method in ("session.create", "session.resume"): + result = {"sessionId": params["sessionId"], "workspacePath": None} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + if method == "session.eventLog.registerInterest": + return {"id": "interest-1"} + return {} + + client._client.request = mock_request + managed: dict[str, ManagedMCPServerConfig] = { + "github": { + "display_name": "GitHub", + "url": "https://example.com/mcp", + "tools": ["issues"], + "timeout": 30_000, + "headers_refresh_ttl_ms": 60_000, + } + } + + session = await client.create_session( + managed_mcp_servers=managed, + on_mcp_headers_refresh=lambda _request, _context: None, + ) + await client.resume_session( + session.session_id, + managed_mcp_servers=managed, + on_mcp_headers_refresh=lambda _request, _context: None, + ) + + requests = { + method: params + for method, params in captured + if method in ("session.create", "session.resume") + } + expected = { + "github": { + "displayName": "GitHub", + "url": "https://example.com/mcp", + "tools": ["issues"], + "timeout": 30_000, + "headersRefreshTtlMs": 60_000, + } + } + assert requests["session.create"]["managedMcpServers"] == expected + assert requests["session.resume"]["managedMcpServers"] == expected + assert ( + sum( + method == "session.eventLog.registerInterest" + and params["eventType"] == "mcp.headers_refresh_required" + for method, params in captured + ) + == 2 + ) + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_session_forwards_cloud_options(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index bdf291e599..d98ff0144c 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -7735,6 +7735,9 @@ pub struct McpHeadersHandlePendingHeadersRefreshRequestHeaders { pub headers: HashMap, /// Headers-refresh response variant discriminator. pub kind: McpHeadersHandlePendingHeadersRefreshRequestHeadersKind, + /// Optional lifetime in milliseconds for these returned headers. The runtime clamps its configured cache lifetime to this value. + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl_ms: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -7744,6 +7747,15 @@ pub struct McpHeadersHandlePendingHeadersRefreshRequestNone { pub kind: McpHeadersHandlePendingHeadersRefreshRequestNoneKind, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpHeadersHandlePendingHeadersRefreshRequestError { + /// Headers-refresh response variant discriminator. + pub kind: McpHeadersHandlePendingHeadersRefreshRequestErrorKind, + /// Host credential broker failure, denial, or revocation reason. + pub message: String, +} + /// MCP headers refresh request id and the host response. /// ///

@@ -8967,6 +8979,9 @@ pub struct McpSamplingExecutionResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpServer { + /// Human-readable display name supplied by a managed server catalog. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, /// Error message if the server failed to connect #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, @@ -16474,6 +16489,32 @@ pub struct SessionOpenOptionsAdditionalContentExclusionPolicy { pub scope: SessionOpenOptionsAdditionalContentExclusionPolicyScope, } +/// Non-secret host-managed HTTP MCP server configuration. The containing map key is the stable managed identity; credentials are supplied dynamically by the host. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedMcpServerConfig { + /// Human-readable catalog display name. + pub display_name: String, + /// Maximum dynamic-header cache lifetime in milliseconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub headers_refresh_ttl_ms: Option, + /// Timeout in milliseconds for tool discovery and tool calls. + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, + /// Tools to include. Defaults to all tools when omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + /// Hosted MCP streamable HTTP endpoint. + pub url: String, +} + /// Command-scoped GitHub credential injection for the shell commands an agent runs. /// /// Each channel is opt-in and independent, and injection is scoped to the individual command @@ -16720,6 +16761,16 @@ pub struct SessionOpenOptions { /// Identifier sent to LSP-style integrations. #[serde(skip_serializing_if = "Option::is_none")] pub lsp_client_name: Option, + /// Non-secret host-managed HTTP MCP servers keyed by stable managed identity. Managed provenance is runtime-established from this separate field and credentials are supplied through dynamic-header refresh. + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
+ #[serde(skip_serializing_if = "Option::is_none")] + pub managed_mcp_servers: Option>, /// Permissions-only enterprise policy injected by the SDK host at session create or resume. Composes restrictively with self-fetched and device policy and is not persisted. #[serde(skip_serializing_if = "Option::is_none")] pub managed_settings: Option, @@ -29828,6 +29879,14 @@ pub enum McpHeadersHandlePendingHeadersRefreshRequestNoneKind { None, } +/// Headers-refresh response variant discriminator. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum McpHeadersHandlePendingHeadersRefreshRequestErrorKind { + #[serde(rename = "error")] + #[default] + Error, +} + /// Host response: supply dynamic headers or decline this refresh. /// ///
@@ -29841,6 +29900,7 @@ pub enum McpHeadersHandlePendingHeadersRefreshRequestNoneKind { pub enum McpHeadersHandlePendingHeadersRefreshRequest { Headers(McpHeadersHandlePendingHeadersRefreshRequestHeaders), None(McpHeadersHandlePendingHeadersRefreshRequestNone), + Error(McpHeadersHandlePendingHeadersRefreshRequestError), } /// Whether a planned configuration change would create or modify an entry diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index a5b123d3b5..848fe0e401 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -5350,6 +5350,9 @@ pub struct SessionCustomAgentsUpdatedData { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpServersLoadedServer { + /// Human-readable display name supplied by a managed server catalog. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, /// Error message if the server failed to connect #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, @@ -7423,7 +7426,7 @@ pub enum SkillSource { Unknown, } -/// Configuration source: user, workspace, plugin, or builtin +/// Configuration source: user, workspace, plugin, builtin, or managed #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum McpServerSource { /// Server configured in the user's global MCP configuration. @@ -7438,6 +7441,9 @@ pub enum McpServerSource { /// Server bundled with the runtime. #[serde(rename = "builtin")] Builtin, + /// Server supplied by a trusted host-managed catalog. + #[serde(rename = "managed")] + Managed, /// Unknown variant for forward compatibility. #[default] #[serde(other)] diff --git a/rust/src/handler.rs b/rust/src/handler.rs index e036b75a10..fd112dc45e 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -2,7 +2,7 @@ //! //! Each callback the CLI may dispatch (permission requests, elicitation //! prompts, user-input questions, exit-plan-mode prompts, -//! auto-mode-switch prompts) has its own focused trait with a single +//! auto-mode-switch prompts, MCP credential requests) has its own focused trait with a single //! `handle` method. //! //! Handlers are **optional**: install only the ones the application cares @@ -15,17 +15,27 @@ //! [`Tool::with_handler`](crate::types::Tool::with_handler) on entries passed to //! [`SessionConfig::with_tools`](crate::types::SessionConfig::with_tools). +use std::collections::HashMap; + use async_trait::async_trait; use serde::{Deserialize, Serialize}; use crate::generated::api_types::{ - McpOauthPendingRequestResponse, McpOauthPendingRequestResponseCancelled, - McpOauthPendingRequestResponseCancelledKind, McpOauthPendingRequestResponseToken, - McpOauthPendingRequestResponseTokenKind, PermissionDecision, PermissionDecisionApproveOnce, - PermissionDecisionContext, PermissionDecisionReject, PermissionDecisionUserNotAvailable, + McpHeadersHandlePendingHeadersRefreshRequest, + McpHeadersHandlePendingHeadersRefreshRequestError, + McpHeadersHandlePendingHeadersRefreshRequestErrorKind, + McpHeadersHandlePendingHeadersRefreshRequestHeaders, + McpHeadersHandlePendingHeadersRefreshRequestHeadersKind, + McpHeadersHandlePendingHeadersRefreshRequestNone, + McpHeadersHandlePendingHeadersRefreshRequestNoneKind, McpOauthPendingRequestResponse, + McpOauthPendingRequestResponseCancelled, McpOauthPendingRequestResponseCancelledKind, + McpOauthPendingRequestResponseToken, McpOauthPendingRequestResponseTokenKind, + PermissionDecision, PermissionDecisionApproveOnce, PermissionDecisionContext, + PermissionDecisionReject, PermissionDecisionUserNotAvailable, }; use crate::session_events::{ - McpOauthRequestReason, McpOauthRequiredStaticClientConfig, McpOauthWWWAuthenticateParams, + McpHeadersRefreshRequiredReason, McpOauthRequestReason, McpOauthRequiredStaticClientConfig, + McpOauthWWWAuthenticateParams, }; use crate::types::{ ElicitationRequest, ElicitationResult, ExitPlanModeData, PermissionRequestData, RequestId, @@ -294,6 +304,75 @@ pub trait McpAuthHandler: Send + Sync + 'static { ) -> McpAuthResult; } +/// Dynamic-header request for a host-managed MCP server. +#[derive(Debug, Clone)] +pub struct McpHeadersRefreshRequest { + /// Display name of the MCP server that requires headers. + pub server_name: String, + /// URL of the MCP server that requires headers. + pub server_url: String, + /// Why the runtime is requesting fresh headers. + pub reason: McpHeadersRefreshRequiredReason, +} + +/// Result returned by an [`McpHeadersRefreshHandler`]. +#[derive(Debug, Clone)] +pub enum McpHeadersRefreshResult { + /// Supplies dynamic HTTP headers for the managed MCP server. + Headers { + /// Headers to overlay onto MCP requests. + headers: HashMap, + /// Optional lifetime in milliseconds for the returned headers. + ttl_ms: Option, + }, + /// Indicates that no dynamic headers are available. + None, +} + +impl McpHeadersRefreshResult { + pub(crate) fn into_wire(self) -> McpHeadersHandlePendingHeadersRefreshRequest { + match self { + Self::Headers { headers, ttl_ms } => { + McpHeadersHandlePendingHeadersRefreshRequest::Headers( + McpHeadersHandlePendingHeadersRefreshRequestHeaders { + headers, + kind: McpHeadersHandlePendingHeadersRefreshRequestHeadersKind::Headers, + ttl_ms, + }, + ) + } + Self::None => McpHeadersHandlePendingHeadersRefreshRequest::None( + McpHeadersHandlePendingHeadersRefreshRequestNone { + kind: McpHeadersHandlePendingHeadersRefreshRequestNoneKind::None, + }, + ), + } + } +} + +pub(crate) fn mcp_headers_error_result( + message: impl Into, +) -> McpHeadersHandlePendingHeadersRefreshRequest { + McpHeadersHandlePendingHeadersRefreshRequest::Error( + McpHeadersHandlePendingHeadersRefreshRequestError { + kind: McpHeadersHandlePendingHeadersRefreshRequestErrorKind::Error, + message: message.into(), + }, + ) +} + +/// Handler for dynamic headers requested by host-managed MCP servers. +#[async_trait] +pub trait McpHeadersRefreshHandler: Send + Sync + 'static { + /// Resolve a managed MCP headers refresh request. + async fn handle( + &self, + session_id: SessionId, + request_id: RequestId, + request: McpHeadersRefreshRequest, + ) -> Result; +} + /// Handler for `user_input.requested` events from the `ask_user` tool. /// /// When unset, `requestUserInput: false` goes on the wire and the diff --git a/rust/src/session.rs b/rust/src/session.rs index b9d2173055..87a4a5ca30 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -12,17 +12,20 @@ use tracing::{Instrument, warn}; use crate::canvas::CanvasHandler; use crate::generated::api_types::{ - LogRequest, ModelSwitchToRequest, OpenCanvasInstance, PermissionDecisionRequest, - RegisterEventInterestParams, ToolsGetCurrentMetadataResult, rpc_methods, + LogRequest, McpHeadersHandlePendingHeadersRefreshRequestRequest, ModelSwitchToRequest, + OpenCanvasInstance, PermissionDecisionRequest, RegisterEventInterestParams, + ToolsGetCurrentMetadataResult, rpc_methods, }; use crate::generated::session_events::{ - CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, McpOauthRequiredData, - SessionCanvasClosedData, SessionErrorData, SessionEventType, SessionIdleData, SessionMode, + CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, + McpHeadersRefreshRequiredData, McpOauthRequiredData, SessionCanvasClosedData, SessionErrorData, + SessionEventType, SessionIdleData, SessionMode, }; use crate::handler::{ AutoModeSwitchHandler, AutoModeSwitchResponse, ElicitationHandler, ExitPlanModeHandler, - McpAuthHandler, McpAuthRequest, McpAuthResult, PermissionHandler, PermissionResult, - UserInputHandler, UserInputResponse, + McpAuthHandler, McpAuthRequest, McpAuthResult, McpHeadersRefreshHandler, + McpHeadersRefreshRequest, PermissionHandler, PermissionResult, UserInputHandler, + UserInputResponse, mcp_headers_error_result, }; use crate::hooks::SessionHooks; use crate::provider_token::BearerTokenProvider; @@ -60,6 +63,7 @@ pub(crate) struct SessionHandlers { pub managed_settings_enabled: bool, pub elicitation: Option>, pub mcp_auth: Option>, + pub mcp_headers: Option>, pub user_input: Option>, pub exit_plan_mode: Option>, pub auto_mode_switch: Option>, @@ -924,6 +928,7 @@ impl Client { ), elicitation: runtime.elicitation_handler.take(), mcp_auth: runtime.mcp_auth_handler.take(), + mcp_headers: runtime.mcp_headers_handler.take(), user_input: runtime.user_input_handler.take(), exit_plan_mode: runtime.exit_plan_mode_handler.take(), auto_mode_switch: runtime.auto_mode_switch_handler.take(), @@ -946,6 +951,7 @@ impl Client { .as_ref() .map(|registration| registration.id().to_string()); let has_mcp_auth_handler = handlers.mcp_auth.is_some(); + let has_mcp_headers_handler = handlers.mcp_headers.is_some(); if self.inner.session_fs_configured && session_fs_provider.is_none() { return Err(ErrorKind::Session(SessionErrorKind::SessionFsProviderRequired).into()); } @@ -1072,6 +1078,8 @@ impl Client { event_tx.clone(), shutdown.clone(), ); + let mut registration = + PendingSessionRegistration::new(self.clone(), session_id.clone(), shutdown.clone()); tracing::debug!( elapsed_ms = setup_start.elapsed().as_millis(), session_id = %session_id, @@ -1081,8 +1089,16 @@ impl Client { "Client::create_session local setup complete" ); *capabilities.write() = create_result.capabilities.unwrap_or_default(); - if has_mcp_auth_handler { - register_mcp_auth_interest(self, &session_id).await?; + if let Err(error) = register_mcp_handler_interests( + self, + &session_id, + has_mcp_auth_handler, + has_mcp_headers_handler, + ) + .await + { + registration.cleanup(event_loop).await; + return Err(error); } tracing::debug!( @@ -1090,6 +1106,7 @@ impl Client { session_id = %session_id, "Client::create_session complete" ); + registration.disarm(); let session = Session { id: session_id, cwd: self.cwd().clone(), @@ -1212,6 +1229,7 @@ impl Client { ), elicitation: runtime.elicitation_handler.take(), mcp_auth: runtime.mcp_auth_handler.take(), + mcp_headers: runtime.mcp_headers_handler.take(), user_input: runtime.user_input_handler.take(), exit_plan_mode: runtime.exit_plan_mode_handler.take(), auto_mode_switch: runtime.auto_mode_switch_handler.take(), @@ -1234,6 +1252,7 @@ impl Client { .as_ref() .map(|registration| registration.id().to_string()); let has_mcp_auth_handler = handlers.mcp_auth.is_some(); + let has_mcp_headers_handler = handlers.mcp_headers.is_some(); if self.inner.session_fs_configured && session_fs_provider.is_none() { return Err(ErrorKind::Session(SessionErrorKind::SessionFsProviderRequired).into()); } @@ -1320,8 +1339,16 @@ impl Client { }) .into()); } - if has_mcp_auth_handler { - register_mcp_auth_interest(self, &session_id).await?; + if let Err(error) = register_mcp_handler_interests( + self, + &session_id, + has_mcp_auth_handler, + has_mcp_headers_handler, + ) + .await + { + registration.cleanup(event_loop).await; + return Err(error); } // Reload skills after resume (best-effort). @@ -1677,9 +1704,13 @@ fn permission_response_params( Some(params) } -async fn register_mcp_auth_interest(client: &Client, session_id: &SessionId) -> Result<(), Error> { +async fn register_mcp_event_interest( + client: &Client, + session_id: &SessionId, + event_type: &str, +) -> Result<(), Error> { let mut params = serde_json::to_value(RegisterEventInterestParams { - event_type: "mcp.oauth_required".to_string(), + event_type: event_type.to_string(), })?; params["sessionId"] = Value::String(session_id.to_string()); client @@ -1688,6 +1719,21 @@ async fn register_mcp_auth_interest(client: &Client, session_id: &SessionId) -> Ok(()) } +async fn register_mcp_handler_interests( + client: &Client, + session_id: &SessionId, + has_auth_handler: bool, + has_headers_handler: bool, +) -> Result<(), Error> { + if has_auth_handler { + register_mcp_event_interest(client, session_id, "mcp.oauth_required").await?; + } + if has_headers_handler { + register_mcp_event_interest(client, session_id, "mcp.headers_refresh_required").await?; + } + Ok(()) +} + fn tool_failure_result(message: impl Into) -> ToolResult { let message = message.into(); ToolResult::Expanded(ToolResultExpanded { @@ -2266,6 +2312,95 @@ async fn handle_notification( .instrument(span), ); } + SessionEventType::McpHeadersRefreshRequired => { + let Some(mcp_headers_handler) = handlers.mcp_headers.clone() else { + warn!( + session_id = %session_id, + "received MCP headers refresh request without a registered handler" + ); + return; + }; + let data: McpHeadersRefreshRequiredData = + match serde_json::from_value(notification.event.data.clone()) { + Ok(data) => data, + Err(error) => { + warn!(error = %error, "failed to deserialize MCP headers refresh request"); + return; + } + }; + let request_id = data.request_id; + let request = McpHeadersRefreshRequest { + server_name: data.server_name, + server_url: data.server_url, + reason: data.reason, + }; + let client = client.clone(); + let sid = session_id.clone(); + let span = tracing::error_span!( + "mcp_headers_request_handler", + session_id = %sid, + request_id = %request_id + ); + tokio::spawn( + async move { + let handler_task = tokio::spawn({ + let sid = sid.clone(); + let request_id = request_id.clone(); + let span = tracing::error_span!( + "mcp_headers_callback", + session_id = %sid, + request_id = %request_id + ); + async move { + let handler_start = Instant::now(); + let result = mcp_headers_handler + .handle(sid.clone(), request_id.clone(), request) + .await; + tracing::debug!( + elapsed_ms = handler_start.elapsed().as_millis(), + session_id = %sid, + request_id = %request_id, + "McpHeadersRefreshHandler::handle dispatch" + ); + result + } + .instrument(span) + }); + let result = match handler_task.await { + Ok(Ok(result)) => result.into_wire(), + Ok(Err(error)) => mcp_headers_error_result(error.to_string()), + Err(error) => mcp_headers_error_result(error.to_string()), + }; + let params = McpHeadersHandlePendingHeadersRefreshRequestRequest { + request_id: request_id.clone(), + result, + }; + let mut wire_params = match serde_json::to_value(params) { + Ok(params) => params, + Err(error) => { + warn!(error = %error, "failed to serialize MCP headers response"); + return; + } + }; + wire_params["sessionId"] = Value::String(sid.to_string()); + if let Err(error) = client + .call( + rpc_methods::SESSION_MCP_HEADERS_HANDLEPENDINGHEADERSREFRESHREQUEST, + Some(wire_params), + ) + .await + { + warn!( + error = %error, + session_id = %sid, + request_id = %request_id, + "failed to send MCP headers response" + ); + } + } + .instrument(span), + ); + } SessionEventType::CommandExecute => { let data: CommandExecuteData = match serde_json::from_value(notification.event.data.clone()) { diff --git a/rust/src/types.rs b/rust/src/types.rs index 6e451eb452..df5ab6de0a 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -20,6 +20,7 @@ pub use crate::copilot_request_handler::{ CopilotWebSocketForwarderBuilder, CopilotWebSocketHandler, CopilotWebSocketMessage, CopilotWebSocketResponse, WebSocketTransform, forward_http, }; +pub use crate::generated::api_types::ManagedMcpServerConfig; use crate::generated::api_types::{CurrentToolMetadata, OpenCanvasInstance}; use crate::generated::session_events::ReasoningSummary; /// Context window tier for models that support tiered context windows. @@ -27,7 +28,7 @@ pub use crate::generated::session_events::{ContextTier, SessionLimitsConfig}; use crate::github_token::GitHubTokenProvider; use crate::handler::{ AutoModeSwitchHandler, ElicitationHandler, ExitPlanModeHandler, McpAuthHandler, - PermissionHandler, UserInputHandler, + McpHeadersRefreshHandler, PermissionHandler, UserInputHandler, }; use crate::hooks::SessionHooks; use crate::provider_token::BearerTokenProvider; @@ -1962,6 +1963,11 @@ pub struct SessionConfig { pub included_builtin_skills: Option>, /// MCP server configurations passed through to the CLI. pub mcp_servers: Option>, + /// Non-secret host-managed HTTP MCP servers keyed by stable managed identity. + /// + /// Credentials are supplied dynamically through + /// [`McpHeadersRefreshHandler`](crate::handler::McpHeadersRefreshHandler). + pub managed_mcp_servers: Option>, /// Controls how MCP OAuth tokens are stored for this session. /// /// - `"persistent"` — tokens are stored in the OS keychain (shared across sessions). @@ -2181,6 +2187,8 @@ pub struct SessionConfig { /// Optional MCP OAuth request handler. When set, the SDK can satisfy MCP /// server OAuth requests with host-acquired token data or cancellation. pub mcp_auth_handler: Option>, + /// Optional dynamic-header handler for host-managed MCP servers. + pub mcp_headers_handler: Option>, /// Optional user-input handler. When `None`, /// `requestUserInput: false` goes on the wire and the `ask_user` /// tool is disabled. @@ -2254,6 +2262,7 @@ impl std::fmt::Debug for SessionConfig { .field("excluded_builtin_agents", &self.excluded_builtin_agents) .field("included_builtin_skills", &self.included_builtin_skills) .field("mcp_servers", &self.mcp_servers) + .field("managed_mcp_servers", &self.managed_mcp_servers) .field("mcp_oauth_token_storage", &self.mcp_oauth_token_storage) .field("embedding_cache_storage", &self.embedding_cache_storage) .field("enable_config_discovery", &self.enable_config_discovery) @@ -2338,6 +2347,10 @@ impl std::fmt::Debug for SessionConfig { "mcp_auth_handler", &self.mcp_auth_handler.as_ref().map(|_| ""), ) + .field( + "mcp_headers_handler", + &self.mcp_headers_handler.as_ref().map(|_| ""), + ) .field( "user_input_handler", &self.user_input_handler.as_ref().map(|_| ""), @@ -2391,6 +2404,7 @@ impl Default for SessionConfig { excluded_builtin_agents: None, included_builtin_skills: None, mcp_servers: None, + managed_mcp_servers: None, mcp_oauth_token_storage: None, enable_config_discovery: None, skip_embedding_retrieval: None, @@ -2441,6 +2455,7 @@ impl Default for SessionConfig { permission_handler: None, elicitation_handler: None, mcp_auth_handler: None, + mcp_headers_handler: None, user_input_handler: None, exit_plan_mode_handler: None, auto_mode_switch_handler: None, @@ -2466,6 +2481,7 @@ pub(crate) struct SessionConfigRuntime { pub permission_policy: Option, pub elicitation_handler: Option>, pub mcp_auth_handler: Option>, + pub mcp_headers_handler: Option>, pub user_input_handler: Option>, pub exit_plan_mode_handler: Option>, pub auto_mode_switch_handler: Option>, @@ -2557,6 +2573,7 @@ impl SessionConfig { excluded_builtin_agents: self.excluded_builtin_agents, tool_filter_precedence: "excluded", mcp_servers: self.mcp_servers, + managed_mcp_servers: self.managed_mcp_servers, mcp_oauth_token_storage: self.mcp_oauth_token_storage, embedding_cache_storage: self.embedding_cache_storage, env_value_mode: "direct", @@ -2619,6 +2636,7 @@ impl SessionConfig { permission_policy: self.permission_policy, elicitation_handler: self.elicitation_handler, mcp_auth_handler: self.mcp_auth_handler, + mcp_headers_handler: self.mcp_headers_handler, user_input_handler: self.user_input_handler, exit_plan_mode_handler: self.exit_plan_mode_handler, auto_mode_switch_handler: self.auto_mode_switch_handler, @@ -2656,6 +2674,12 @@ impl SessionConfig { self } + /// Install an [`McpHeadersRefreshHandler`] for host-managed MCP dynamic headers. + pub fn with_mcp_headers_handler(mut self, handler: Arc) -> Self { + self.mcp_headers_handler = Some(handler); + self + } + /// Install a [`UserInputHandler`]. Required for the `ask_user` tool /// to be enabled. pub fn with_user_input_handler(mut self, handler: Arc) -> Self { @@ -2880,6 +2904,15 @@ impl SessionConfig { self } + /// Set host-managed HTTP MCP server configurations. + pub fn with_managed_mcp_servers( + mut self, + servers: HashMap, + ) -> Self { + self.managed_mcp_servers = Some(servers); + self + } + /// Set MCP OAuth token storage mode. /// /// - `"persistent"` — tokens stored in the OS keychain. @@ -3342,6 +3375,8 @@ pub struct ResumeSessionConfig { pub included_builtin_skills: Option>, /// Re-supply MCP servers so they remain available after app restart. pub mcp_servers: Option>, + /// Re-supply host-managed HTTP MCP servers after app restart. + pub managed_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, @@ -3506,6 +3541,8 @@ pub struct ResumeSessionConfig { pub elicitation_handler: Option>, /// Optional MCP OAuth handler. See [`SessionConfig::mcp_auth_handler`]. pub mcp_auth_handler: Option>, + /// Optional managed MCP dynamic-header handler. + pub mcp_headers_handler: Option>, /// Optional user-input handler. See /// [`SessionConfig::user_input_handler`]. pub user_input_handler: Option>, @@ -3564,6 +3601,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("excluded_builtin_agents", &self.excluded_builtin_agents) .field("included_builtin_skills", &self.included_builtin_skills) .field("mcp_servers", &self.mcp_servers) + .field("managed_mcp_servers", &self.managed_mcp_servers) .field("mcp_oauth_token_storage", &self.mcp_oauth_token_storage) .field("embedding_cache_storage", &self.embedding_cache_storage) .field("enable_config_discovery", &self.enable_config_discovery) @@ -3643,6 +3681,10 @@ impl std::fmt::Debug for ResumeSessionConfig { "elicitation_handler", &self.elicitation_handler.as_ref().map(|_| ""), ) + .field( + "mcp_headers_handler", + &self.mcp_headers_handler.as_ref().map(|_| ""), + ) .field( "user_input_handler", &self.user_input_handler.as_ref().map(|_| ""), @@ -3743,6 +3785,7 @@ impl ResumeSessionConfig { excluded_builtin_agents: self.excluded_builtin_agents, tool_filter_precedence: "excluded", mcp_servers: self.mcp_servers, + managed_mcp_servers: self.managed_mcp_servers, mcp_oauth_token_storage: self.mcp_oauth_token_storage, embedding_cache_storage: self.embedding_cache_storage, env_value_mode: "direct", @@ -3806,6 +3849,7 @@ impl ResumeSessionConfig { permission_policy: self.permission_policy, elicitation_handler: self.elicitation_handler, mcp_auth_handler: self.mcp_auth_handler, + mcp_headers_handler: self.mcp_headers_handler, user_input_handler: self.user_input_handler, exit_plan_mode_handler: self.exit_plan_mode_handler, auto_mode_switch_handler: self.auto_mode_switch_handler, @@ -3850,6 +3894,7 @@ impl ResumeSessionConfig { excluded_builtin_agents: None, included_builtin_skills: None, mcp_servers: None, + managed_mcp_servers: None, mcp_oauth_token_storage: None, enable_config_discovery: None, skip_embedding_retrieval: None, @@ -3901,6 +3946,7 @@ impl ResumeSessionConfig { permission_handler: None, elicitation_handler: None, mcp_auth_handler: None, + mcp_headers_handler: None, user_input_handler: None, exit_plan_mode_handler: None, auto_mode_switch_handler: None, @@ -3933,6 +3979,12 @@ impl ResumeSessionConfig { self } + /// Install an [`McpHeadersRefreshHandler`] for host-managed MCP dynamic headers. + pub fn with_mcp_headers_handler(mut self, handler: Arc) -> Self { + self.mcp_headers_handler = Some(handler); + self + } + /// Install a [`UserInputHandler`] for the resumed session. pub fn with_user_input_handler(mut self, handler: Arc) -> Self { self.user_input_handler = Some(handler); @@ -4149,6 +4201,15 @@ impl ResumeSessionConfig { self } + /// Re-supply host-managed HTTP MCP server configurations on resume. + pub fn with_managed_mcp_servers( + mut self, + servers: HashMap, + ) -> Self { + self.managed_mcp_servers = Some(servers); + 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 { @@ -5969,11 +6030,12 @@ mod tests { AttachmentSelectionRange, AzureProviderOptions, CapiSessionOptions, ConnectionState, CopilotExpAssignmentResponse, CustomAgentConfig, DeliveryMode, ExpConfigEntry, ExpFlagValue, ExtensionInfo, GitHubMcpToolConfig, GitHubReferenceType, - InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, McpStdioServerConfig, - MemoryConfiguration, NamedProviderConfig, PermissionResponseCapability, ProviderConfig, - ProviderModelConfig, ReasoningSummary, ResumeSessionConfig, SessionConfig, SessionEvent, - SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, - ToolResultResponse, ensure_attachment_display_names, + InfiniteSessionConfig, LargeToolOutputConfig, ManagedMcpServerConfig, McpServerConfig, + McpStdioServerConfig, MemoryConfiguration, NamedProviderConfig, + PermissionResponseCapability, ProviderConfig, ProviderModelConfig, ReasoningSummary, + ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, SystemMessageConfig, Tool, + ToolBinaryResult, ToolResult, ToolResultExpanded, ToolResultResponse, + ensure_attachment_display_names, }; use crate::generated::session_events::TypedSessionEvent; @@ -6257,6 +6319,46 @@ mod tests { assert!(!wire.request_mcp_apps); } + #[test] + fn managed_mcp_servers_serialize_on_create_and_resume() { + let servers = HashMap::from([( + "catalog-id".to_string(), + ManagedMcpServerConfig { + display_name: "Managed server".to_string(), + headers_refresh_ttl_ms: Some(30_000), + timeout: Some(15_000), + tools: Some(vec!["search".to_string()]), + url: "https://mcp.example.test".to_string(), + }, + )]); + + let (create_wire, _) = SessionConfig::default() + .with_managed_mcp_servers(servers.clone()) + .into_wire(Some(SessionId::from("managed-create"))) + .expect("create config has no duplicate handlers"); + let create_json = serde_json::to_value(create_wire).unwrap(); + assert_eq!( + create_json["managedMcpServers"]["catalog-id"], + json!({ + "displayName": "Managed server", + "headersRefreshTtlMs": 30_000, + "timeout": 15_000, + "tools": ["search"], + "url": "https://mcp.example.test" + }) + ); + + let (resume_wire, _) = ResumeSessionConfig::new(SessionId::from("managed-resume")) + .with_managed_mcp_servers(servers) + .into_wire() + .expect("resume config has no duplicate handlers"); + let resume_json = serde_json::to_value(resume_wire).unwrap(); + assert_eq!( + resume_json["managedMcpServers"]["catalog-id"]["displayName"], + "Managed server" + ); + } + #[test] fn custom_agents_local_only_serializes_on_create_and_resume() { let (create_wire, _) = SessionConfig::default() diff --git a/rust/src/wire.rs b/rust/src/wire.rs index f7de33839c..1ecad408e9 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -13,6 +13,7 @@ //! configs hold trait-object handlers, the wire structs hold only the //! plain data the runtime needs. +use std::collections::HashMap; use std::path::PathBuf; use indexmap::IndexMap; @@ -20,7 +21,7 @@ use serde::Serialize; use crate::canvas::CanvasDeclaration; use crate::generated::api_types::{ - ModelCapabilitiesOverride, OpenCanvasInstance, RemoteSessionMode, + ManagedMcpServerConfig, ModelCapabilitiesOverride, OpenCanvasInstance, RemoteSessionMode, }; use crate::generated::session_events::ReasoningSummary; use crate::types::{ @@ -89,6 +90,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub managed_mcp_servers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub mcp_oauth_token_storage: Option, #[serde(skip_serializing_if = "Option::is_none")] pub embedding_cache_storage: Option, @@ -244,6 +247,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub managed_mcp_servers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub mcp_oauth_token_storage: Option, #[serde(skip_serializing_if = "Option::is_none")] pub embedding_cache_storage: Option, diff --git a/rust/tests/e2e/rpc_tasks_and_handlers.rs b/rust/tests/e2e/rpc_tasks_and_handlers.rs index 540238ddd0..d0de7a237c 100644 --- a/rust/tests/e2e/rpc_tasks_and_handlers.rs +++ b/rust/tests/e2e/rpc_tasks_and_handlers.rs @@ -428,6 +428,7 @@ async fn should_return_expected_results_for_missing_pending_handler_requestids() "missing".to_string(), )]), kind: McpHeadersHandlePendingHeadersRefreshRequestHeadersKind::Headers, + ttl_ms: None, }, ), }, diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index a51d61910d..45b7cf503f 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -13,15 +14,16 @@ use github_copilot_sdk::github_token::{ use github_copilot_sdk::handler::{ ApproveAllHandler, AutoModeSwitchHandler, AutoModeSwitchResponse, ElicitationHandler, ExitPlanModeHandler, ExitPlanModeResult, McpAuthHandler, McpAuthRequest, McpAuthResult, - PermissionHandler, PermissionResult, UserInputHandler, UserInputResponse, + McpHeadersRefreshHandler, McpHeadersRefreshRequest, McpHeadersRefreshResult, PermissionHandler, + PermissionResult, UserInputHandler, UserInputResponse, }; use github_copilot_sdk::rpc::{ CanvasProviderInvokeActionRequest, CanvasProviderOpenRequest, CanvasProviderOpenResult, OpenCanvasInstance, }; use github_copilot_sdk::session_events::{ - ManagedSettingsResolvedSource, McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig, - SessionManagedSettingsResolvedData, + ManagedSettingsResolvedSource, McpHeadersRefreshRequiredReason, McpOauthRequiredData, + ReasoningSummary, SessionLimitsConfig, SessionManagedSettingsResolvedData, }; use github_copilot_sdk::types::{ CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, CommandContext, @@ -42,6 +44,10 @@ struct TestCanvasHandler; struct CancelMcpAuthHandler; +struct TestMcpHeadersHandler { + calls: tokio::sync::mpsc::UnboundedSender<(SessionId, RequestId, McpHeadersRefreshRequest)>, +} + struct ContextualApproveHandler; #[async_trait] @@ -73,6 +79,41 @@ impl McpAuthHandler for CancelMcpAuthHandler { } } +#[async_trait] +impl McpHeadersRefreshHandler for TestMcpHeadersHandler { + async fn handle( + &self, + session_id: SessionId, + request_id: RequestId, + request: McpHeadersRefreshRequest, + ) -> Result { + self.calls + .send((session_id, request_id, request.clone())) + .unwrap(); + match request.server_name.as_str() { + "ttl-absent" => Ok(McpHeadersRefreshResult::Headers { + headers: HashMap::from([( + "Authorization".to_string(), + "Bearer absent".to_string(), + )]), + ttl_ms: None, + }), + "ttl-present" => Ok(McpHeadersRefreshResult::Headers { + headers: HashMap::from([( + "Authorization".to_string(), + "Bearer present".to_string(), + )]), + ttl_ms: Some(12_345), + }), + "none" => Ok(McpHeadersRefreshResult::None), + _ => Err(github_copilot_sdk::Error::with_message( + ErrorKind::InvalidConfig, + "credential broker unavailable", + )), + } + } +} + #[async_trait] impl CanvasHandler for TestCanvasHandler { async fn on_open( @@ -741,6 +782,197 @@ async fn resume_session_registers_mcp_auth_interest_only_with_handler() { let _session = timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); } +#[tokio::test] +async fn managed_mcp_headers_handler_dispatches_all_results() { + let (calls_tx, mut calls_rx) = tokio::sync::mpsc::unbounded_channel(); + let handler = Arc::new(TestMcpHeadersHandler { calls: calls_tx }); + let (client, server_read, server_write) = make_client(); + let mut server = FakeServer { + read: server_read, + write: server_write, + session_id: "managed-mcp-session".to_string(), + }; + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session( + SessionConfig::default() + .with_session_id("managed-mcp-session") + .with_mcp_headers_handler(handler), + ) + .await + .unwrap() + } + }); + + let create_request = server.read_request().await; + assert_eq!(create_request["method"], "session.create"); + server + .respond( + &create_request, + serde_json::json!({ + "sessionId": "managed-mcp-session", + "workspacePath": "/workspace" + }), + ) + .await; + let interest_request = server.read_request().await; + assert_eq!( + interest_request["method"], + "session.eventLog.registerInterest" + ); + assert_eq!( + interest_request["params"]["eventType"], + "mcp.headers_refresh_required" + ); + server + .respond(&interest_request, serde_json::json!({ "id": "interest-1" })) + .await; + let _session = timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); + + let cases = [ + ( + "ttl-absent", + "startup", + McpHeadersRefreshRequiredReason::Startup, + serde_json::json!({ + "kind": "headers", + "headers": { "Authorization": "Bearer absent" } + }), + ), + ( + "ttl-present", + "ttl-expired", + McpHeadersRefreshRequiredReason::TtlExpired, + serde_json::json!({ + "kind": "headers", + "headers": { "Authorization": "Bearer present" }, + "ttlMs": 12_345 + }), + ), + ( + "none", + "auth-failed", + McpHeadersRefreshRequiredReason::AuthFailed, + serde_json::json!({ "kind": "none" }), + ), + ( + "error", + "startup", + McpHeadersRefreshRequiredReason::Startup, + serde_json::json!({ + "kind": "error", + "message": "credential broker unavailable" + }), + ), + ]; + + for (index, (server_name, reason, expected_reason, expected_result)) in + cases.into_iter().enumerate() + { + let request_id = format!("headers-{index}"); + let server_url = format!("https://{server_name}.example.test/mcp"); + server + .send_event( + "mcp.headers_refresh_required", + serde_json::json!({ + "requestId": request_id, + "serverName": server_name, + "serverUrl": server_url, + "reason": reason + }), + ) + .await; + + let response_request = timeout(TIMEOUT, server.read_request()).await.unwrap(); + assert_eq!( + response_request["method"], + "session.mcp.headers.handlePendingHeadersRefreshRequest" + ); + assert_eq!(response_request["params"]["requestId"], request_id); + assert_eq!(response_request["params"]["result"], expected_result); + + let (received_session_id, received_request_id, received_request) = + timeout(TIMEOUT, calls_rx.recv()).await.unwrap().unwrap(); + assert_eq!(received_session_id.as_str(), "managed-mcp-session"); + assert_eq!(received_request_id, request_id); + assert_eq!(received_request.server_name, server_name); + assert_eq!(received_request.server_url, server_url); + assert_eq!(received_request.reason, expected_reason); + + server + .respond(&response_request, serde_json::json!({ "success": true })) + .await; + } +} + +#[tokio::test] +async fn managed_mcp_interest_failure_cleans_up_session_dispatch() { + let (calls_tx, mut calls_rx) = tokio::sync::mpsc::unbounded_channel(); + let handler = Arc::new(TestMcpHeadersHandler { calls: calls_tx }); + let (client, server_read, server_write) = make_client(); + let mut server = FakeServer { + read: server_read, + write: server_write, + session_id: "managed-mcp-interest-failure".to_string(), + }; + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session( + SessionConfig::default() + .with_session_id("managed-mcp-interest-failure") + .with_mcp_headers_handler(handler), + ) + .await + } + }); + + let create_request = server.read_request().await; + server + .respond( + &create_request, + serde_json::json!({ + "sessionId": "managed-mcp-interest-failure", + "workspacePath": "/workspace" + }), + ) + .await; + let interest_request = server.read_request().await; + assert_eq!( + interest_request["method"], + "session.eventLog.registerInterest" + ); + assert_eq!( + interest_request["params"]["eventType"], + "mcp.headers_refresh_required" + ); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": interest_request["id"], + "error": { "code": -32603, "message": "registration failed" } + }); + write_framed(&mut server.write, &serde_json::to_vec(&response).unwrap()).await; + + let result = timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); + assert!(result.is_err()); + + server + .send_event( + "mcp.headers_refresh_required", + serde_json::json!({ + "requestId": "stale-request", + "serverName": "ttl-absent", + "serverUrl": "https://example.test/mcp", + "reason": "startup" + }), + ) + .await; + assert!(timeout(TIMEOUT, calls_rx.recv()).await.unwrap().is_none()); +} + async fn server_respond_create( writer: &mut (impl AsyncWrite + Unpin), request: &Value,