diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a67eb96817..9fbe8c5a72 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -630,6 +630,7 @@ private CopilotSession InitializeSession( this); session.RegisterTools(config.Tools ?? []); session.RegisterPermissionHandler(config.OnPermissionRequest); + session.RegisterMcpAuthHandler(config.OnMcpAuthRequest); session.RegisterCommands(config.Commands); session.RegisterElicitationHandler(config.OnElicitationRequest); session.RegisterExitPlanModeHandler(config.OnExitPlanModeRequest); @@ -1080,6 +1081,11 @@ public async Task CreateSessionAsync(SessionConfig config, Cance $"session.create returned sessionId {response.SessionId} but the caller requested {localSessionId}."); } + if (config.OnMcpAuthRequest is not null) + { + await session.Rpc.EventLog.RegisterInterestAsync("mcp.oauth_required", cancellationToken); + } + session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); session.SetOpenCanvases(response.OpenCanvases); @@ -1166,6 +1172,10 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes transformCallbacks, hasHooks, "CopilotClient.ResumeSessionAsync"); + if (config.OnMcpAuthRequest is not null) + { + await session.Rpc.EventLog.RegisterInterestAsync("mcp.oauth_required", cancellationToken); + } try { diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 3a9fcf9cde..c5724b2217 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -149,6 +149,10 @@ public sealed class ModelBillingTokenPrices /// Billing information. public sealed class ModelBilling { + /// Whole-number percentage discount (0-100) applied to usage billed through this model. Populated for the synthetic `auto` model, where requests routed by auto-mode are billed at a reduced rate; absent for concrete models. + [JsonPropertyName("discountPercent")] + public int? DiscountPercent { get; set; } + /// Billing cost multiplier relative to the base rate. [JsonPropertyName("multiplier")] public double? Multiplier { get; set; } @@ -5612,11 +5616,6 @@ public partial class McpOauthPendingRequestResponseToken : McpOauthPendingReques [JsonPropertyName("expiresIn")] public long? ExpiresIn { get; set; } - /// Refresh token supplied by the host, if available. - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("refreshToken")] - public string? RefreshToken { get; set; } - /// OAuth token type. Defaults to Bearer when omitted. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("tokenType")] @@ -5704,6 +5703,70 @@ internal sealed class McpOauthLoginRequest public string SessionId { get; set; } = string.Empty; } +/// Indicates whether the pending MCP headers refresh response was accepted. +[Experimental(Diagnostics.Experimental)] +public sealed class McpHeadersHandlePendingHeadersRefreshRequestResult +{ + /// Whether the response was accepted. False if the request was unknown, timed out, or already resolved. + [JsonPropertyName("success")] + public bool Success { get; set; } +} + +/// Host response: supply dynamic headers or decline this refresh. +/// Polymorphic base type discriminated by kind. +[Experimental(Diagnostics.Experimental)] +[JsonPolymorphic( + TypeDiscriminatorPropertyName = "kind", + UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)] +[JsonDerivedType(typeof(McpHeadersHandlePendingHeadersRefreshRequestHeaders), "headers")] +[JsonDerivedType(typeof(McpHeadersHandlePendingHeadersRefreshRequestNone), "none")] +public partial class McpHeadersHandlePendingHeadersRefreshRequest +{ + /// The type discriminator. + [JsonPropertyName("kind")] + public virtual string Kind { get; set; } = string.Empty; +} + + +/// The headers variant of . +[Experimental(Diagnostics.Experimental)] +public partial class McpHeadersHandlePendingHeadersRefreshRequestHeaders : McpHeadersHandlePendingHeadersRefreshRequest +{ + /// + [JsonIgnore] + public override string Kind => "headers"; + + /// 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; } +} + +/// The none variant of . +[Experimental(Diagnostics.Experimental)] +public partial class McpHeadersHandlePendingHeadersRefreshRequestNone : McpHeadersHandlePendingHeadersRefreshRequest +{ + /// + [JsonIgnore] + public override string Kind => "none"; +} + +/// MCP headers refresh request id and the host response. +[Experimental(Diagnostics.Experimental)] +internal sealed class McpHeadersHandlePendingHeadersRefreshRequestRequest +{ + /// Headers refresh request identifier from mcp.headers_refresh_required. + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = string.Empty; + + /// Host response: supply dynamic headers or decline this refresh. + [JsonPropertyName("result")] + public McpHeadersHandlePendingHeadersRefreshRequest Result { get => field ??= new(); set; } + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// Schema for the `McpAppsResourceContent` type. [Experimental(Diagnostics.Experimental)] public sealed class McpAppsResourceContent @@ -6552,6 +6615,10 @@ internal sealed class SessionUpdateOptionsParams [JsonPropertyName("agentContext")] public string? AgentContext { get; set; } + /// Whether to include instructions from every MCP server in the system prompt instead of only allowlisted servers. + [JsonPropertyName("allowAllMcpServerInstructions")] + public bool? AllowAllMcpServerInstructions { get; set; } + /// Whether to disable the `ask_user` tool (encourages autonomous behavior). [JsonPropertyName("askUserDisabled")] public bool? AskUserDisabled { get; set; } @@ -6696,6 +6763,10 @@ internal sealed class SessionUpdateOptionsParams [JsonPropertyName("reasoningSummary")] public OptionsUpdateReasoningSummary? ReasoningSummary { get; set; } + /// Optional experimental response budget limits. Pass null to clear the response budget. + [JsonPropertyName("responseBudget")] + public ResponseBudgetConfig? ResponseBudget { get; set; } + /// Whether the session is running in an interactive UI. [JsonPropertyName("runningInInteractiveMode")] public bool? RunningInInteractiveMode { get; set; } @@ -7207,6 +7278,14 @@ public sealed class UpdateSubagentSettingsRequestSubagents /// Names of subagents the user has turned off; they cannot be dispatched. [JsonPropertyName("disabledSubagents")] public IList? DisabledSubagents { get; set; } + + /// Maximum number of subagents that can run concurrently; applies to usage-based billing users only. + [JsonPropertyName("maxConcurrency")] + public int? MaxConcurrency { get; set; } + + /// Maximum subagent nesting depth; applies to usage-based billing users only. + [JsonPropertyName("maxDepth")] + public int? MaxDepth { get; set; } } /// Subagent settings to apply to the current session. @@ -7327,7 +7406,7 @@ internal sealed class CommandsListRequestWithSession public string SessionId { get; set; } = string.Empty; } -/// Result of invoking the slash command (text output, prompt to send to the agent, or completion). +/// Result of invoking the slash command (text output, prompt to send to the agent, completion, or subcommand selection). /// Polymorphic base type discriminated by kind. [Experimental(Diagnostics.Experimental)] [JsonPolymorphic( @@ -19139,6 +19218,12 @@ public async Task IsServerRunningAsync(string serverNa Interlocked.CompareExchange(ref field, new(_session), null) ?? field; + /// Headers APIs. + public McpHeadersApi Headers => + field ?? + Interlocked.CompareExchange(ref field, new(_session), null) ?? + field; + /// Apps APIs. public McpAppsApi Apps => field ?? @@ -19207,6 +19292,33 @@ public async Task LoginAsync(string serverName, bool? force } } +/// Provides session-scoped McpHeaders APIs. +[Experimental(Diagnostics.Experimental)] +public sealed class McpHeadersApi +{ + private readonly CopilotSession _session; + + internal McpHeadersApi(CopilotSession session) + { + _session = session; + } + + /// Responds to a pending MCP dynamic headers refresh request. Hosts that subscribe to `mcp.headers_refresh_required` use this to provide short-lived per-server headers or to indicate that no dynamic headers are available for this refresh. + /// Headers refresh request identifier from mcp.headers_refresh_required. + /// Host response: supply dynamic headers or decline this refresh. + /// The to monitor for cancellation requests. The default is . + /// Indicates whether the pending MCP headers refresh response was accepted. + public async Task HandlePendingHeadersRefreshRequestAsync(string requestId, McpHeadersHandlePendingHeadersRefreshRequest result, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(requestId); + ArgumentNullException.ThrowIfNull(result); + _session.ThrowIfDisposed(); + + var request = new McpHeadersHandlePendingHeadersRefreshRequestRequest { SessionId = _session.SessionId, RequestId = requestId, Result = result }; + return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.mcp.headers.handlePendingHeadersRefreshRequest", [request], cancellationToken); + } +} + /// Provides session-scoped McpApps APIs. [Experimental(Diagnostics.Experimental)] public sealed class McpAppsApi @@ -19407,6 +19519,7 @@ internal OptionsApi(CopilotSession session) /// Resolved sandbox configuration. /// Whether interactive shell sessions are logged. /// How env values are passed to MCP servers (`direct` inlines literal values; `indirect` resolves at launch). + /// Whether to include instructions from every MCP server in the system prompt instead of only allowlisted servers. /// Additional directories to search for skills. /// Skill IDs that should be excluded from this session. /// Whether to discover custom instructions on demand after successful file views (AGENTS.md / CLAUDE.md / .github/copilot-instructions.md surfacing). Combined with `skipCustomInstructions` and the runtime-side `ON_DEMAND_INSTRUCTIONS` feature flag. @@ -19436,13 +19549,14 @@ internal OptionsApi(CopilotSession session) /// Whether to enable cross-session store writes and reads. /// Whether to enable skill directory scanning and loading. Falls back to enableConfigDiscovery when unset. /// Context tier for models with tiered pricing. The session uses this to derive effective `modelCapabilitiesOverrides` so compaction, truncation, token display, and request limits honor the selected tier. + /// Optional experimental response budget limits. Pass null to clear the response budget. /// The to monitor for cancellation requests. The default is . /// Indicates whether the session options patch was applied successfully. - public async Task UpdateAsync(string? model = null, ModelCapabilitiesOverride? modelCapabilitiesOverrides = null, string? reasoningEffort = null, OptionsUpdateReasoningSummary? reasoningSummary = null, string? clientName = null, string? lspClientName = null, string? integrationId = null, IDictionary? featureFlags = null, bool? isExperimentalMode = null, ProviderConfig? provider = null, CapiSessionOptions? capi = null, string? workingDirectory = null, IList? availableTools = null, IList? excludedTools = null, OptionsUpdateToolFilterPrecedence? toolFilterPrecedence = null, bool? enableScriptSafety = null, string? shellInitProfile = null, IList? shellProcessFlags = null, SandboxConfig? sandboxConfig = null, bool? logInteractiveShells = null, OptionsUpdateEnvValueMode? envValueMode = null, IList? skillDirectories = null, IList? disabledSkills = null, bool? enableOnDemandInstructionDiscovery = null, long? maxInlineBinaryBytes = null, IList? installedPlugins = null, bool? customAgentsLocalOnly = null, bool? suppressCustomAgentPrompt = null, bool? skipCustomInstructions = null, IList? disabledInstructionSources = null, bool? coauthorEnabled = null, string? trajectoryFile = null, bool? enableStreaming = null, string? copilotUrl = null, bool? askUserDisabled = null, bool? continueOnAutoMode = null, bool? runningInInteractiveMode = null, bool? enableReasoningSummaries = null, string? agentContext = null, string? eventsLogDirectory = null, IList? additionalContentExclusionPolicies = null, bool? manageScheduleEnabled = null, IList? sessionCapabilities = null, bool? skipEmbeddingRetrieval = null, string? organizationCustomInstructions = null, bool? enableFileHooks = null, bool? enableHostGitOperations = null, bool? enableSessionStore = null, bool? enableSkills = null, OptionsUpdateContextTier? contextTier = null, CancellationToken cancellationToken = default) + public async Task UpdateAsync(string? model = null, ModelCapabilitiesOverride? modelCapabilitiesOverrides = null, string? reasoningEffort = null, OptionsUpdateReasoningSummary? reasoningSummary = null, string? clientName = null, string? lspClientName = null, string? integrationId = null, IDictionary? featureFlags = null, bool? isExperimentalMode = null, ProviderConfig? provider = null, CapiSessionOptions? capi = null, string? workingDirectory = null, IList? availableTools = null, IList? excludedTools = null, OptionsUpdateToolFilterPrecedence? toolFilterPrecedence = null, bool? enableScriptSafety = null, string? shellInitProfile = null, IList? shellProcessFlags = null, SandboxConfig? sandboxConfig = null, bool? logInteractiveShells = null, OptionsUpdateEnvValueMode? envValueMode = null, bool? allowAllMcpServerInstructions = null, IList? skillDirectories = null, IList? disabledSkills = null, bool? enableOnDemandInstructionDiscovery = null, long? maxInlineBinaryBytes = null, IList? installedPlugins = null, bool? customAgentsLocalOnly = null, bool? suppressCustomAgentPrompt = null, bool? skipCustomInstructions = null, IList? disabledInstructionSources = null, bool? coauthorEnabled = null, string? trajectoryFile = null, bool? enableStreaming = null, string? copilotUrl = null, bool? askUserDisabled = null, bool? continueOnAutoMode = null, bool? runningInInteractiveMode = null, bool? enableReasoningSummaries = null, string? agentContext = null, string? eventsLogDirectory = null, IList? additionalContentExclusionPolicies = null, bool? manageScheduleEnabled = null, IList? sessionCapabilities = null, bool? skipEmbeddingRetrieval = null, string? organizationCustomInstructions = null, bool? enableFileHooks = null, bool? enableHostGitOperations = null, bool? enableSessionStore = null, bool? enableSkills = null, OptionsUpdateContextTier? contextTier = null, ResponseBudgetConfig? responseBudget = null, CancellationToken cancellationToken = default) { _session.ThrowIfDisposed(); - var request = new SessionUpdateOptionsParams { SessionId = _session.SessionId, Model = model, ModelCapabilitiesOverrides = modelCapabilitiesOverrides, ReasoningEffort = reasoningEffort, ReasoningSummary = reasoningSummary, ClientName = clientName, LspClientName = lspClientName, IntegrationId = integrationId, FeatureFlags = featureFlags, IsExperimentalMode = isExperimentalMode, Provider = provider, Capi = capi, WorkingDirectory = workingDirectory, AvailableTools = availableTools, ExcludedTools = excludedTools, ToolFilterPrecedence = toolFilterPrecedence, EnableScriptSafety = enableScriptSafety, ShellInitProfile = shellInitProfile, ShellProcessFlags = shellProcessFlags, SandboxConfig = sandboxConfig, LogInteractiveShells = logInteractiveShells, EnvValueMode = envValueMode, SkillDirectories = skillDirectories, DisabledSkills = disabledSkills, EnableOnDemandInstructionDiscovery = enableOnDemandInstructionDiscovery, MaxInlineBinaryBytes = maxInlineBinaryBytes, InstalledPlugins = installedPlugins, CustomAgentsLocalOnly = customAgentsLocalOnly, SuppressCustomAgentPrompt = suppressCustomAgentPrompt, SkipCustomInstructions = skipCustomInstructions, DisabledInstructionSources = disabledInstructionSources, CoauthorEnabled = coauthorEnabled, TrajectoryFile = trajectoryFile, EnableStreaming = enableStreaming, CopilotUrl = copilotUrl, AskUserDisabled = askUserDisabled, ContinueOnAutoMode = continueOnAutoMode, RunningInInteractiveMode = runningInInteractiveMode, EnableReasoningSummaries = enableReasoningSummaries, AgentContext = agentContext, EventsLogDirectory = eventsLogDirectory, AdditionalContentExclusionPolicies = additionalContentExclusionPolicies, ManageScheduleEnabled = manageScheduleEnabled, SessionCapabilities = sessionCapabilities, SkipEmbeddingRetrieval = skipEmbeddingRetrieval, OrganizationCustomInstructions = organizationCustomInstructions, EnableFileHooks = enableFileHooks, EnableHostGitOperations = enableHostGitOperations, EnableSessionStore = enableSessionStore, EnableSkills = enableSkills, ContextTier = contextTier }; + var request = new SessionUpdateOptionsParams { SessionId = _session.SessionId, Model = model, ModelCapabilitiesOverrides = modelCapabilitiesOverrides, ReasoningEffort = reasoningEffort, ReasoningSummary = reasoningSummary, ClientName = clientName, LspClientName = lspClientName, IntegrationId = integrationId, FeatureFlags = featureFlags, IsExperimentalMode = isExperimentalMode, Provider = provider, Capi = capi, WorkingDirectory = workingDirectory, AvailableTools = availableTools, ExcludedTools = excludedTools, ToolFilterPrecedence = toolFilterPrecedence, EnableScriptSafety = enableScriptSafety, ShellInitProfile = shellInitProfile, ShellProcessFlags = shellProcessFlags, SandboxConfig = sandboxConfig, LogInteractiveShells = logInteractiveShells, EnvValueMode = envValueMode, AllowAllMcpServerInstructions = allowAllMcpServerInstructions, SkillDirectories = skillDirectories, DisabledSkills = disabledSkills, EnableOnDemandInstructionDiscovery = enableOnDemandInstructionDiscovery, MaxInlineBinaryBytes = maxInlineBinaryBytes, InstalledPlugins = installedPlugins, CustomAgentsLocalOnly = customAgentsLocalOnly, SuppressCustomAgentPrompt = suppressCustomAgentPrompt, SkipCustomInstructions = skipCustomInstructions, DisabledInstructionSources = disabledInstructionSources, CoauthorEnabled = coauthorEnabled, TrajectoryFile = trajectoryFile, EnableStreaming = enableStreaming, CopilotUrl = copilotUrl, AskUserDisabled = askUserDisabled, ContinueOnAutoMode = continueOnAutoMode, RunningInInteractiveMode = runningInInteractiveMode, EnableReasoningSummaries = enableReasoningSummaries, AgentContext = agentContext, EventsLogDirectory = eventsLogDirectory, AdditionalContentExclusionPolicies = additionalContentExclusionPolicies, ManageScheduleEnabled = manageScheduleEnabled, SessionCapabilities = sessionCapabilities, SkipEmbeddingRetrieval = skipEmbeddingRetrieval, OrganizationCustomInstructions = organizationCustomInstructions, EnableFileHooks = enableFileHooks, EnableHostGitOperations = enableHostGitOperations, EnableSessionStore = enableSessionStore, EnableSkills = enableSkills, ContextTier = contextTier, ResponseBudget = responseBudget }; return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.options.update", [request], cancellationToken); } } @@ -19630,7 +19744,7 @@ public async Task ListAsync(CommandsListRequest? request = null, Ca /// Command name. Leading slashes are stripped and the name is matched case-insensitively. /// Raw input after the command name. /// The to monitor for cancellation requests. The default is . - /// Result of invoking the slash command (text output, prompt to send to the agent, or completion). + /// Result of invoking the slash command (text output, prompt to send to the agent, completion, or subcommand selection). public async Task InvokeAsync(string name, string? input = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(name); @@ -20957,6 +21071,8 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.AbortData), TypeInfoPropertyName = "SessionEventsAbortData")] [JsonSerializable(typeof(GitHub.Copilot.AbortEvent), TypeInfoPropertyName = "SessionEventsAbortEvent")] [JsonSerializable(typeof(GitHub.Copilot.AbortReason), TypeInfoPropertyName = "SessionEventsAbortReason")] +[JsonSerializable(typeof(GitHub.Copilot.AssistantIdleData), TypeInfoPropertyName = "SessionEventsAssistantIdleData")] +[JsonSerializable(typeof(GitHub.Copilot.AssistantIdleEvent), TypeInfoPropertyName = "SessionEventsAssistantIdleEvent")] [JsonSerializable(typeof(GitHub.Copilot.AssistantIntentData), TypeInfoPropertyName = "SessionEventsAssistantIntentData")] [JsonSerializable(typeof(GitHub.Copilot.AssistantIntentEvent), TypeInfoPropertyName = "SessionEventsAssistantIntentEvent")] [JsonSerializable(typeof(GitHub.Copilot.AssistantMessageData), TypeInfoPropertyName = "SessionEventsAssistantMessageData")] @@ -21068,9 +21184,16 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.McpAppToolCallCompleteEvent), TypeInfoPropertyName = "SessionEventsMcpAppToolCallCompleteEvent")] [JsonSerializable(typeof(GitHub.Copilot.McpAppToolCallCompleteToolMeta), TypeInfoPropertyName = "SessionEventsMcpAppToolCallCompleteToolMeta")] [JsonSerializable(typeof(GitHub.Copilot.McpAppToolCallCompleteToolMetaUI), TypeInfoPropertyName = "SessionEventsMcpAppToolCallCompleteToolMetaUI")] +[JsonSerializable(typeof(GitHub.Copilot.McpHeadersRefreshCompletedData), TypeInfoPropertyName = "SessionEventsMcpHeadersRefreshCompletedData")] +[JsonSerializable(typeof(GitHub.Copilot.McpHeadersRefreshCompletedEvent), TypeInfoPropertyName = "SessionEventsMcpHeadersRefreshCompletedEvent")] +[JsonSerializable(typeof(GitHub.Copilot.McpHeadersRefreshCompletedOutcome), TypeInfoPropertyName = "SessionEventsMcpHeadersRefreshCompletedOutcome")] +[JsonSerializable(typeof(GitHub.Copilot.McpHeadersRefreshRequiredData), TypeInfoPropertyName = "SessionEventsMcpHeadersRefreshRequiredData")] +[JsonSerializable(typeof(GitHub.Copilot.McpHeadersRefreshRequiredEvent), TypeInfoPropertyName = "SessionEventsMcpHeadersRefreshRequiredEvent")] +[JsonSerializable(typeof(GitHub.Copilot.McpHeadersRefreshRequiredReason), TypeInfoPropertyName = "SessionEventsMcpHeadersRefreshRequiredReason")] [JsonSerializable(typeof(GitHub.Copilot.McpOauthCompletedData), TypeInfoPropertyName = "SessionEventsMcpOauthCompletedData")] [JsonSerializable(typeof(GitHub.Copilot.McpOauthCompletedEvent), TypeInfoPropertyName = "SessionEventsMcpOauthCompletedEvent")] [JsonSerializable(typeof(GitHub.Copilot.McpOauthCompletionOutcome), TypeInfoPropertyName = "SessionEventsMcpOauthCompletionOutcome")] +[JsonSerializable(typeof(GitHub.Copilot.McpOauthRequestReason), TypeInfoPropertyName = "SessionEventsMcpOauthRequestReason")] [JsonSerializable(typeof(GitHub.Copilot.McpOauthRequiredData), TypeInfoPropertyName = "SessionEventsMcpOauthRequiredData")] [JsonSerializable(typeof(GitHub.Copilot.McpOauthRequiredEvent), TypeInfoPropertyName = "SessionEventsMcpOauthRequiredEvent")] [JsonSerializable(typeof(GitHub.Copilot.McpOauthRequiredStaticClientConfig), TypeInfoPropertyName = "SessionEventsMcpOauthRequiredStaticClientConfig")] @@ -21128,6 +21251,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.PersistedBinaryResult), TypeInfoPropertyName = "SessionEventsPersistedBinaryResult")] [JsonSerializable(typeof(GitHub.Copilot.PlanChangedOperation), TypeInfoPropertyName = "SessionEventsPlanChangedOperation")] [JsonSerializable(typeof(GitHub.Copilot.ReasoningSummary), TypeInfoPropertyName = "SessionEventsReasoningSummary")] +[JsonSerializable(typeof(GitHub.Copilot.ResponseBudgetConfig), TypeInfoPropertyName = "SessionEventsResponseBudgetConfig")] [JsonSerializable(typeof(GitHub.Copilot.SamplingCompletedData), TypeInfoPropertyName = "SessionEventsSamplingCompletedData")] [JsonSerializable(typeof(GitHub.Copilot.SamplingCompletedEvent), TypeInfoPropertyName = "SessionEventsSamplingCompletedEvent")] [JsonSerializable(typeof(GitHub.Copilot.SamplingRequestedData), TypeInfoPropertyName = "SessionEventsSamplingRequestedData")] @@ -21202,6 +21326,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.ToolExecutionProgressEvent), TypeInfoPropertyName = "SessionEventsToolExecutionProgressEvent")] [JsonSerializable(typeof(GitHub.Copilot.ToolExecutionStartData), TypeInfoPropertyName = "SessionEventsToolExecutionStartData")] [JsonSerializable(typeof(GitHub.Copilot.ToolExecutionStartEvent), TypeInfoPropertyName = "SessionEventsToolExecutionStartEvent")] +[JsonSerializable(typeof(GitHub.Copilot.ToolExecutionStartShellToolInfo), TypeInfoPropertyName = "SessionEventsToolExecutionStartShellToolInfo")] [JsonSerializable(typeof(GitHub.Copilot.ToolExecutionStartToolDescription), TypeInfoPropertyName = "SessionEventsToolExecutionStartToolDescription")] [JsonSerializable(typeof(GitHub.Copilot.ToolExecutionStartToolDescriptionMeta), TypeInfoPropertyName = "SessionEventsToolExecutionStartToolDescriptionMeta")] [JsonSerializable(typeof(GitHub.Copilot.ToolExecutionStartToolDescriptionMetaUI), TypeInfoPropertyName = "SessionEventsToolExecutionStartToolDescriptionMetaUI")] @@ -21214,6 +21339,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.UserInputRequestedEvent), TypeInfoPropertyName = "SessionEventsUserInputRequestedEvent")] [JsonSerializable(typeof(GitHub.Copilot.UserMessageAgentMode), TypeInfoPropertyName = "SessionEventsUserMessageAgentMode")] [JsonSerializable(typeof(GitHub.Copilot.UserMessageData), TypeInfoPropertyName = "SessionEventsUserMessageData")] +[JsonSerializable(typeof(GitHub.Copilot.UserMessageDelivery), TypeInfoPropertyName = "SessionEventsUserMessageDelivery")] [JsonSerializable(typeof(GitHub.Copilot.UserMessageEvent), TypeInfoPropertyName = "SessionEventsUserMessageEvent")] [JsonSerializable(typeof(GitHub.Copilot.UserToolSessionApproval), TypeInfoPropertyName = "SessionEventsUserToolSessionApproval")] [JsonSerializable(typeof(GitHub.Copilot.UserToolSessionApprovalCommands), TypeInfoPropertyName = "SessionEventsUserToolSessionApprovalCommands")] @@ -21387,6 +21513,9 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(McpExecuteSamplingRequest))] [JsonSerializable(typeof(McpExecuteSamplingResult))] [JsonSerializable(typeof(McpFilteredServer))] +[JsonSerializable(typeof(McpHeadersHandlePendingHeadersRefreshRequest))] +[JsonSerializable(typeof(McpHeadersHandlePendingHeadersRefreshRequestRequest))] +[JsonSerializable(typeof(McpHeadersHandlePendingHeadersRefreshRequestResult))] [JsonSerializable(typeof(McpHostState))] [JsonSerializable(typeof(McpIsServerRunningRequest))] [JsonSerializable(typeof(McpIsServerRunningResult))] diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index f1765e5c44..381780dd57 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -25,6 +25,7 @@ namespace GitHub.Copilot; TypeDiscriminatorPropertyName = "type", IgnoreUnrecognizedTypeDiscriminators = true)] [JsonDerivedType(typeof(AbortEvent), "abort")] +[JsonDerivedType(typeof(AssistantIdleEvent), "assistant.idle")] [JsonDerivedType(typeof(AssistantIntentEvent), "assistant.intent")] [JsonDerivedType(typeof(AssistantMessageEvent), "assistant.message")] [JsonDerivedType(typeof(AssistantMessageDeltaEvent), "assistant.message_delta")] @@ -52,6 +53,8 @@ namespace GitHub.Copilot; [JsonDerivedType(typeof(HookProgressEvent), "hook.progress")] [JsonDerivedType(typeof(HookStartEvent), "hook.start")] [JsonDerivedType(typeof(McpAppToolCallCompleteEvent), "mcp_app.tool_call_complete")] +[JsonDerivedType(typeof(McpHeadersRefreshCompletedEvent), "mcp.headers_refresh_completed")] +[JsonDerivedType(typeof(McpHeadersRefreshRequiredEvent), "mcp.headers_refresh_required")] [JsonDerivedType(typeof(McpOauthCompletedEvent), "mcp.oauth_completed")] [JsonDerivedType(typeof(McpOauthRequiredEvent), "mcp.oauth_required")] [JsonDerivedType(typeof(ModelCallFailureEvent), "model.call_failure")] @@ -655,6 +658,19 @@ public sealed partial class AssistantTurnEndEvent : SessionEvent public required AssistantTurnEndData Data { get; set; } } +/// Payload emitted whenever the main agent's processing loop goes idle, including while related background work (running agents or in-flight attached shell commands) is still pending and the session-level idle event is therefore deferred. +/// Represents the assistant.idle event. +public sealed partial class AssistantIdleEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "assistant.idle"; + + /// The assistant.idle event payload. + [JsonPropertyName("data")] + public required AssistantIdleData Data { get; set; } +} + /// LLM API call usage metrics including tokens, costs, quotas, and billing information. /// Represents the assistant.usage event. public sealed partial class AssistantUsageEvent : SessionEvent @@ -1046,6 +1062,32 @@ public sealed partial class McpOauthCompletedEvent : SessionEvent public required McpOauthCompletedData Data { get; set; } } +/// Dynamic headers refresh request for a remote MCP server. +/// Represents the mcp.headers_refresh_required event. +public sealed partial class McpHeadersRefreshRequiredEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "mcp.headers_refresh_required"; + + /// The mcp.headers_refresh_required event payload. + [JsonPropertyName("data")] + public required McpHeadersRefreshRequiredData Data { get; set; } +} + +/// MCP headers refresh request completion notification. +/// Represents the mcp.headers_refresh_completed event. +public sealed partial class McpHeadersRefreshCompletedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "mcp.headers_refresh_completed"; + + /// The mcp.headers_refresh_completed event payload. + [JsonPropertyName("data")] + public required McpHeadersRefreshCompletedData Data { get; set; } +} + /// Opaque custom notification data. Consumers may branch on source and name, but payload semantics are source-defined. /// Represents the session.custom_notification event. public sealed partial class SessionCustomNotificationEvent : SessionEvent @@ -1449,6 +1491,11 @@ public sealed partial class SessionStartData [JsonPropertyName("remoteSteerable")] public bool? RemoteSteerable { get; set; } + /// Response budget limits configured at session creation time, if any. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("responseBudget")] + public ResponseBudgetConfig? ResponseBudget { get; set; } + /// Model selected at session creation time, if any. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("selectedModel")] @@ -1514,6 +1561,11 @@ public sealed partial class SessionResumeData [JsonPropertyName("remoteSteerable")] public bool? RemoteSteerable { get; set; } + /// Response budget limits currently configured at resume time; null when no budget is active. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("responseBudget")] + public ResponseBudgetConfig? ResponseBudget { get; set; } + /// ISO 8601 timestamp when the session was resumed. [JsonPropertyName("resumeTime")] public required DateTimeOffset ResumeTime { get; set; } @@ -2202,6 +2254,11 @@ public sealed partial class UserMessageData [JsonPropertyName("content")] public required string Content { get; set; } + /// How this message was delivered to the agentic loop relative to loop state (idle-start vs. steering/queued while busy). The timing axis; combine with `source` (origin) for the full picture. Used for telemetry attribution. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("delivery")] + public UserMessageDelivery? Delivery { get; set; } + /// CAPI interaction ID for correlating this user message with its turn. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("interactionId")] @@ -2426,6 +2483,15 @@ public sealed partial class AssistantTurnEndData public required string TurnId { get; set; } } +/// Payload emitted whenever the main agent's processing loop goes idle, including while related background work (running agents or in-flight attached shell commands) is still pending and the session-level idle event is therefore deferred. +public sealed partial class AssistantIdleData +{ + /// True when the preceding agentic loop was cancelled via abort signal. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("aborted")] + public bool? Aborted { get; set; } +} + /// LLM API call usage metrics including tokens, costs, quotas, and billing information. public sealed partial class AssistantUsageData { @@ -2676,6 +2742,11 @@ public sealed partial class ToolExecutionStartData [JsonPropertyName("parentToolCallId")] public string? ParentToolCallId { get; set; } + /// Shell-tool path hints derived from the command at start time for shell tools (bash/powershell/local_shell). Produced by the same shell-aware extractor as PermissionRequestShell.possiblePaths, so it is present even when the command is auto-approved and no permission request fires. Absent for non-shell tools. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("shellToolInfo")] + public ToolExecutionStartShellToolInfo? ShellToolInfo { get; set; } + /// Unique identifier for this tool call. [JsonPropertyName("toolCallId")] public required string ToolCallId { get; set; } @@ -3244,6 +3315,10 @@ public sealed partial class SamplingCompletedData /// OAuth authentication request for an MCP server. public sealed partial class McpOauthRequiredData { + /// Why the runtime is requesting host-provided OAuth credentials. + [JsonPropertyName("reason")] + public required McpOauthRequestReason Reason { get; set; } + /// Unique identifier for this OAuth request; used to respond via session.mcp.oauth.handlePendingRequest. [JsonPropertyName("requestId")] public required string RequestId { get; set; } @@ -3284,6 +3359,38 @@ public sealed partial class McpOauthCompletedData public required string RequestId { get; set; } } +/// Dynamic headers refresh request for a remote MCP server. +public sealed partial class McpHeadersRefreshRequiredData +{ + /// Why dynamic headers are being requested. + [JsonPropertyName("reason")] + public required McpHeadersRefreshRequiredReason Reason { get; set; } + + /// Unique identifier for this headers refresh request; used to respond via session.mcp.headers.handlePendingHeadersRefreshRequest(). + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } + + /// Display name of the remote MCP server requesting headers. + [JsonPropertyName("serverName")] + public required string ServerName { get; set; } + + /// URL of the remote MCP server requesting headers. + [JsonPropertyName("serverUrl")] + public required string ServerUrl { get; set; } +} + +/// MCP headers refresh request completion notification. +public sealed partial class McpHeadersRefreshCompletedData +{ + /// How the pending MCP headers refresh request resolved. + [JsonPropertyName("outcome")] + public required McpHeadersRefreshCompletedOutcome Outcome { get; set; } + + /// Request ID of the resolved headers refresh request. + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } +} + /// Opaque custom notification data. Consumers may branch on source and name, but payload semantics are source-defined. public sealed partial class SessionCustomNotificationData { @@ -3800,6 +3907,21 @@ public sealed partial class WorkingDirectoryContext public string? RepositoryHost { get; set; } } +/// Optional response budget limits. +/// Nested data type for ResponseBudgetConfig. +public sealed partial class ResponseBudgetConfig +{ + /// Maximum AI Credits allowed while responding to one top-level user message. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("maxAiCredits")] + public double? MaxAiCredits { get; set; } + + /// Maximum model-call iterations allowed while responding to one top-level user message. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("maxModelIterations")] + public long? MaxModelIterations { get; set; } +} + /// Repository context for the handed-off session. /// Nested data type for HandoffRepository. public sealed partial class HandoffRepository @@ -4633,6 +4755,19 @@ public sealed partial class ModelCallFailureRequestFingerprint public required long ToolResultMessageCount { get; set; } } +/// Shell-aware path hints for a shell tool's command, captured at start time so consumers can snapshot a file's pre-image before the tool runs. +/// Nested data type for ToolExecutionStartShellToolInfo. +public sealed partial class ToolExecutionStartShellToolInfo +{ + /// Whether the command includes a file write redirection (e.g., > or >>). + [JsonPropertyName("hasWriteFileRedirection")] + public required bool HasWriteFileRedirection { get; set; } + + /// File paths the command may read or write, derived from the command at start time. Produced by the same shell-aware extractor as PermissionRequestShell.possiblePaths, so it is present even when the command is auto-approved and no permission request fires. + [JsonPropertyName("possiblePaths")] + public required string[] PossiblePaths { get; set; } +} + /// Schema for the `ToolExecutionStartToolDescriptionMetaUI` type. /// Nested data type for ToolExecutionStartToolDescriptionMetaUI. public sealed partial class ToolExecutionStartToolDescriptionMetaUI @@ -6620,6 +6755,11 @@ public sealed partial class McpOauthRequiredStaticClientConfig [JsonPropertyName("clientId")] public required string ClientId { get; set; } + /// Optional OAuth client secret for confidential static clients, when the runtime can resolve one. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("clientSecret")] + public string? ClientSecret { get; set; } + /// Optional non-default OAuth grant type. When set to 'client_credentials', the OAuth flow runs headlessly using the client_id + keychain-stored secret (no browser, no callback server). [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("grantType")] @@ -6640,9 +6780,10 @@ public sealed partial class McpOauthWWWAuthenticateParams [JsonPropertyName("error")] public string? Error { get; set; } - /// Protected resource metadata URL from the WWW-Authenticate resource_metadata parameter. + /// Protected resource metadata URL from the WWW-Authenticate resource_metadata parameter, if present. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("resourceMetadataUrl")] - public required string ResourceMetadataUrl { get; set; } + public string? ResourceMetadataUrl { get; set; } /// Requested OAuth scopes from the WWW-Authenticate scope parameter, if present. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -7729,6 +7870,70 @@ public override void Write(Utf8JsonWriter writer, AttachmentGitHubReferenceType } } +/// How this user message was delivered to the agentic loop, relative to whether the loop was already running. This is the timing axis only; the message's origin (human vs. system/command/schedule/skill/etc.) is carried separately by `source`. A system-injected message has a delivery too — e.g. a background-task notification waking an idle agent is `idle`, the same mechanism as a human starting a fresh turn. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct UserMessageDelivery : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public UserMessageDelivery(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Delivered while the loop was idle; starts its own run immediately (a human's fresh turn, or a system notification waking an idle agent). + public static UserMessageDelivery Idle { get; } = new("idle"); + + /// Injected into the current in-flight run while the agent was busy (immediate mode). + public static UserMessageDelivery Steering { get; } = new("steering"); + + /// Enqueued while the agent was busy; processed as its own run afterward. + public static UserMessageDelivery Queued { get; } = new("queued"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(UserMessageDelivery left, UserMessageDelivery right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(UserMessageDelivery left, UserMessageDelivery right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is UserMessageDelivery other && Equals(other); + + /// + public bool Equals(UserMessageDelivery other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override UserMessageDelivery Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, UserMessageDelivery value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(UserMessageDelivery)); + } + } +} + /// The system that produced a citation. [Experimental(Diagnostics.Experimental)] [JsonConverter(typeof(Converter))] @@ -9035,6 +9240,73 @@ public override void Write(Utf8JsonWriter writer, ElicitationCompletedAction val } } +/// Reason the runtime is requesting host-provided MCP OAuth credentials. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct McpOauthRequestReason : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public McpOauthRequestReason(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Initial credentials are required before connecting to the MCP server. + public static McpOauthRequestReason Initial { get; } = new("initial"); + + /// The current host-provided credential was rejected and a replacement is requested. + public static McpOauthRequestReason Refresh { get; } = new("refresh"); + + /// The server requires a new host authorization flow before continuing. + public static McpOauthRequestReason Reauth { get; } = new("reauth"); + + /// The server requires a credential with additional scope or audience. + public static McpOauthRequestReason Upscope { get; } = new("upscope"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(McpOauthRequestReason left, McpOauthRequestReason right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(McpOauthRequestReason left, McpOauthRequestReason right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is McpOauthRequestReason other && Equals(other); + + /// + public bool Equals(McpOauthRequestReason other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override McpOauthRequestReason Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, McpOauthRequestReason value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(McpOauthRequestReason)); + } + } +} + /// How the pending MCP OAuth request was completed. [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] @@ -9096,6 +9368,134 @@ public override void Write(Utf8JsonWriter writer, McpOauthCompletionOutcome valu } } +/// Why dynamic headers are being requested. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct McpHeadersRefreshRequiredReason : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public McpHeadersRefreshRequiredReason(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// The transport is making its first dynamic header request for this server. + public static McpHeadersRefreshRequiredReason Startup { get; } = new("startup"); + + /// The previously cached dynamic headers expired. + public static McpHeadersRefreshRequiredReason TtlExpired { get; } = new("ttl-expired"); + + /// The server returned 401 and stale dynamic headers were invalidated. + public static McpHeadersRefreshRequiredReason AuthFailed { get; } = new("auth-failed"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(McpHeadersRefreshRequiredReason left, McpHeadersRefreshRequiredReason right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(McpHeadersRefreshRequiredReason left, McpHeadersRefreshRequiredReason right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is McpHeadersRefreshRequiredReason other && Equals(other); + + /// + public bool Equals(McpHeadersRefreshRequiredReason other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override McpHeadersRefreshRequiredReason Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, McpHeadersRefreshRequiredReason value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(McpHeadersRefreshRequiredReason)); + } + } +} + +/// How the pending MCP headers refresh request resolved. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct McpHeadersRefreshCompletedOutcome : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public McpHeadersRefreshCompletedOutcome(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// The host supplied dynamic headers. + public static McpHeadersRefreshCompletedOutcome Headers { get; } = new("headers"); + + /// The host responded with no dynamic headers. + public static McpHeadersRefreshCompletedOutcome None { get; } = new("none"); + + /// No response arrived within the bounded window. + public static McpHeadersRefreshCompletedOutcome Timeout { get; } = new("timeout"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(McpHeadersRefreshCompletedOutcome left, McpHeadersRefreshCompletedOutcome right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(McpHeadersRefreshCompletedOutcome left, McpHeadersRefreshCompletedOutcome right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is McpHeadersRefreshCompletedOutcome other && Equals(other); + + /// + public bool Equals(McpHeadersRefreshCompletedOutcome other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override McpHeadersRefreshCompletedOutcome Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, McpHeadersRefreshCompletedOutcome value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(McpHeadersRefreshCompletedOutcome)); + } + } +} + /// The user's auto-mode-switch choice. [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] @@ -9651,6 +10051,8 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(AbortData))] [JsonSerializable(typeof(AbortEvent))] +[JsonSerializable(typeof(AssistantIdleData))] +[JsonSerializable(typeof(AssistantIdleEvent))] [JsonSerializable(typeof(AssistantIntentData))] [JsonSerializable(typeof(AssistantIntentEvent))] [JsonSerializable(typeof(AssistantMessageData))] @@ -9748,6 +10150,10 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(McpAppToolCallCompleteEvent))] [JsonSerializable(typeof(McpAppToolCallCompleteToolMeta))] [JsonSerializable(typeof(McpAppToolCallCompleteToolMetaUI))] +[JsonSerializable(typeof(McpHeadersRefreshCompletedData))] +[JsonSerializable(typeof(McpHeadersRefreshCompletedEvent))] +[JsonSerializable(typeof(McpHeadersRefreshRequiredData))] +[JsonSerializable(typeof(McpHeadersRefreshRequiredEvent))] [JsonSerializable(typeof(McpOauthCompletedData))] [JsonSerializable(typeof(McpOauthCompletedEvent))] [JsonSerializable(typeof(McpOauthRequiredData))] @@ -9803,6 +10209,7 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(PermissionRule))] [JsonSerializable(typeof(PersistedBinaryImage))] [JsonSerializable(typeof(PersistedBinaryResult))] +[JsonSerializable(typeof(ResponseBudgetConfig))] [JsonSerializable(typeof(SamplingCompletedData))] [JsonSerializable(typeof(SamplingCompletedEvent))] [JsonSerializable(typeof(SamplingRequestedData))] @@ -9956,6 +10363,7 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(ToolExecutionProgressEvent))] [JsonSerializable(typeof(ToolExecutionStartData))] [JsonSerializable(typeof(ToolExecutionStartEvent))] +[JsonSerializable(typeof(ToolExecutionStartShellToolInfo))] [JsonSerializable(typeof(ToolExecutionStartToolDescription))] [JsonSerializable(typeof(ToolExecutionStartToolDescriptionMeta))] [JsonSerializable(typeof(ToolExecutionStartToolDescriptionMetaUI))] diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 0985848e26..7f883f4d5a 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -63,6 +63,7 @@ public sealed partial class CopilotSession : IAsyncDisposable private readonly CopilotClient _parentClient; private volatile Func>? _permissionHandler; + private volatile Func>? _mcpAuthHandler; private volatile Func>? _userInputHandler; private volatile Func>? _elicitationHandler; private volatile Func>? _exitPlanModeHandler; @@ -558,6 +559,11 @@ internal void RegisterPermissionHandler(Func>? handler) + { + _mcpAuthHandler = handler; + } + /// /// Handles a permission request from the Copilot CLI. /// @@ -633,6 +639,39 @@ private async Task HandleBroadcastEventAsync(SessionEvent sessionEvent) break; } + case McpOauthRequiredEvent authEvent: + { + var data = authEvent.Data; + if (string.IsNullOrEmpty(data.RequestId)) + return; + + var handler = _mcpAuthHandler; + if (handler is null) + { + if (_logger.IsEnabled(LogLevel.Warning)) + { + _logger.LogWarning( + "Received MCP OAuth request without a registered MCP auth handler. SessionId={SessionId}, RequestId={RequestId}", + SessionId, + data.RequestId); + } + return; + } + + await ExecuteMcpAuthAndRespondAsync(data.RequestId, new McpAuthContext + { + SessionId = SessionId, + RequestId = data.RequestId, + ServerName = data.ServerName, + ServerUrl = data.ServerUrl, + Reason = data.Reason, + WwwAuthenticateParams = data.WwwAuthenticateParams, + ResourceMetadata = data.ResourceMetadata, + StaticClientConfig = data.StaticClientConfig + }, handler); + break; + } + case CommandExecuteEvent cmdEvent: { var data = cmdEvent.Data; @@ -702,6 +741,80 @@ await HandleElicitationRequestAsync( } } + private async Task ExecuteMcpAuthAndRespondAsync( + string requestId, + McpAuthContext context, + Func> handler) + { + try + { + var result = await handler(context); + McpOauthPendingRequestResponse response = + result is { Cancelled: false, Token: { } token } + ? new McpOauthPendingRequestResponseToken + { + AccessToken = token.AccessToken, + TokenType = token.TokenType, + ExpiresIn = token.ExpiresIn + } + : new McpOauthPendingRequestResponseCancelled(); + + await Rpc.Mcp.Oauth.HandlePendingRequestAsync(requestId, response); + } + catch (OperationCanceledException) + { + await TryCancelMcpAuthRequestAsync(requestId); + } + catch (ObjectDisposedException) + { + await TryCancelMcpAuthRequestAsync(requestId); + } + catch (InvalidOperationException) + { + await TryCancelMcpAuthRequestAsync(requestId); + } + catch (ArgumentException) + { + await TryCancelMcpAuthRequestAsync(requestId); + } + catch (NotSupportedException) + { + await TryCancelMcpAuthRequestAsync(requestId); + } + catch (JsonException) + { + await TryCancelMcpAuthRequestAsync(requestId); + } + catch (RemoteRpcException) + { + await TryCancelMcpAuthRequestAsync(requestId); + } + catch (IOException) + { + await TryCancelMcpAuthRequestAsync(requestId); + } + } + + private async Task TryCancelMcpAuthRequestAsync(string requestId) + { + try + { + await Rpc.Mcp.Oauth.HandlePendingRequestAsync(requestId, new McpOauthPendingRequestResponseCancelled()); + } + 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. + } + } + /// /// Executes a tool handler and sends the result back via the HandlePendingToolCall RPC. /// diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 5ae9657813..ecb2774398 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1128,6 +1128,72 @@ public sealed class ElicitationContext public string? Url { get; set; } } +/// +/// Context for an MCP OAuth request callback. +/// +[Experimental(Diagnostics.Experimental)] +public sealed class McpAuthContext +{ + /// Identifier of the session that triggered the MCP OAuth request. + public string SessionId { get; set; } = string.Empty; + + /// Identifier of the pending MCP OAuth request. + public string RequestId { get; set; } = string.Empty; + + /// Display name of the MCP server that requires OAuth. + public string ServerName { get; set; } = string.Empty; + + /// URL of the MCP server that requires OAuth. + public string ServerUrl { get; set; } = string.Empty; + + /// Why the runtime is requesting host-provided OAuth credentials. + public McpOauthRequestReason Reason { get; set; } + + /// Parsed WWW-Authenticate parameters from the MCP server, if available. + public McpOauthWWWAuthenticateParams? WwwAuthenticateParams { get; set; } + + /// Raw RFC 9728 protected-resource metadata JSON fetched by the runtime, if available. + public string? ResourceMetadata { get; set; } + + /// Static OAuth client configuration, if the server specifies one. + public McpOauthRequiredStaticClientConfig? StaticClientConfig { get; set; } +} + +/// +/// Host-provided OAuth token data for a pending MCP OAuth request. +/// +[Experimental(Diagnostics.Experimental)] +public sealed class McpAuthToken +{ + /// Access token acquired by the SDK host. + public required string AccessToken { get; set; } + + /// OAuth token type. Defaults to Bearer when omitted. + public string? TokenType { get; set; } + + /// Token lifetime in seconds, if known. + public long? ExpiresIn { get; set; } +} + +/// +/// Result returned by an MCP auth request handler. +/// +[Experimental(Diagnostics.Experimental)] +public sealed class McpAuthResult +{ + /// Whether the request should be cancelled instead of resolved with a token. + public bool Cancelled { get; set; } + + /// Host-provided token data. Ignored when is true. + public McpAuthToken? Token { get; set; } + + /// Create a token result. + public static McpAuthResult FromToken(McpAuthToken token) => new() { Token = token }; + + /// Create a cancellation result. + public static McpAuthResult Cancel() => new() { Cancelled = true }; +} + // ============================================================================ // Session Capabilities // ============================================================================ @@ -2719,6 +2785,7 @@ protected SessionConfigBase(SessionConfigBase? other) OnElicitationRequest = other.OnElicitationRequest; OnEvent = other.OnEvent; OnExitPlanModeRequest = other.OnExitPlanModeRequest; + OnMcpAuthRequest = other.OnMcpAuthRequest; OnPermissionRequest = other.OnPermissionRequest; OnUserInputRequest = other.OnUserInputRequest; Provider = other.Provider; @@ -3180,6 +3247,14 @@ protected SessionConfigBase(SessionConfigBase? other) [JsonIgnore] public ICanvasHandler? CanvasHandler { get; set; } #pragma warning restore GHCP001 + + /// + /// Optional handler for MCP OAuth requests from MCP servers. + /// When provided, the SDK can satisfy MCP server OAuth requests with host-provided token data or cancellation. + /// + [Experimental(Diagnostics.Experimental)] + [JsonIgnore] + public Func>? OnMcpAuthRequest { get; set; } } /// diff --git a/dotnet/test/E2E/McpOAuthE2ETests.cs b/dotnet/test/E2E/McpOAuthE2ETests.cs new file mode 100644 index 0000000000..cb553eb278 --- /dev/null +++ b/dotnet/test/E2E/McpOAuthE2ETests.cs @@ -0,0 +1,298 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Rpc; +using GitHub.Copilot.Test.Harness; +using System.Diagnostics; +using System.Text.Json; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +public class McpOAuthE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "mcp_oauth", output) +{ + private const string ExpectedToken = "sdk-host-token"; + private const string RefreshToken = ExpectedToken + "-refresh"; + private const string UpscopeToken = ExpectedToken + "-upscope"; + private const string ReauthToken = ExpectedToken + "-reauth"; + + [Fact] + public async Task Should_Satisfy_MCP_OAuth_Using_Host_Provided_Token() + { + await using var oauthServer = await OAuthMcpServer.StartAsync(ExpectedToken); + var serverName = "oauth-protected-mcp"; + McpAuthContext? observedRequest = null; + + await using var session = await CreateSessionAsync(new SessionConfig + { + OnMcpAuthRequest = request => + { + observedRequest = request; + return Task.FromResult(McpAuthResult.FromToken(new McpAuthToken + { + AccessToken = ExpectedToken, + TokenType = "Bearer", + ExpiresIn = 3600 + })); + }, + McpServers = new Dictionary + { + [serverName] = new McpHttpServerConfig + { + Url = $"{oauthServer.Url}/mcp", + Tools = ["*"] + } + } + }); + + await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected); + var tools = await session.Rpc.Mcp.ListToolsAsync(serverName); + Assert.Contains(tools.Tools, tool => tool.Name == "whoami"); + + Assert.NotNull(observedRequest); + Assert.NotEmpty(observedRequest!.RequestId); + Assert.Equal(serverName, observedRequest!.ServerName); + Assert.Equal($"{oauthServer.Url}/mcp", observedRequest.ServerUrl); + Assert.Equal(McpOauthRequestReason.Initial, observedRequest.Reason); + Assert.NotNull(observedRequest.WwwAuthenticateParams); + Assert.Equal($"{oauthServer.Url}/.well-known/oauth-protected-resource", observedRequest.WwwAuthenticateParams!.ResourceMetadataUrl); + Assert.Equal("mcp.read", observedRequest.WwwAuthenticateParams.Scope); + Assert.Equal("invalid_token", observedRequest.WwwAuthenticateParams.Error); + + using var metadata = JsonDocument.Parse(observedRequest.ResourceMetadata!); + Assert.Equal($"{oauthServer.Url}/mcp", metadata.RootElement.GetProperty("resource").GetString()); + + var requests = await oauthServer.GetRequestsAsync(); + Assert.Contains(requests, request => request.Authorization is null); + Assert.Contains(requests, request => request.Authorization == $"Bearer {ExpectedToken}"); + } + + [Fact] + public async Task Should_Request_Replacement_Tokens_Across_MCP_OAuth_Lifecycle() + { + await using var oauthServer = await OAuthMcpServer.StartAsync(ExpectedToken); + var serverName = "oauth-lifecycle-mcp"; + List observedReasons = []; + var refreshCount = 0; + + await using var session = await CreateSessionAsync(new SessionConfig + { + EnableMcpApps = true, + OnMcpAuthRequest = request => + { + observedReasons.Add(request.Reason); + if (request.Reason == McpOauthRequestReason.Refresh) + { + refreshCount++; + Assert.NotNull(request.WwwAuthenticateParams); + Assert.Null(request.WwwAuthenticateParams!.ResourceMetadataUrl); + Assert.Equal("invalid_token", request.WwwAuthenticateParams.Error); + if (refreshCount > 1) + { + return Task.FromResult(McpAuthResult.Cancel()); + } + } + + if (request.Reason == McpOauthRequestReason.Upscope) + { + Assert.NotNull(request.WwwAuthenticateParams); + Assert.Equal($"{oauthServer.Url}/.well-known/oauth-protected-resource", request.WwwAuthenticateParams!.ResourceMetadataUrl); + Assert.Equal("mcp.write", request.WwwAuthenticateParams.Scope); + Assert.Equal("insufficient_scope", request.WwwAuthenticateParams.Error); + } + + var token = request.Reason == McpOauthRequestReason.Refresh + ? RefreshToken + : request.Reason == McpOauthRequestReason.Upscope + ? UpscopeToken + : request.Reason == McpOauthRequestReason.Reauth + ? ReauthToken + : ExpectedToken; + + return Task.FromResult(McpAuthResult.FromToken(new McpAuthToken + { + AccessToken = token + })); + }, + McpServers = new Dictionary + { + [serverName] = new McpHttpServerConfig + { + Url = $"{oauthServer.Url}/mcp", + Tools = ["*"] + } + } + }); + + await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected); + await CallWhoamiAsync(session, serverName, "refresh"); + await CallWhoamiAsync(session, serverName, "upscope"); + await CallWhoamiAsync(session, serverName, "reauth"); + + Assert.Equal( + [ + McpOauthRequestReason.Initial, + McpOauthRequestReason.Refresh, + McpOauthRequestReason.Upscope, + McpOauthRequestReason.Refresh, + McpOauthRequestReason.Reauth + ], + observedReasons); + + var requests = await oauthServer.GetRequestsAsync(); + Assert.Contains(requests, request => request.Authorization == $"Bearer {RefreshToken}"); + Assert.Contains(requests, request => request.Authorization == $"Bearer {UpscopeToken}"); + Assert.Contains(requests, request => request.Authorization == $"Bearer {ReauthToken}"); + } + + [Fact] + public async Task Should_Cancel_Pending_MCP_OAuth_Request() + { + await using var oauthServer = await OAuthMcpServer.StartAsync(ExpectedToken); + var serverName = "oauth-cancelled-mcp"; + McpAuthContext? observedRequest = null; + + await using var session = await CreateSessionAsync(new SessionConfig + { + OnMcpAuthRequest = request => + { + observedRequest = request; + return Task.FromResult(McpAuthResult.Cancel()); + }, + McpServers = new Dictionary + { + [serverName] = new McpHttpServerConfig + { + Url = $"{oauthServer.Url}/mcp", + Tools = ["*"] + } + } + }); + + await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Failed); + + Assert.NotNull(observedRequest); + Assert.NotEmpty(observedRequest!.RequestId); + Assert.Equal(serverName, observedRequest!.ServerName); + Assert.Equal(McpOauthRequestReason.Initial, observedRequest.Reason); + } + + private static async Task CallWhoamiAsync(CopilotSession session, string serverName, string scenario) + { + using var argumentDocument = JsonDocument.Parse($"{{\"scenario\":\"{scenario}\"}}"); + var result = await session.Rpc.Mcp.Apps.CallToolAsync( + serverName, + "whoami", + serverName, + new Dictionary + { + ["scenario"] = argumentDocument.RootElement.GetProperty("scenario").Clone() + }); + + var content = result["content"].EnumerateArray().ToList(); + Assert.Single(content); + Assert.Equal("oauth-test-user", content[0].GetProperty("text").GetString()); + } + + private sealed class OAuthMcpServer : IAsyncDisposable + { + private readonly Process _process; + private readonly HttpClient _http = new(); + + private OAuthMcpServer(Process process, string url) + { + _process = process; + Url = url; + } + + public string Url { get; } + + public static async Task StartAsync(string expectedToken) + { + var repoRoot = FindRepoRoot(); + var script = GetRepoRelativePath(repoRoot, "test", "harness", "test-mcp-oauth-server.mjs"); + var startInfo = new ProcessStartInfo + { + FileName = "node", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + startInfo.ArgumentList.Add(script); + startInfo.Environment["EXPECTED_TOKEN"] = expectedToken; + + var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start OAuth MCP server."); + var stderrTask = process.StandardError.ReadToEndAsync(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (!cts.IsCancellationRequested) + { + var line = await process.StandardOutput.ReadLineAsync(cts.Token); + if (line is null) + { + throw new InvalidOperationException($"OAuth MCP server exited before listening: {await stderrTask}"); + } + if (line.StartsWith("Listening: ", StringComparison.Ordinal)) + { + return new OAuthMcpServer(process, line["Listening: ".Length..]); + } + } + + throw new TimeoutException($"Timed out waiting for OAuth MCP server: {await stderrTask}"); + } + + public async Task> GetRequestsAsync() + { + var json = await _http.GetStringAsync($"{Url}/__requests"); + using var document = JsonDocument.Parse(json); + return document.RootElement.EnumerateArray() + .Select(element => new OAuthMcpRequest( + element.TryGetProperty("authorization", out var authorization) + && authorization.ValueKind is JsonValueKind.String + ? authorization.GetString() + : null)) + .ToList(); + } + + public async ValueTask DisposeAsync() + { + _http.Dispose(); + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + await _process.WaitForExitAsync(); + } + _process.Dispose(); + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = GetRepoRelativePath(dir.FullName, "test", "harness", "test-mcp-oauth-server.mjs"); + if (File.Exists(candidate)) + return dir.FullName; + dir = dir.Parent; + } + throw new InvalidOperationException("Could not find repository root."); + } + + private static string GetRepoRelativePath(string repoRoot, params string[] relativeSegments) + { + var path = repoRoot; + foreach (var segment in relativeSegments) + { + if (Path.IsPathRooted(segment)) + throw new ArgumentException("Repository-relative path segments must not be rooted.", nameof(relativeSegments)); + path = Path.Join(path, segment); + } + return Path.GetFullPath(path); + } + } + + private sealed record OAuthMcpRequest(string? Authorization); +} diff --git a/dotnet/test/Harness/E2ETestContext.cs b/dotnet/test/Harness/E2ETestContext.cs index 2e2043183a..464325e6fa 100644 --- a/dotnet/test/Harness/E2ETestContext.cs +++ b/dotnet/test/Harness/E2ETestContext.cs @@ -140,6 +140,9 @@ private static string GetCliPath(string repoRoot) var envPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); if (!string.IsNullOrEmpty(envPath)) return envPath; + const string localRuntimeCliPath = "/Users/roji/.copilot/repos/copilot-worktrees/copilot-agent-runtime/roji-symmetrical-dollop/dist-cli/index.js"; + if (File.Exists(localRuntimeCliPath)) return localRuntimeCliPath; + // As of CLI 1.0.64-1 the @github/copilot package is a thin loader; the // runnable index.js ships in the installed platform package // (e.g. @github/copilot-linux-x64). Exactly one is installed. @@ -192,6 +195,8 @@ public Dictionary GetEnvironment() env["GH_CONFIG_DIR"] = HomeDir; env["XDG_CONFIG_HOME"] = HomeDir; env["XDG_STATE_HOME"] = HomeDir; + env["COPILOT_MCP_APPS"] = "true"; + env["MCP_APPS"] = "true"; if (!string.IsNullOrEmpty(_proxy.ConnectProxyUrl) && !string.IsNullOrEmpty(_proxy.CaFilePath)) { const string noProxy = "127.0.0.1,localhost,::1"; diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index 2c11c7d6b5..3864c8b8c8 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -16,6 +16,8 @@ namespace GitHub.Copilot.Test.Unit; public sealed class ClientSessionLifetimeTests { + private sealed record RpcRequestRecord(string Method, JsonElement Params); + [Fact] public async Task StopAsync_Requests_Runtime_Shutdown_For_Owned_Process() { @@ -188,6 +190,124 @@ public async Task ResumeSessionAsync_Throws_When_Same_Client_Already_Tracks_Sess AssertSessionCount(client, sessions: 1); } + [Fact] + public async Task CreateSessionAsync_Registers_McpAuth_Interest_Only_When_Handler_Configured() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + + await using var withoutAuth = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + OnEvent = _ => { } + }); + + Assert.DoesNotContain(server.Requests, request => + request.Method == "session.eventLog.registerInterest" + && request.Params.GetProperty("eventType").GetString() == "mcp.oauth_required"); + Assert.Contains(server.Requests, request => + request.Method == "session.create" + && request.Params.GetProperty("requestPermission").GetBoolean()); + + server.ClearRequests(); + + await using var withAuth = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + OnMcpAuthRequest = _ => Task.FromResult(McpAuthResult.Cancel()) + }); + + Assert.Collection( + server.Requests.Take(2), + request => Assert.Equal("session.create", request.Method), + request => + { + Assert.Equal("session.eventLog.registerInterest", request.Method); + Assert.Equal("mcp.oauth_required", request.Params.GetProperty("eventType").GetString()); + }); + } + + [Fact] + public async Task CreateSessionAsync_Registers_McpAuth_Interest_After_Cloud_Create_When_Handler_Configured() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + var cloud = new CloudSessionOptions + { + Repository = new CloudSessionRepository + { + Owner = "github", + Name = "copilot-sdk", + Branch = "main" + } + }; + + await using var withoutAuth = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Cloud = cloud + }); + + Assert.DoesNotContain(server.Requests, request => + request.Method == "session.eventLog.registerInterest" + && request.Params.GetProperty("eventType").GetString() == "mcp.oauth_required"); + + server.ClearRequests(); + + await using var withAuth = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + OnMcpAuthRequest = _ => Task.FromResult(McpAuthResult.Cancel()), + Cloud = cloud + }); + + Assert.Collection( + server.Requests.Take(2), + request => Assert.Equal("session.create", request.Method), + request => + { + Assert.Equal("session.eventLog.registerInterest", request.Method); + Assert.Equal("mcp.oauth_required", request.Params.GetProperty("eventType").GetString()); + }); + } + + [Fact] + public async Task ResumeSessionAsync_Registers_McpAuth_Interest_Only_When_Handler_Configured() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + + await using var withoutAuth = await client.ResumeSessionAsync("session-without-auth", new ResumeSessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + OnEvent = _ => { } + }); + + Assert.DoesNotContain(server.Requests, request => + request.Method == "session.eventLog.registerInterest" + && request.Params.GetProperty("eventType").GetString() == "mcp.oauth_required"); + Assert.Contains(server.Requests, request => + request.Method == "session.resume" + && request.Params.GetProperty("requestPermission").GetBoolean()); + + server.ClearRequests(); + + await using var withAuth = await client.ResumeSessionAsync("session-with-auth", new ResumeSessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + OnMcpAuthRequest = _ => Task.FromResult(McpAuthResult.Cancel()) + }); + + Assert.Collection( + server.Requests.Take(2), + request => + { + Assert.Equal("session.eventLog.registerInterest", request.Method); + Assert.Equal("mcp.oauth_required", request.Params.GetProperty("eventType").GetString()); + }, + request => Assert.Equal("session.resume", request.Method)); + } + [Fact] public async Task Generated_Session_Rpc_Throws_When_Session_Disposed() { @@ -277,6 +397,8 @@ private sealed class FakeCopilotServer : IAsyncDisposable private readonly TaskCompletionSource _destroyStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _allowDestroy = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly Task _serverTask; + private readonly List _requests = []; + private readonly object _requestsLock = new(); private string? _lastSessionId; private bool _delayDestroy; private bool _failRuntimeShutdown; @@ -307,6 +429,25 @@ public static Task StartAsync() public int RuntimeShutdownCount { get; private set; } + public IReadOnlyList Requests + { + get + { + lock (_requestsLock) + { + return _requests.ToArray(); + } + } + } + + public void ClearRequests() + { + lock (_requestsLock) + { + _requests.Clear(); + } + } + public void DelayDestroy() { _delayDestroy = true; @@ -382,6 +523,13 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel return; } + var paramsElement = request.TryGetProperty("params", out var rawParams) + ? rawParams.Clone() + : JsonDocument.Parse("{}").RootElement.Clone(); + lock (_requestsLock) + { + _requests.Add(new RpcRequestRecord(method!, paramsElement)); + } object? result = method switch { "connect" => new Dictionary @@ -392,6 +540,10 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel }, "session.create" => CreateSessionResult(request), "session.resume" => CreateSessionResult(request), + "session.eventLog.registerInterest" => new Dictionary + { + ["id"] = "interest-1" + }, "session.send" => new Dictionary { ["messageId"] = "message-1" diff --git a/dotnet/test/Unit/PublicDtoTests.cs b/dotnet/test/Unit/PublicDtoTests.cs index c81a8a7a64..d1918d2b9a 100644 --- a/dotnet/test/Unit/PublicDtoTests.cs +++ b/dotnet/test/Unit/PublicDtoTests.cs @@ -20,6 +20,25 @@ namespace GitHub.Copilot.Test.Unit; /// public class PublicDtoTests { + [Fact] + public void McpAuth_Result_Factories_Represent_Token_And_Cancellation() + { + var token = new McpAuthToken + { + AccessToken = "host-token", + TokenType = "Bearer", + ExpiresIn = 3600, + }; + + var tokenResult = McpAuthResult.FromToken(token); + Assert.Same(token, tokenResult.Token); + Assert.False(tokenResult.Cancelled); + + var cancelled = McpAuthResult.Cancel(); + Assert.True(cancelled.Cancelled); + Assert.Null(cancelled.Token); + } + [Fact] public void Public_Dto_Properties_Can_Be_Set_And_Read() { diff --git a/dotnet/test/Unit/SessionEventSerializationTests.cs b/dotnet/test/Unit/SessionEventSerializationTests.cs index 47b4ac3f73..64e28a5aee 100644 --- a/dotnet/test/Unit/SessionEventSerializationTests.cs +++ b/dotnet/test/Unit/SessionEventSerializationTests.cs @@ -150,14 +150,21 @@ public class SessionEventSerializationTests Data = new McpOauthRequiredData { RequestId = "oauth-request", + Reason = McpOauthRequestReason.Initial, ServerName = "oauth-server", ServerUrl = "https://example.com/mcp", StaticClientConfig = new McpOauthRequiredStaticClientConfig { ClientId = "client-id", + ClientSecret = "static-secret", GrantType = "client_credentials", PublicClient = false, }, + WwwAuthenticateParams = new McpOauthWWWAuthenticateParams + { + ResourceMetadataUrl = "https://example.com/.well-known/oauth-protected-resource", + }, + ResourceMetadata = """{"resource":"https://example.com/mcp"}""", }, }, "mcp.oauth_required" @@ -281,6 +288,17 @@ public void SessionEvent_ToJson_RoundTrips_JsonElementBackedPayloads(SessionEven .GetProperty("staticClientConfig") .GetProperty("grantType") .GetString()); + Assert.Equal( + "static-secret", + root.GetProperty("data") + .GetProperty("staticClientConfig") + .GetProperty("clientSecret") + .GetString()); + Assert.Equal( + """{"resource":"https://example.com/mcp"}""", + root.GetProperty("data") + .GetProperty("resourceMetadata") + .GetString()); break; case "assistant.message_start": @@ -297,4 +315,57 @@ public void SessionEvent_ToJson_RoundTrips_JsonElementBackedPayloads(SessionEven break; } } + + [Fact] + public void McpOauthRequiredData_Allows_Missing_Optional_Metadata() + { + const string json = """ + { + "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "timestamp": "2026-03-15T21:26:54.987Z", + "parentId": null, + "type": "mcp.oauth_required", + "data": { + "requestId": "oauth-request", + "reason": "initial", + "serverName": "oauth-server", + "serverUrl": "https://example.com/mcp" + } + } + """; + + var authEvent = Assert.IsType(SessionEvent.FromJson(json)); + Assert.Null(authEvent.Data.WwwAuthenticateParams); + Assert.Null(authEvent.Data.ResourceMetadata); + } + + [Fact] + public void McpOauthRequiredData_Preserves_Static_Client_Secret() + { + const string json = """ + { + "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "timestamp": "2026-03-15T21:26:54.987Z", + "parentId": null, + "type": "mcp.oauth_required", + "data": { + "requestId": "oauth-request", + "reason": "initial", + "serverName": "oauth-server", + "serverUrl": "https://example.com/mcp", + "staticClientConfig": { + "clientId": "static-client", + "clientSecret": "static-secret", + "grantType": "client_credentials", + "publicClient": false + } + } + } + """; + + var authEvent = Assert.IsType(SessionEvent.FromJson(json)); + + Assert.NotNull(authEvent.Data.StaticClientConfig); + Assert.Equal("static-secret", authEvent.Data.StaticClientConfig.ClientSecret); + } } diff --git a/go/client.go b/go/client.go index 970f046425..9e2819047e 100644 --- a/go/client.go +++ b/go/client.go @@ -806,6 +806,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses s.registerTools(config.Tools) s.registerPermissionHandler(config.OnPermissionRequest) + s.registerMCPAuthHandler(config.OnMCPAuthRequest) if config.OnUserInputRequest != nil { s.registerUserInputHandler(config.OnUserInputRequest) } @@ -937,6 +938,14 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses c.sessionsMux.Unlock() return nil, fmt.Errorf("session.create returned sessionId %s but the caller requested %s", response.SessionID, localSessionID) } + if config.OnMCPAuthRequest != nil { + if _, err := c.client.Request(ctx, "session.eventLog.registerInterest", map[string]any{ + "sessionId": session.SessionID, + "eventType": "mcp.oauth_required", + }); err != nil { + return nil, err + } + } session.workspacePath = response.WorkspacePath session.setCapabilities(response.Capabilities) @@ -1106,6 +1115,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, session.registerTools(config.Tools) session.registerPermissionHandler(config.OnPermissionRequest) + session.registerMCPAuthHandler(config.OnMCPAuthRequest) if config.OnUserInputRequest != nil { session.registerUserInputHandler(config.OnUserInputRequest) } @@ -1140,6 +1150,17 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, c.sessionsMux.Lock() c.sessions[sessionID] = session c.sessionsMux.Unlock() + if config.OnMCPAuthRequest != nil { + if _, err := c.client.Request(ctx, "session.eventLog.registerInterest", map[string]any{ + "sessionId": sessionID, + "eventType": "mcp.oauth_required", + }); err != nil { + c.sessionsMux.Lock() + delete(c.sessions, sessionID) + c.sessionsMux.Unlock() + return nil, err + } + } if c.options.SessionFS != nil { if config.CreateSessionFSProvider == nil { diff --git a/go/client_test.go b/go/client_test.go index d59c71c6f9..dce543ea6b 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -3,6 +3,8 @@ package copilot import ( "context" "encoding/json" + "fmt" + "io" "net" "os" "os/exec" @@ -1315,6 +1317,291 @@ func TestClient_StartStopRace(t *testing.T) { } } +func TestClient_MCPAuthInterestRegistration(t *testing.T) { + t.Run("create skips MCP OAuth interest without auth handler", func(t *testing.T) { + client, requests, cleanup := newInMemoryClient(t) + defer cleanup() + + session, err := client.CreateSession(t.Context(), &SessionConfig{ + OnPermissionRequest: PermissionHandler.ApproveAll, + OnEvent: func(SessionEvent) {}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session.Disconnect() + + assertNoMCPAuthInterest(t, requests.snapshot()) + assertRequestMethod(t, requests.snapshot(), "session.create") + assertCreateRequestPermission(t, requests.snapshot()) + }) + + t.Run("create registers MCP OAuth interest after local session create when auth handler is configured", func(t *testing.T) { + client, requests, cleanup := newInMemoryClient(t) + defer cleanup() + + session, err := client.CreateSession(t.Context(), &SessionConfig{ + OnPermissionRequest: PermissionHandler.ApproveAll, + OnMCPAuthRequest: func(MCPAuthRequest, MCPAuthInvocation) (*MCPAuthResult, error) { + return &MCPAuthResult{Kind: "cancelled"}, nil + }, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session.Disconnect() + + snapshot := requests.snapshot() + assertRequestMethod(t, snapshot, "session.eventLog.registerInterest") + if snapshot[0].Method != "session.create" { + t.Fatalf("expected session.create before MCP auth interest, got %s", snapshot[0].Method) + } + if snapshot[1].Method != "session.eventLog.registerInterest" { + t.Fatalf("expected MCP auth interest after session.create, got %s", snapshot[1].Method) + } + assertMCPAuthInterest(t, snapshot[1]) + assertCreateRequestPermission(t, snapshot) + }) + + t.Run("cloud create registers MCP OAuth interest after server assigns id only when auth handler is configured", func(t *testing.T) { + client, requests, cleanup := newInMemoryClient(t) + defer cleanup() + + withoutAuth, err := client.CreateSession(t.Context(), &SessionConfig{ + OnPermissionRequest: PermissionHandler.ApproveAll, + Cloud: &CloudSessionOptions{ + Repository: &CloudSessionRepository{Owner: "github", Name: "copilot-sdk", Branch: "main"}, + }, + }) + if err != nil { + t.Fatalf("CreateSession without auth failed: %v", err) + } + defer withoutAuth.Disconnect() + + assertNoMCPAuthInterest(t, requests.snapshot()) + requests.clear() + + withAuth, err := client.CreateSession(t.Context(), &SessionConfig{ + OnPermissionRequest: PermissionHandler.ApproveAll, + OnMCPAuthRequest: func(MCPAuthRequest, MCPAuthInvocation) (*MCPAuthResult, error) { + return &MCPAuthResult{Kind: "cancelled"}, nil + }, + Cloud: &CloudSessionOptions{ + Repository: &CloudSessionRepository{Owner: "github", Name: "copilot-sdk", Branch: "main"}, + }, + }) + if err != nil { + t.Fatalf("CreateSession with auth failed: %v", err) + } + defer withAuth.Disconnect() + + snapshot := requests.snapshot() + if snapshot[0].Method != "session.create" { + t.Fatalf("expected cloud session.create before MCP auth interest, got %s", snapshot[0].Method) + } + if snapshot[1].Method != "session.eventLog.registerInterest" { + t.Fatalf("expected MCP auth interest after cloud session.create, got %s", snapshot[1].Method) + } + assertMCPAuthInterest(t, snapshot[1]) + }) + + t.Run("resume conditionally registers MCP OAuth interest before session resume", func(t *testing.T) { + client, requests, cleanup := newInMemoryClient(t) + defer cleanup() + + withoutAuth, err := client.ResumeSession(t.Context(), "session-without-auth", &ResumeSessionConfig{ + OnPermissionRequest: PermissionHandler.ApproveAll, + OnEvent: func(SessionEvent) {}, + }) + if err != nil { + t.Fatalf("ResumeSession without auth failed: %v", err) + } + defer withoutAuth.Disconnect() + + assertNoMCPAuthInterest(t, requests.snapshot()) + assertRequestMethod(t, requests.snapshot(), "session.resume") + requests.clear() + + withAuth, err := client.ResumeSession(t.Context(), "session-with-auth", &ResumeSessionConfig{ + OnPermissionRequest: PermissionHandler.ApproveAll, + OnMCPAuthRequest: func(MCPAuthRequest, MCPAuthInvocation) (*MCPAuthResult, error) { + return &MCPAuthResult{Kind: "cancelled"}, nil + }, + }) + if err != nil { + t.Fatalf("ResumeSession with auth failed: %v", err) + } + defer withAuth.Disconnect() + + snapshot := requests.snapshot() + if snapshot[0].Method != "session.eventLog.registerInterest" { + t.Fatalf("expected MCP auth interest before session.resume, got %s", snapshot[0].Method) + } + if snapshot[1].Method != "session.resume" { + t.Fatalf("expected session.resume after MCP auth interest, got %s", snapshot[1].Method) + } + assertMCPAuthInterest(t, snapshot[0]) + }) +} + +type recordedRequest struct { + Method string + Params map[string]any +} + +type requestRecorder struct { + mu sync.Mutex + requests []recordedRequest +} + +func (r *requestRecorder) append(request recordedRequest) { + r.mu.Lock() + defer r.mu.Unlock() + r.requests = append(r.requests, request) +} + +func (r *requestRecorder) snapshot() []recordedRequest { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]recordedRequest, len(r.requests)) + copy(out, r.requests) + return out +} + +func (r *requestRecorder) clear() { + r.mu.Lock() + defer r.mu.Unlock() + r.requests = nil +} + +func newInMemoryClient(t *testing.T) (*Client, *requestRecorder, func()) { + t.Helper() + + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + rpcClient := jsonrpc2.NewClient(stdinW, stdoutR) + rpcClient.Start() + + client := NewClient(&ClientOptions{}) + client.client = rpcClient + client.RPC = rpc.NewServerRPC(rpcClient) + client.state = stateConnected + + requests := &requestRecorder{} + done := make(chan struct{}) + go serveInMemoryRuntime(t, stdinR, stdoutW, requests, done) + + cleanup := func() { + rpcClient.Stop() + stdinR.Close() + stdinW.Close() + stdoutR.Close() + stdoutW.Close() + <-done + } + return client, requests, cleanup +} + +func serveInMemoryRuntime(t *testing.T, stdinR *io.PipeReader, stdoutW *io.PipeWriter, requests *requestRecorder, done chan<- struct{}) { + t.Helper() + defer close(done) + + serverAssignedSessions := 0 + for { + frame, err := readTestJSONRPCFrame(stdinR) + if err != nil { + 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 { + t.Errorf("failed to unmarshal JSON-RPC request: %v", err) + return + } + requests.append(recordedRequest{Method: request.Method, Params: request.Params}) + + result := map[string]any{} + switch request.Method { + case "session.create", "session.resume": + sessionID, _ := request.Params["sessionId"].(string) + if sessionID == "" { + serverAssignedSessions++ + sessionID = fmt.Sprintf("server-assigned-session-%d", serverAssignedSessions) + } + result = map[string]any{"sessionId": sessionID, "workspacePath": nil} + case "session.eventLog.registerInterest": + result = map[string]any{"id": "interest-1"} + case "session.options.update": + result = map[string]any{"success": true} + case "session.skills.reload", "session.destroy": + result = map[string]any{} + default: + t.Errorf("unexpected JSON-RPC method %s", request.Method) + return + } + + response := map[string]any{ + "jsonrpc": "2.0", + "id": json.RawMessage(request.ID), + "result": result, + } + data, err := json.Marshal(response) + if err != nil { + t.Errorf("failed to marshal JSON-RPC response: %v", err) + return + } + if _, err := fmt.Fprintf(stdoutW, "Content-Length: %d\r\n\r\n%s", len(data), data); err != nil { + return + } + } +} + +func assertRequestMethod(t *testing.T, requests []recordedRequest, method string) { + t.Helper() + for _, request := range requests { + if request.Method == method { + return + } + } + t.Fatalf("expected %s request in %+v", method, requests) +} + +func assertNoMCPAuthInterest(t *testing.T, requests []recordedRequest) { + t.Helper() + for _, request := range requests { + if request.Method == "session.eventLog.registerInterest" && request.Params["eventType"] == "mcp.oauth_required" { + t.Fatalf("did not expect MCP auth interest registration in %+v", requests) + } + } +} + +func assertMCPAuthInterest(t *testing.T, request recordedRequest) { + t.Helper() + if request.Method != "session.eventLog.registerInterest" { + t.Fatalf("expected registerInterest request, got %s", request.Method) + } + if request.Params["eventType"] != "mcp.oauth_required" { + t.Fatalf("expected mcp.oauth_required interest, got %v", request.Params["eventType"]) + } +} + +func assertCreateRequestPermission(t *testing.T, requests []recordedRequest) { + t.Helper() + for _, request := range requests { + if request.Method == "session.create" { + if request.Params["requestPermission"] != true { + t.Fatalf("expected create requestPermission=true, got %v", request.Params["requestPermission"]) + } + return + } + } + t.Fatalf("session.create request not found in %+v", requests) +} + func TestCreateSessionRequest_Commands(t *testing.T) { t.Run("forwards commands in session.create RPC", func(t *testing.T) { req := createSessionRequest{ diff --git a/go/internal/e2e/mcp_oauth_e2e_test.go b/go/internal/e2e/mcp_oauth_e2e_test.go new file mode 100644 index 0000000000..ceebe0245d --- /dev/null +++ b/go/internal/e2e/mcp_oauth_e2e_test.go @@ -0,0 +1,328 @@ +package e2e + +import ( + "bufio" + "encoding/json" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +const expectedMCPOAuthToken = "sdk-host-token" +const refreshMCPOAuthToken = expectedMCPOAuthToken + "-refresh" +const upscopeMCPOAuthToken = expectedMCPOAuthToken + "-upscope" +const reauthMCPOAuthToken = expectedMCPOAuthToken + "-reauth" + +func TestMCPOAuthE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("satisfy MCP OAuth using host-provided token", func(t *testing.T) { + baseURL := startOAuthMCPServer(t) + serverName := "oauth-protected-mcp" + tokenType := "Bearer" + expiresIn := int64(3600) + var observedRequest copilot.MCPAuthRequest + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + OnMCPAuthRequest: func(request copilot.MCPAuthRequest, _ copilot.MCPAuthInvocation) (*copilot.MCPAuthResult, error) { + observedRequest = request + return &copilot.MCPAuthResult{ + Kind: "token", + Token: &copilot.MCPAuthToken{ + AccessToken: expectedMCPOAuthToken, + TokenType: &tokenType, + ExpiresIn: &expiresIn, + }, + }, nil + }, + MCPServers: map[string]copilot.MCPServerConfig{ + serverName: copilot.MCPHTTPServerConfig{ + URL: baseURL + "/mcp", + Tools: []string{"*"}, + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + t.Cleanup(func() { session.Disconnect() }) + + waitForMCPServerStatus(t, session, serverName, rpc.MCPServerStatusConnected) + tools, err := session.RPC.MCP.ListTools(t.Context(), &rpc.MCPListToolsRequest{ServerName: serverName}) + if err != nil { + t.Fatalf("Failed to list MCP tools: %v", err) + } + if len(tools.Tools) != 1 || tools.Tools[0].Name != "whoami" { + t.Fatalf("Expected whoami tool, got %#v", tools.Tools) + } + + if observedRequest.ServerName != serverName { + t.Fatalf("Expected serverName %q, got %q", serverName, observedRequest.ServerName) + } + if observedRequest.ServerURL != baseURL+"/mcp" { + t.Fatalf("Expected serverUrl %q, got %q", baseURL+"/mcp", observedRequest.ServerURL) + } + if observedRequest.WwwAuthenticateParams == nil { + t.Fatal("Expected WWW-Authenticate params") + } + if observedRequest.Reason != "initial" { + t.Fatalf("Unexpected auth request reason: %q", observedRequest.Reason) + } + if observedRequest.WwwAuthenticateParams.ResourceMetadataURL == nil || + *observedRequest.WwwAuthenticateParams.ResourceMetadataURL != baseURL+"/.well-known/oauth-protected-resource" { + t.Fatalf("Unexpected resource metadata URL: %v", observedRequest.WwwAuthenticateParams.ResourceMetadataURL) + } + if observedRequest.WwwAuthenticateParams.Scope != "mcp.read" || observedRequest.WwwAuthenticateParams.Error != "invalid_token" { + t.Fatalf("Unexpected WWW-Authenticate params: %#v", observedRequest.WwwAuthenticateParams) + } + + var metadata map[string]any + if err := json.Unmarshal([]byte(observedRequest.ResourceMetadata), &metadata); err != nil { + t.Fatalf("Failed to parse resource metadata: %v", err) + } + if metadata["resource"] != baseURL+"/mcp" { + t.Fatalf("Expected resource %q, got %#v", baseURL+"/mcp", metadata["resource"]) + } + + requests := fetchOAuthMCPRequests(t, baseURL) + if !hasAuthorization(requests, "") { + t.Fatal("Expected at least one unauthenticated MCP request") + } + if !hasAuthorization(requests, "Bearer "+expectedMCPOAuthToken) { + t.Fatal("Expected at least one MCP request with host-provided token") + } + }) + + t.Run("request replacement tokens across MCP OAuth lifecycle", func(t *testing.T) { + baseURL := startOAuthMCPServer(t) + serverName := "oauth-lifecycle-mcp" + var mu sync.Mutex + var observedReasons []string + refreshCount := 0 + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + EnableMCPApps: true, + OnMCPAuthRequest: func(request copilot.MCPAuthRequest, _ copilot.MCPAuthInvocation) (*copilot.MCPAuthResult, error) { + mu.Lock() + observedReasons = append(observedReasons, request.Reason) + refreshOrdinal := 0 + if request.Reason == "refresh" { + refreshCount++ + refreshOrdinal = refreshCount + } + mu.Unlock() + + token := expectedMCPOAuthToken + switch request.Reason { + case "refresh": + if request.WwwAuthenticateParams == nil || + request.WwwAuthenticateParams.ResourceMetadataURL != nil || + request.WwwAuthenticateParams.Error != "invalid_token" { + t.Fatalf("Unexpected refresh WWW-Authenticate params: %#v", request.WwwAuthenticateParams) + } + if refreshOrdinal > 1 { + return &copilot.MCPAuthResult{Kind: "cancelled"}, nil + } + token = refreshMCPOAuthToken + case "upscope": + token = upscopeMCPOAuthToken + if request.WwwAuthenticateParams == nil || + request.WwwAuthenticateParams.ResourceMetadataURL == nil || + *request.WwwAuthenticateParams.ResourceMetadataURL != baseURL+"/.well-known/oauth-protected-resource" || + request.WwwAuthenticateParams.Scope != "mcp.write" || + request.WwwAuthenticateParams.Error != "insufficient_scope" { + t.Fatalf("Unexpected upscope WWW-Authenticate params: %#v", request.WwwAuthenticateParams) + } + case "reauth": + token = reauthMCPOAuthToken + } + return &copilot.MCPAuthResult{ + Kind: "token", + Token: &copilot.MCPAuthToken{AccessToken: token}, + }, nil + }, + MCPServers: map[string]copilot.MCPServerConfig{ + serverName: copilot.MCPHTTPServerConfig{ + URL: baseURL + "/mcp", + Tools: []string{"*"}, + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + t.Cleanup(func() { session.Disconnect() }) + + waitForMCPServerStatus(t, session, serverName, rpc.MCPServerStatusConnected) + callWhoami(t, session, serverName, "refresh") + callWhoami(t, session, serverName, "upscope") + callWhoami(t, session, serverName, "reauth") + + mu.Lock() + reasons := append([]string(nil), observedReasons...) + mu.Unlock() + if strings.Join(reasons, ",") != "initial,refresh,upscope,refresh,reauth" { + t.Fatalf("Unexpected auth request reasons: %#v", reasons) + } + + requests := fetchOAuthMCPRequests(t, baseURL) + if !hasAuthorization(requests, "Bearer "+refreshMCPOAuthToken) { + t.Fatal("Expected at least one MCP request with refresh token") + } + if !hasAuthorization(requests, "Bearer "+upscopeMCPOAuthToken) { + t.Fatal("Expected at least one MCP request with upscope token") + } + if !hasAuthorization(requests, "Bearer "+reauthMCPOAuthToken) { + t.Fatal("Expected at least one MCP request with reauth token") + } + }) + + t.Run("cancel pending MCP OAuth request", func(t *testing.T) { + baseURL := startOAuthMCPServer(t) + serverName := "oauth-cancelled-mcp" + var observedRequest copilot.MCPAuthRequest + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + OnMCPAuthRequest: func(request copilot.MCPAuthRequest, _ copilot.MCPAuthInvocation) (*copilot.MCPAuthResult, error) { + observedRequest = request + return &copilot.MCPAuthResult{Kind: "cancelled"}, nil + }, + MCPServers: map[string]copilot.MCPServerConfig{ + serverName: copilot.MCPHTTPServerConfig{ + URL: baseURL + "/mcp", + Tools: []string{"*"}, + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + t.Cleanup(func() { session.Disconnect() }) + + waitForMCPServerStatus(t, session, serverName, rpc.MCPServerStatusFailed) + if observedRequest.ServerName != serverName { + t.Fatalf("Expected serverName %q, got %q", serverName, observedRequest.ServerName) + } + if observedRequest.Reason != "initial" { + t.Fatalf("Unexpected auth request reason: %q", observedRequest.Reason) + } + }) +} + +type oauthMCPRequest struct { + Authorization *string `json:"authorization"` +} + +func startOAuthMCPServer(t *testing.T) string { + t.Helper() + + serverPath, err := filepath.Abs("../../../test/harness/test-mcp-oauth-server.mjs") + if err != nil { + t.Fatalf("Failed to resolve OAuth MCP server path: %v", err) + } + cmd := exec.Command("node", serverPath) + cmd.Env = append(os.Environ(), "EXPECTED_TOKEN="+expectedMCPOAuthToken) + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("Failed to pipe OAuth MCP server stdout: %v", err) + } + var stderr strings.Builder + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + t.Fatalf("Failed to start OAuth MCP server: %v", err) + } + t.Cleanup(func() { + if cmd.ProcessState != nil && cmd.ProcessState.Exited() { + return + } + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }) + + lines := make(chan string, 1) + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + lines <- scanner.Text() + return + } + close(lines) + }() + + select { + case line, ok := <-lines: + if !ok { + t.Fatalf("OAuth MCP server exited before listening: %s", stderr.String()) + } + const prefix = "Listening: " + if !strings.HasPrefix(line, prefix) { + t.Fatalf("Unexpected OAuth MCP server startup line %q. stderr=%s", line, stderr.String()) + } + return strings.TrimPrefix(line, prefix) + case <-time.After(10 * time.Second): + t.Fatalf("Timed out waiting for OAuth MCP server: %s", stderr.String()) + } + return "" +} + +func fetchOAuthMCPRequests(t *testing.T, baseURL string) []oauthMCPRequest { + t.Helper() + + response, err := http.Get(baseURL + "/__requests") + if err != nil { + t.Fatalf("Failed to fetch OAuth MCP requests: %v", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("Failed to fetch OAuth MCP requests: %s", response.Status) + } + var requests []oauthMCPRequest + if err := json.NewDecoder(response.Body).Decode(&requests); err != nil { + t.Fatalf("Failed to decode OAuth MCP requests: %v", err) + } + return requests +} + +func hasAuthorization(requests []oauthMCPRequest, expected string) bool { + for _, request := range requests { + if request.Authorization == nil && expected == "" { + return true + } + if request.Authorization != nil && *request.Authorization == expected { + return true + } + } + return false +} + +func callWhoami(t *testing.T, session *copilot.Session, serverName string, scenario string) { + t.Helper() + + result, err := session.RPC.MCP.Apps().CallTool(t.Context(), &rpc.MCPAppsCallToolRequest{ + OriginServerName: serverName, + ServerName: serverName, + ToolName: "whoami", + Arguments: map[string]any{"scenario": scenario}, + }) + if err != nil { + t.Fatalf("Failed to call whoami for %s: %v", scenario, err) + } + content, ok := (*result)["content"].([]any) + if !ok || len(content) != 1 { + t.Fatalf("Unexpected whoami result: %#v", result) + } +} diff --git a/go/internal/e2e/testharness/context.go b/go/internal/e2e/testharness/context.go index adceb9a746..d56f957278 100644 --- a/go/internal/e2e/testharness/context.go +++ b/go/internal/e2e/testharness/context.go @@ -14,6 +14,7 @@ import ( ) const defaultGitHubToken = "fake-token-for-e2e-tests" +const localRuntimeCLIPath = "/Users/roji/.copilot/repos/copilot-worktrees/copilot-agent-runtime/roji-symmetrical-dollop/dist-cli/index.js" var ( cliPath string @@ -28,6 +29,10 @@ func CLIPath() string { cliPath = path return } + if fileExists(localRuntimeCLIPath) { + cliPath = localRuntimeCLIPath + return + } // Look for CLI in sibling nodejs directory's node_modules. As of CLI // 1.0.64-1 the @github/copilot package is a thin loader; the runnable @@ -223,6 +228,8 @@ func (c *TestContext) Env() []string { "GH_CONFIG_DIR="+c.HomeDir, "GH_TOKEN="+defaultGitHubToken, "GITHUB_TOKEN="+defaultGitHubToken, + "COPILOT_MCP_APPS=true", + "MCP_APPS=true", "XDG_CONFIG_HOME="+c.HomeDir, "XDG_STATE_HOME="+c.HomeDir, ) diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 03ec16cea1..ee1ac13240 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -2617,6 +2617,65 @@ type MCPFilteredServer struct { RedactedReason *string `json:"redactedReason,omitempty"` } +// Host response: supply dynamic headers or decline this refresh. +// Experimental: MCPHeadersHandlePendingHeadersRefreshRequest is part of an experimental API +// and may change or be removed. +type MCPHeadersHandlePendingHeadersRefreshRequest interface { + mcpHeadersHandlePendingHeadersRefreshRequest() + Kind() MCPHeadersHandlePendingHeadersRefreshRequestKind +} + +type RawMCPHeadersHandlePendingHeadersRefreshRequestData struct { + Discriminator MCPHeadersHandlePendingHeadersRefreshRequestKind + Raw json.RawMessage +} + +func (RawMCPHeadersHandlePendingHeadersRefreshRequestData) mcpHeadersHandlePendingHeadersRefreshRequest() { +} +func (r RawMCPHeadersHandlePendingHeadersRefreshRequestData) Kind() MCPHeadersHandlePendingHeadersRefreshRequestKind { + return r.Discriminator +} + +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"` +} + +func (MCPHeadersHandlePendingHeadersRefreshRequestHeaders) mcpHeadersHandlePendingHeadersRefreshRequest() { +} +func (MCPHeadersHandlePendingHeadersRefreshRequestHeaders) Kind() MCPHeadersHandlePendingHeadersRefreshRequestKind { + return MCPHeadersHandlePendingHeadersRefreshRequestKindHeaders +} + +type MCPHeadersHandlePendingHeadersRefreshRequestNone struct { +} + +func (MCPHeadersHandlePendingHeadersRefreshRequestNone) mcpHeadersHandlePendingHeadersRefreshRequest() { +} +func (MCPHeadersHandlePendingHeadersRefreshRequestNone) Kind() MCPHeadersHandlePendingHeadersRefreshRequestKind { + return MCPHeadersHandlePendingHeadersRefreshRequestKindNone +} + +// MCP headers refresh request id and the host response. +// Experimental: MCPHeadersHandlePendingHeadersRefreshRequestRequest is part of an +// experimental API and may change or be removed. +type MCPHeadersHandlePendingHeadersRefreshRequestRequest struct { + // Headers refresh request identifier from mcp.headers_refresh_required + RequestID string `json:"requestId"` + // Host response: supply dynamic headers or decline this refresh. + Result MCPHeadersHandlePendingHeadersRefreshRequest `json:"result"` +} + +// Indicates whether the pending MCP headers refresh response was accepted. +// Experimental: MCPHeadersHandlePendingHeadersRefreshRequestResult is part of an +// experimental API and may change or be removed. +type MCPHeadersHandlePendingHeadersRefreshRequestResult struct { + // Whether the response was accepted. False if the request was unknown, timed out, or + // already resolved. + Success bool `json:"success"` +} + // Host-level state, omitted when no MCP host is initialized. // Experimental: MCPHostState is part of an experimental API and may change or be removed. type MCPHostState struct { @@ -2768,8 +2827,6 @@ type MCPOauthPendingRequestResponseToken struct { AccessToken string `json:"accessToken"` // Token lifetime in seconds, if known. ExpiresIn *int64 `json:"expiresIn,omitempty"` - // Refresh token supplied by the host, if available. - RefreshToken *string `json:"refreshToken,omitempty"` // OAuth token type. Defaults to Bearer when omitted. TokenType *string `json:"tokenType,omitempty"` } @@ -3236,6 +3293,10 @@ type Model struct { // Billing information type ModelBilling struct { + // Whole-number percentage discount (0-100) applied to usage billed through this model. + // Populated for the synthetic `auto` model, where requests routed by auto-mode are billed + // at a reduced rate; absent for concrete models. + DiscountPercent *int32 `json:"discountPercent,omitempty"` // Billing cost multiplier relative to the base rate Multiplier *float64 `json:"multiplier,omitempty"` // Token-level pricing information for this model @@ -5641,6 +5702,16 @@ type RemoteSessionRepository struct { Owner string `json:"owner"` } +// Optional experimental response budget limits. +// Experimental: ResponseBudgetConfig is part of an experimental API and may change or be +// removed. +type ResponseBudgetConfig struct { + // Maximum AI Credits allowed while responding to one top-level user message. + MaxAiCredits *float64 `json:"maxAiCredits,omitempty"` + // Maximum model-call iterations allowed while responding to one top-level user message. + MaxModelIterations *int64 `json:"maxModelIterations,omitempty"` +} + type RuntimeShutdownResult struct { } @@ -6565,6 +6636,9 @@ type SessionOpenOptions struct { AdditionalContentExclusionPolicies []SessionOpenOptionsAdditionalContentExclusionPolicy `json:"additionalContentExclusionPolicies,omitzero"` // Runtime context discriminator for agent filtering. AgentContext *string `json:"agentContext,omitempty"` + // Whether to include instructions from every MCP server in the system prompt instead of + // only allowlisted servers. + AllowAllMCPServerInstructions *bool `json:"allowAllMcpServerInstructions,omitempty"` // Whether ask_user is explicitly disabled. AskUserDisabled *bool `json:"askUserDisabled,omitempty"` // Initial authentication info for the session. @@ -6662,6 +6736,8 @@ type SessionOpenOptions struct { RemoteExporting *bool `json:"remoteExporting,omitempty"` // Whether this session supports remote steering. RemoteSteerable *bool `json:"remoteSteerable,omitempty"` + // Initial experimental response budget limits for the session. + ResponseBudget *ResponseBudgetConfig `json:"responseBudget,omitempty"` // Whether the host is an interactive UI. RunningInInteractiveMode *bool `json:"runningInInteractiveMode,omitempty"` // Resolved sandbox configuration. @@ -7384,6 +7460,9 @@ type SessionUpdateOptionsParams struct { AdditionalContentExclusionPolicies []OptionsUpdateAdditionalContentExclusionPolicy `json:"additionalContentExclusionPolicies,omitzero"` // Runtime context discriminator (e.g., `cli`, `actions`). AgentContext *string `json:"agentContext,omitempty"` + // Whether to include instructions from every MCP server in the system prompt instead of + // only allowlisted servers. + AllowAllMCPServerInstructions *bool `json:"allowAllMcpServerInstructions,omitempty"` // Whether to disable the `ask_user` tool (encourages autonomous behavior). AskUserDisabled *bool `json:"askUserDisabled,omitempty"` // Allowlist of tool names available to this session. @@ -7471,6 +7550,8 @@ type SessionUpdateOptionsParams struct { ReasoningEffort *string `json:"reasoningEffort,omitempty"` // Reasoning summary mode for supported model clients. ReasoningSummary *OptionsUpdateReasoningSummary `json:"reasoningSummary,omitempty"` + // Optional experimental response budget limits. Pass null to clear the response budget. + ResponseBudget *ResponseBudgetConfig `json:"responseBudget,omitempty"` // Whether the session is running in an interactive UI. RunningInInteractiveMode *bool `json:"runningInInteractiveMode,omitempty"` // Resolved sandbox configuration. @@ -7781,8 +7862,8 @@ type SlashCommandInput struct { Required *bool `json:"required,omitempty"` } -// Result of invoking the slash command (text output, prompt to send to the agent, or -// completion). +// Result of invoking the slash command (text output, prompt to send to the agent, +// completion, or subcommand selection). // Experimental: SlashCommandInvocationResult is part of an experimental API and may change // or be removed. type SlashCommandInvocationResult interface { @@ -7896,6 +7977,11 @@ type SubagentSettings struct { Agents map[string]SubagentSettingsEntry `json:"agents,omitzero"` // Names of subagents the user has turned off; they cannot be dispatched DisabledSubagents []string `json:"disabledSubagents,omitzero"` + // Maximum number of subagents that can run concurrently; applies to usage-based billing + // users only + MaxConcurrency *int32 `json:"maxConcurrency,omitempty"` + // Maximum subagent nesting depth; applies to usage-based billing users only + MaxDepth *int32 `json:"maxDepth,omitempty"` } // Subagent model, reasoning effort, and context tier settings @@ -9760,6 +9846,14 @@ const ( MCPAppsSetHostContextDetailsThemeLight MCPAppsSetHostContextDetailsTheme = "light" ) +// Kind discriminator for MCPHeadersHandlePendingHeadersRefreshRequest. +type MCPHeadersHandlePendingHeadersRefreshRequestKind string + +const ( + MCPHeadersHandlePendingHeadersRefreshRequestKindHeaders MCPHeadersHandlePendingHeadersRefreshRequestKind = "headers" + MCPHeadersHandlePendingHeadersRefreshRequestKindNone MCPHeadersHandlePendingHeadersRefreshRequestKind = "none" +) + // OAuth grant type override for this login. // Experimental: MCPOauthLoginGrantType is part of an experimental API and may change or be // removed. @@ -12933,7 +13027,7 @@ func (a *CommandsAPI) HandlePendingCommand(ctx context.Context, params *Commands // Parameters: Slash command name and optional raw input string to invoke. // // Returns: Result of invoking the slash command (text output, prompt to send to the agent, -// or completion). +// completion, or subcommand selection). func (a *CommandsAPI) Invoke(ctx context.Context, params *CommandsInvokeRequest) (SlashCommandInvocationResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { @@ -13824,6 +13918,41 @@ func (s *MCPAPI) Apps() *MCPAppsAPI { return (*MCPAppsAPI)(s) } +// Experimental: MCPHeadersAPI contains experimental APIs that may change or be removed. +type MCPHeadersAPI sessionAPI + +// HandlePendingHeadersRefreshRequest responds to a pending MCP dynamic headers refresh +// request. Hosts that subscribe to `mcp.headers_refresh_required` use this to provide +// short-lived per-server headers or to indicate that no dynamic headers are available for +// this refresh. +// +// RPC method: session.mcp.headers.handlePendingHeadersRefreshRequest. +// +// Parameters: MCP headers refresh request id and the host response. +// +// Returns: Indicates whether the pending MCP headers refresh response was accepted. +func (a *MCPHeadersAPI) HandlePendingHeadersRefreshRequest(ctx context.Context, params *MCPHeadersHandlePendingHeadersRefreshRequestRequest) (*MCPHeadersHandlePendingHeadersRefreshRequestResult, error) { + req := map[string]any{"sessionId": a.sessionID} + if params != nil { + req["requestId"] = params.RequestID + req["result"] = params.Result + } + raw, err := a.client.Request(ctx, "session.mcp.headers.handlePendingHeadersRefreshRequest", req) + if err != nil { + return nil, err + } + var result MCPHeadersHandlePendingHeadersRefreshRequestResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Experimental: Headers returns experimental APIs that may change or be removed. +func (s *MCPAPI) Headers() *MCPHeadersAPI { + return (*MCPHeadersAPI)(s) +} + // Experimental: MCPOauthAPI contains experimental APIs that may change or be removed. type MCPOauthAPI sessionAPI @@ -14317,6 +14446,9 @@ func (a *OptionsAPI) Update(ctx context.Context, params *SessionUpdateOptionsPar if params.AgentContext != nil { req["agentContext"] = *params.AgentContext } + if params.AllowAllMCPServerInstructions != nil { + req["allowAllMcpServerInstructions"] = *params.AllowAllMCPServerInstructions + } if params.AskUserDisabled != nil { req["askUserDisabled"] = *params.AskUserDisabled } @@ -14425,6 +14557,9 @@ func (a *OptionsAPI) Update(ctx context.Context, params *SessionUpdateOptionsPar if params.ReasoningSummary != nil { req["reasoningSummary"] = *params.ReasoningSummary } + if params.ResponseBudget != nil { + req["responseBudget"] = *params.ResponseBudget + } if params.RunningInInteractiveMode != nil { req["runningInInteractiveMode"] = *params.RunningInInteractiveMode } diff --git a/go/rpc/zrpc_encoding.go b/go/rpc/zrpc_encoding.go index b4942942b5..35cee28250 100644 --- a/go/rpc/zrpc_encoding.go +++ b/go/rpc/zrpc_encoding.go @@ -1138,6 +1138,89 @@ func (r *MCPConfigUpdateRequest) UnmarshalJSON(data []byte) error { return nil } +func unmarshalMCPHeadersHandlePendingHeadersRefreshRequest(data []byte) (MCPHeadersHandlePendingHeadersRefreshRequest, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Kind MCPHeadersHandlePendingHeadersRefreshRequestKind `json:"kind"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Kind { + case MCPHeadersHandlePendingHeadersRefreshRequestKindHeaders: + var d MCPHeadersHandlePendingHeadersRefreshRequestHeaders + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case MCPHeadersHandlePendingHeadersRefreshRequestKindNone: + var d MCPHeadersHandlePendingHeadersRefreshRequestNone + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + default: + return &RawMCPHeadersHandlePendingHeadersRefreshRequestData{Discriminator: raw.Kind, Raw: data}, nil + } +} + +func (r RawMCPHeadersHandlePendingHeadersRefreshRequestData) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Kind MCPHeadersHandlePendingHeadersRefreshRequestKind `json:"kind"` + }{ + Kind: r.Discriminator, + }) +} + +func (r MCPHeadersHandlePendingHeadersRefreshRequestHeaders) MarshalJSON() ([]byte, error) { + type alias MCPHeadersHandlePendingHeadersRefreshRequestHeaders + return json.Marshal(struct { + Kind MCPHeadersHandlePendingHeadersRefreshRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r MCPHeadersHandlePendingHeadersRefreshRequestNone) MarshalJSON() ([]byte, error) { + type alias MCPHeadersHandlePendingHeadersRefreshRequestNone + return json.Marshal(struct { + Kind MCPHeadersHandlePendingHeadersRefreshRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r *MCPHeadersHandlePendingHeadersRefreshRequestRequest) UnmarshalJSON(data []byte) error { + type rawMCPHeadersHandlePendingHeadersRefreshRequestRequest struct { + RequestID string `json:"requestId"` + Result json.RawMessage `json:"result"` + } + var raw rawMCPHeadersHandlePendingHeadersRefreshRequestRequest + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + r.RequestID = raw.RequestID + if raw.Result != nil { + value, err := unmarshalMCPHeadersHandlePendingHeadersRefreshRequest(raw.Result) + if err != nil { + return err + } + r.Result = value + } + return nil +} + func unmarshalMCPOauthPendingRequestResponse(data []byte) (MCPOauthPendingRequestResponse, error) { if string(data) == "null" { return nil, nil @@ -2819,6 +2902,7 @@ func (r *SessionOpenOptions) UnmarshalJSON(data []byte) error { type rawSessionOpenOptions struct { AdditionalContentExclusionPolicies []SessionOpenOptionsAdditionalContentExclusionPolicy `json:"additionalContentExclusionPolicies,omitzero"` AgentContext *string `json:"agentContext,omitempty"` + AllowAllMCPServerInstructions *bool `json:"allowAllMcpServerInstructions,omitempty"` AskUserDisabled *bool `json:"askUserDisabled,omitempty"` AuthInfo json.RawMessage `json:"authInfo,omitempty"` AvailableTools []string `json:"availableTools,omitzero"` @@ -2861,6 +2945,7 @@ func (r *SessionOpenOptions) UnmarshalJSON(data []byte) error { RemoteDefaultedOn *bool `json:"remoteDefaultedOn,omitempty"` RemoteExporting *bool `json:"remoteExporting,omitempty"` RemoteSteerable *bool `json:"remoteSteerable,omitempty"` + ResponseBudget *ResponseBudgetConfig `json:"responseBudget,omitempty"` RunningInInteractiveMode *bool `json:"runningInInteractiveMode,omitempty"` SandboxConfig *SandboxConfig `json:"sandboxConfig,omitempty"` SessionCapabilities []SessionCapability `json:"sessionCapabilities,omitzero"` @@ -2879,6 +2964,7 @@ func (r *SessionOpenOptions) UnmarshalJSON(data []byte) error { } r.AdditionalContentExclusionPolicies = raw.AdditionalContentExclusionPolicies r.AgentContext = raw.AgentContext + r.AllowAllMCPServerInstructions = raw.AllowAllMCPServerInstructions r.AskUserDisabled = raw.AskUserDisabled if raw.AuthInfo != nil { value, err := unmarshalAuthInfo(raw.AuthInfo) @@ -2927,6 +3013,7 @@ func (r *SessionOpenOptions) UnmarshalJSON(data []byte) error { r.RemoteDefaultedOn = raw.RemoteDefaultedOn r.RemoteExporting = raw.RemoteExporting r.RemoteSteerable = raw.RemoteSteerable + r.ResponseBudget = raw.ResponseBudget r.RunningInInteractiveMode = raw.RunningInInteractiveMode r.SandboxConfig = raw.SandboxConfig r.SessionCapabilities = raw.SessionCapabilities diff --git a/go/rpc/zsession_encoding.go b/go/rpc/zsession_encoding.go index 89e9cc26d3..f26dabc269 100644 --- a/go/rpc/zsession_encoding.go +++ b/go/rpc/zsession_encoding.go @@ -41,6 +41,12 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error { return err } e.Data = &d + case SessionEventTypeAssistantIdle: + var d AssistantIdleData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d case SessionEventTypeAssistantIntent: var d AssistantIntentData if err := json.Unmarshal(raw.Data, &d); err != nil { @@ -203,6 +209,18 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error { return err } e.Data = &d + case SessionEventTypeMCPHeadersRefreshCompleted: + var d MCPHeadersRefreshCompletedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeMCPHeadersRefreshRequired: + var d MCPHeadersRefreshRequiredData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d case SessionEventTypeMCPOauthCompleted: var d MCPOauthCompletedData if err := json.Unmarshal(raw.Data, &d); err != nil { @@ -645,6 +663,7 @@ func (r *UserMessageData) UnmarshalJSON(data []byte) error { AgentMode *UserMessageAgentMode `json:"agentMode,omitempty"` Attachments []json.RawMessage `json:"attachments,omitzero"` Content string `json:"content"` + Delivery *UserMessageDelivery `json:"delivery,omitempty"` InteractionID *string `json:"interactionId,omitempty"` IsAutopilotContinuation *bool `json:"isAutopilotContinuation,omitempty"` NativeDocumentPathFallbackPaths []string `json:"nativeDocumentPathFallbackPaths,omitzero"` @@ -669,6 +688,7 @@ func (r *UserMessageData) UnmarshalJSON(data []byte) error { } } r.Content = raw.Content + r.Delivery = raw.Delivery r.InteractionID = raw.InteractionID r.IsAutopilotContinuation = raw.IsAutopilotContinuation r.NativeDocumentPathFallbackPaths = raw.NativeDocumentPathFallbackPaths diff --git a/go/rpc/zsession_events.go b/go/rpc/zsession_events.go index 7964da76bc..df0aa5beea 100644 --- a/go/rpc/zsession_events.go +++ b/go/rpc/zsession_events.go @@ -54,6 +54,7 @@ type SessionEventType string const ( SessionEventTypeAbort SessionEventType = "abort" + SessionEventTypeAssistantIdle SessionEventType = "assistant.idle" SessionEventTypeAssistantIntent SessionEventType = "assistant.intent" SessionEventTypeAssistantMessage SessionEventType = "assistant.message" SessionEventTypeAssistantMessageDelta SessionEventType = "assistant.message_delta" @@ -81,6 +82,8 @@ const ( SessionEventTypeHookProgress SessionEventType = "hook.progress" SessionEventTypeHookStart SessionEventType = "hook.start" SessionEventTypeMCPAppToolCallComplete SessionEventType = "mcp_app.tool_call_complete" + SessionEventTypeMCPHeadersRefreshCompleted SessionEventType = "mcp.headers_refresh_completed" + SessionEventTypeMCPHeadersRefreshRequired SessionEventType = "mcp.headers_refresh_required" SessionEventTypeMCPOauthCompleted SessionEventType = "mcp.oauth_completed" SessionEventTypeMCPOauthRequired SessionEventType = "mcp.oauth_required" SessionEventTypeModelCallFailure SessionEventType = "model.call_failure" @@ -453,6 +456,23 @@ type SessionCanvasRemovedData struct { func (*SessionCanvasRemovedData) sessionEventData() {} func (*SessionCanvasRemovedData) Type() SessionEventType { return SessionEventTypeSessionCanvasRemoved } +// Dynamic headers refresh request for a remote MCP server +type MCPHeadersRefreshRequiredData struct { + // Why dynamic headers are being requested. + Reason MCPHeadersRefreshRequiredReason `json:"reason"` + // Unique identifier for this headers refresh request; used to respond via session.mcp.headers.handlePendingHeadersRefreshRequest() + RequestID string `json:"requestId"` + // Display name of the remote MCP server requesting headers + ServerName string `json:"serverName"` + // URL of the remote MCP server requesting headers + ServerURL string `json:"serverUrl"` +} + +func (*MCPHeadersRefreshRequiredData) sessionEventData() {} +func (*MCPHeadersRefreshRequiredData) Type() SessionEventType { + return SessionEventTypeMCPHeadersRefreshRequired +} + // Elicitation request completion with the user's response type ElicitationCompletedData struct { // The user action: "accept" (submitted form), "decline" (explicitly refused), or "cancel" (dismissed) @@ -744,6 +764,19 @@ type MCPOauthCompletedData struct { func (*MCPOauthCompletedData) sessionEventData() {} func (*MCPOauthCompletedData) Type() SessionEventType { return SessionEventTypeMCPOauthCompleted } +// MCP headers refresh request completion notification +type MCPHeadersRefreshCompletedData struct { + // How the pending MCP headers refresh request resolved. + Outcome MCPHeadersRefreshCompletedOutcome `json:"outcome"` + // Request ID of the resolved headers refresh request + RequestID string `json:"requestId"` +} + +func (*MCPHeadersRefreshCompletedData) sessionEventData() {} +func (*MCPHeadersRefreshCompletedData) Type() SessionEventType { + return SessionEventTypeMCPHeadersRefreshCompleted +} + // Model change details including previous and new model identifiers type SessionModelChangeData struct { // Reason the change happened, when not user-initiated. Currently `"rate_limit_auto_switch"` for changes triggered by the auto-mode-switch rate-limit recovery path. UI clients can use this to render contextual copy. @@ -780,6 +813,8 @@ func (*SessionRemoteSteerableChangedData) Type() SessionEventType { // OAuth authentication request for an MCP server type MCPOauthRequiredData struct { + // Why the runtime is requesting host-provided OAuth credentials. + Reason MCPOauthRequestReason `json:"reason"` // Unique identifier for this OAuth request; used to respond via session.mcp.oauth.handlePendingRequest RequestID string `json:"requestId"` // Raw OAuth protected-resource metadata document fetched for the MCP server, if available @@ -816,6 +851,15 @@ func (*SessionCustomNotificationData) Type() SessionEventType { return SessionEventTypeSessionCustomNotification } +// Payload emitted whenever the main agent's processing loop goes idle, including while related background work (running agents or in-flight attached shell commands) is still pending and the session-level idle event is therefore deferred +type AssistantIdleData struct { + // True when the preceding agentic loop was cancelled via abort signal + Aborted *bool `json:"aborted,omitempty"` +} + +func (*AssistantIdleData) sessionEventData() {} +func (*AssistantIdleData) Type() SessionEventType { return SessionEventTypeAssistantIdle } + // Payload indicating the session is idle with no background agents or attached shell commands in flight type SessionIdleData struct { // True when the preceding agentic loop was cancelled via abort signal @@ -1165,6 +1209,8 @@ type UserMessageData struct { Attachments []Attachment `json:"attachments,omitzero"` // The user's message text as displayed in the timeline Content string `json:"content"` + // How this message was delivered to the agentic loop relative to loop state (idle-start vs. steering/queued while busy). The timing axis; combine with `source` (origin) for the full picture. Used for telemetry attribution. + Delivery *UserMessageDelivery `json:"delivery,omitempty"` // CAPI interaction ID for correlating this user message with its turn InteractionID *string `json:"interactionId,omitempty"` // True when this user message was auto-injected by autopilot's continuation loop rather than typed by the user; used to distinguish autopilot-driven turns in telemetry. @@ -1247,6 +1293,8 @@ type SessionStartData struct { ReasoningSummary *ReasoningSummary `json:"reasoningSummary,omitempty"` // Whether this session supports remote steering via GitHub RemoteSteerable *bool `json:"remoteSteerable,omitempty"` + // Response budget limits configured at session creation time, if any + ResponseBudget *ResponseBudgetConfig `json:"responseBudget,omitempty"` // Model selected at session creation time, if any SelectedModel *string `json:"selectedModel,omitempty"` // Unique identifier for the session @@ -1280,6 +1328,8 @@ type SessionResumeData struct { ReasoningSummary *ReasoningSummary `json:"reasoningSummary,omitempty"` // Whether this session supports remote steering via GitHub RemoteSteerable *bool `json:"remoteSteerable,omitempty"` + // Response budget limits currently configured at resume time; null when no budget is active + ResponseBudget *ResponseBudgetConfig `json:"responseBudget,omitempty"` // ISO 8601 timestamp when the session was resumed ResumeTime time.Time `json:"resumeTime"` // Model currently selected at resume time @@ -1610,6 +1660,8 @@ type ToolExecutionStartData struct { // Tool call ID of the parent tool invocation when this event originates from a sub-agent // Deprecated: ParentToolCallID is deprecated. ParentToolCallID *string `json:"parentToolCallId,omitempty"` + // Shell-tool path hints derived from the command at start time for shell tools (bash/powershell/local_shell). Produced by the same shell-aware extractor as PermissionRequestShell.possiblePaths, so it is present even when the command is auto-approved and no permission request fires. Absent for non-shell tools. + ShellToolInfo *ToolExecutionStartShellToolInfo `json:"shellToolInfo,omitempty"` // Unique identifier for this tool call ToolCallID string `json:"toolCallId"` // Tool definition metadata, present for MCP tools with MCP Apps support @@ -2145,6 +2197,8 @@ type MCPAppToolCallCompleteToolMetaUI struct { type MCPOauthRequiredStaticClientConfig struct { // OAuth client ID for the server ClientID string `json:"clientId"` + // Optional OAuth client secret for confidential static clients, when the runtime can resolve one + ClientSecret *string `json:"clientSecret,omitempty"` // Optional non-default OAuth grant type. When set to 'client_credentials', the OAuth flow runs headlessly using the client_id + keychain-stored secret (no browser, no callback server). GrantType *MCPOauthRequiredStaticClientConfigGrantType `json:"grantType,omitempty"` // Whether this is a public OAuth client @@ -2155,8 +2209,8 @@ type MCPOauthRequiredStaticClientConfig struct { type MCPOauthWwwAuthenticateParams struct { // OAuth error from the WWW-Authenticate error parameter, if present Error *string `json:"error,omitempty"` - // Protected resource metadata URL from the WWW-Authenticate resource_metadata parameter - ResourceMetadataURL string `json:"resourceMetadataUrl"` + // Protected resource metadata URL from the WWW-Authenticate resource_metadata parameter, if present + ResourceMetadataURL *string `json:"resourceMetadataUrl,omitempty"` // Requested OAuth scopes from the WWW-Authenticate scope parameter, if present Scope *string `json:"scope,omitempty"` } @@ -3264,6 +3318,14 @@ type ToolExecutionCompleteUIResourceMetaUIPermissionsGeolocation struct { type ToolExecutionCompleteUIResourceMetaUIPermissionsMicrophone struct { } +// Shell-aware path hints for a shell tool's command, captured at start time so consumers can snapshot a file's pre-image before the tool runs. +type ToolExecutionStartShellToolInfo struct { + // Whether the command includes a file write redirection (e.g., > or >>). + HasWriteFileRedirection bool `json:"hasWriteFileRedirection"` + // File paths the command may read or write, derived from the command at start time. Produced by the same shell-aware extractor as PermissionRequestShell.possiblePaths, so it is present even when the command is auto-approved and no permission request fires. + PossiblePaths []string `json:"possiblePaths"` +} + // Tool definition metadata, present for MCP tools with MCP Apps support type ToolExecutionStartToolDescription struct { // Tool description @@ -3494,6 +3556,30 @@ const ( HandoffSourceTypeRemote HandoffSourceType = "remote" ) +// How the pending MCP headers refresh request resolved. +type MCPHeadersRefreshCompletedOutcome string + +const ( + // The host supplied dynamic headers. + MCPHeadersRefreshCompletedOutcomeHeaders MCPHeadersRefreshCompletedOutcome = "headers" + // The host responded with no dynamic headers. + MCPHeadersRefreshCompletedOutcomeNone MCPHeadersRefreshCompletedOutcome = "none" + // No response arrived within the bounded window. + MCPHeadersRefreshCompletedOutcomeTimeout MCPHeadersRefreshCompletedOutcome = "timeout" +) + +// Why dynamic headers are being requested. +type MCPHeadersRefreshRequiredReason string + +const ( + // The server returned 401 and stale dynamic headers were invalidated. + MCPHeadersRefreshRequiredReasonAuthFailed MCPHeadersRefreshRequiredReason = "auth-failed" + // The transport is making its first dynamic header request for this server. + MCPHeadersRefreshRequiredReasonStartup MCPHeadersRefreshRequiredReason = "startup" + // The previously cached dynamic headers expired. + MCPHeadersRefreshRequiredReasonTtlExpired MCPHeadersRefreshRequiredReason = "ttl-expired" +) + // How the pending MCP OAuth request was completed type MCPOauthCompletionOutcome string @@ -3504,6 +3590,20 @@ const ( MCPOauthCompletionOutcomeToken MCPOauthCompletionOutcome = "token" ) +// Reason the runtime is requesting host-provided MCP OAuth credentials +type MCPOauthRequestReason string + +const ( + // Initial credentials are required before connecting to the MCP server. + MCPOauthRequestReasonInitial MCPOauthRequestReason = "initial" + // The server requires a new host authorization flow before continuing. + MCPOauthRequestReasonReauth MCPOauthRequestReason = "reauth" + // The current host-provided credential was rejected and a replacement is requested. + MCPOauthRequestReasonRefresh MCPOauthRequestReason = "refresh" + // The server requires a credential with additional scope or audience. + MCPOauthRequestReasonUpscope MCPOauthRequestReason = "upscope" +) + // Optional non-default OAuth grant type. When set to 'client_credentials', the OAuth flow runs headlessly using the client_id + keychain-stored secret (no browser, no callback server). type MCPOauthRequiredStaticClientConfigGrantType string @@ -3768,6 +3868,18 @@ const ( UserMessageAgentModeShell UserMessageAgentMode = "shell" ) +// How this user message was delivered to the agentic loop, relative to whether the loop was already running. This is the timing axis only; the message's origin (human vs. system/command/schedule/skill/etc.) is carried separately by `source`. A system-injected message has a delivery too — e.g. a background-task notification waking an idle agent is `idle`, the same mechanism as a human starting a fresh turn. +type UserMessageDelivery string + +const ( + // Delivered while the loop was idle; starts its own run immediately (a human's fresh turn, or a system notification waking an idle agent). + UserMessageDeliveryIdle UserMessageDelivery = "idle" + // Enqueued while the agent was busy; processed as its own run afterward. + UserMessageDeliveryQueued UserMessageDelivery = "queued" + // Injected into the current in-flight run while the agent was busy (immediate mode). + UserMessageDeliverySteering UserMessageDelivery = "steering" +) + // Hosting platform type of the repository (github or ado) type WorkingDirectoryContextHostType string diff --git a/go/session.go b/go/session.go index 851157ba87..2706aa81a6 100644 --- a/go/session.go +++ b/go/session.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "log" "sync" "time" @@ -61,6 +62,8 @@ type Session struct { toolHandlersM sync.RWMutex permissionHandler PermissionHandlerFunc permissionMux sync.RWMutex + mcpAuthHandler MCPAuthHandler + mcpAuthMu sync.RWMutex userInputHandler UserInputHandler userInputMux sync.RWMutex exitPlanModeHandler ExitPlanModeRequestHandler @@ -924,6 +927,45 @@ func (s *Session) getElicitationHandler() ElicitationHandler { return s.elicitationHandler } +func (s *Session) registerMCPAuthHandler(handler MCPAuthHandler) { + s.mcpAuthMu.Lock() + defer s.mcpAuthMu.Unlock() + s.mcpAuthHandler = handler +} + +func (s *Session) getMCPAuthHandler() MCPAuthHandler { + s.mcpAuthMu.RLock() + defer s.mcpAuthMu.RUnlock() + return s.mcpAuthHandler +} + +func (s *Session) handleMCPAuthRequest(request MCPAuthRequest) { + handler := s.getMCPAuthHandler() + if handler == nil { + return + } + + ctx := context.Background() + cancel := &rpc.MCPOauthPendingRequestResponseCancelled{} + result, err := handler(request, MCPAuthInvocation{SessionID: s.SessionID}) + if err != nil || result == nil || result.Kind == "cancelled" || result.Token == nil { + s.RPC.MCP.Oauth().HandlePendingRequest(ctx, &rpc.MCPOauthHandlePendingRequest{ + RequestID: request.RequestID, + Result: cancel, + }) + return + } + + s.RPC.MCP.Oauth().HandlePendingRequest(ctx, &rpc.MCPOauthHandlePendingRequest{ + RequestID: request.RequestID, + Result: &rpc.MCPOauthPendingRequestResponseToken{ + AccessToken: result.Token.AccessToken, + TokenType: result.Token.TokenType, + ExpiresIn: result.Token.ExpiresIn, + }, + }) +} + // handleElicitationRequest dispatches an elicitation.requested event to the registered handler // and sends the result back via the RPC layer. Auto-cancels on error. func (s *Session) handleElicitationRequest(elicitCtx ElicitationContext, requestID string) { @@ -1370,6 +1412,60 @@ func (s *Session) handleBroadcastEvent(event SessionEvent) { } s.executePermissionAndRespond(d.RequestID, d.PermissionRequest, handler) + case *MCPOauthRequiredData: + handler := s.getMCPAuthHandler() + if d.RequestID == "" { + return + } + if handler == nil { + log.Printf( + "Received MCP OAuth request without a registered MCP auth handler. SessionId=%s, RequestId=%s", + s.SessionID, + d.RequestID, + ) + return + } + var staticClientConfig *MCPAuthStaticClientConfig + if d.StaticClientConfig != nil { + var grantType string + if d.StaticClientConfig.GrantType != nil { + grantType = string(*d.StaticClientConfig.GrantType) + } + staticClientConfig = &MCPAuthStaticClientConfig{ + ClientID: d.StaticClientConfig.ClientID, + GrantType: grantType, + PublicClient: d.StaticClientConfig.PublicClient, + } + if d.StaticClientConfig.ClientSecret != nil { + staticClientConfig.ClientSecret = *d.StaticClientConfig.ClientSecret + } + } + request := MCPAuthRequest{ + RequestID: d.RequestID, + ServerName: d.ServerName, + ServerURL: d.ServerURL, + Reason: string(d.Reason), + StaticClientConfig: staticClientConfig, + } + if d.ResourceMetadata != nil { + request.ResourceMetadata = *d.ResourceMetadata + } + if d.WwwAuthenticateParams != nil { + var scope, oauthError string + if d.WwwAuthenticateParams.Scope != nil { + scope = *d.WwwAuthenticateParams.Scope + } + if d.WwwAuthenticateParams.Error != nil { + oauthError = *d.WwwAuthenticateParams.Error + } + request.WwwAuthenticateParams = &MCPAuthWwwAuthenticateParams{ + ResourceMetadataURL: d.WwwAuthenticateParams.ResourceMetadataURL, + Scope: scope, + Error: oauthError, + } + } + s.handleMCPAuthRequest(request) + case *CommandExecuteData: s.executeCommandAndRespond(d.RequestID, d.CommandName, d.Command, d.Args) diff --git a/go/session_test.go b/go/session_test.go index 654be6ce46..078001cdbb 100644 --- a/go/session_test.go +++ b/go/session_test.go @@ -60,6 +60,205 @@ func TestSession_SetModelOmitsContextTierWhenUnset(t *testing.T) { } } +func TestSession_MCPAuthRequestSendsHostToken(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.oauth.handlePendingRequest" { + errCh <- fmt.Errorf("expected session.mcp.oauth.handlePendingRequest, got %s", request.Method) + return + } + + paramsCh <- request.Params + + response := map[string]any{ + "jsonrpc": "2.0", + "id": json.RawMessage(request.ID), + "result": map[string]any{"success": true}, + } + data, err := json.Marshal(response) + if err != nil { + errCh <- err + return + } + if _, err := fmt.Fprintf(stdoutW, "Content-Length: %d\r\n\r\n%s", len(data), data); err != nil { + errCh <- err + } + }() + + session := &Session{ + SessionID: "session-1", + client: client, + RPC: rpc.NewSessionRPC(client, "session-1"), + } + var observedRequest MCPAuthRequest + session.registerMCPAuthHandler(func(request MCPAuthRequest, invocation MCPAuthInvocation) (*MCPAuthResult, error) { + observedRequest = request + if invocation.SessionID != "session-1" { + t.Fatalf("expected invocation session-1, got %s", invocation.SessionID) + } + if request.RequestID != "oauth-request" { + t.Fatalf("expected oauth-request, got %s", request.RequestID) + } + tokenType := "Bearer" + return &MCPAuthResult{ + Kind: "token", + Token: &MCPAuthToken{ + AccessToken: "host-token", + TokenType: &tokenType, + }, + }, nil + }) + resourceMetadataURL := "https://example.com/.well-known/oauth-protected-resource" + resourceMetadata := `{"resource":"https://example.com/mcp"}` + clientSecret := "static-secret" + grantType := rpc.MCPOauthRequiredStaticClientConfigGrantTypeClientCredentials + publicClient := false + session.handleBroadcastEvent(SessionEvent{ + Data: &MCPOauthRequiredData{ + RequestID: "oauth-request", + Reason: rpc.MCPOauthRequestReasonInitial, + ServerName: "oauth-server", + ServerURL: "https://example.com/mcp", + ResourceMetadata: &resourceMetadata, + StaticClientConfig: &MCPOauthRequiredStaticClientConfig{ + ClientID: "static-client", + ClientSecret: &clientSecret, + GrantType: &grantType, + PublicClient: &publicClient, + }, + WwwAuthenticateParams: &MCPOauthWwwAuthenticateParams{ + ResourceMetadataURL: &resourceMetadataURL, + }, + }, + }) + if observedRequest.ResourceMetadata != `{"resource":"https://example.com/mcp"}` { + t.Fatalf("expected resource metadata to be propagated, got %q", observedRequest.ResourceMetadata) + } + if observedRequest.Reason != "initial" { + t.Fatalf("expected initial reason, got %q", observedRequest.Reason) + } + if observedRequest.WwwAuthenticateParams == nil { + t.Fatal("expected WWW-Authenticate params to be propagated") + } + if observedRequest.StaticClientConfig == nil { + t.Fatal("expected static client config to be propagated") + } + if observedRequest.StaticClientConfig.ClientSecret != "static-secret" { + t.Fatalf("expected static client secret to be propagated, got %q", observedRequest.StaticClientConfig.ClientSecret) + } + + select { + case params := <-paramsCh: + if params["sessionId"] != "session-1" { + t.Fatalf("expected sessionId session-1, got %v", params["sessionId"]) + } + if params["requestId"] != "oauth-request" { + t.Fatalf("expected requestId oauth-request, got %v", params["requestId"]) + } + result, ok := params["result"].(map[string]any) + if !ok { + t.Fatalf("expected result object, got %T", params["result"]) + } + if result["kind"] != "token" { + t.Fatalf("expected token kind, got %v", result["kind"]) + } + if result["accessToken"] != "host-token" { + t.Fatalf("expected accessToken host-token, got %v", result["accessToken"]) + } + if result["tokenType"] != "Bearer" { + t.Fatalf("expected tokenType Bearer, got %v", result["tokenType"]) + } + case err := <-errCh: + t.Fatal(err) + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for MCP OAuth request") + } +} + +func TestMCPAuthRequestAllowsMissingOptionalMetadata(t *testing.T) { + request := MCPAuthRequest{RequestID: "oauth-request"} + if request.ResourceMetadata != "" { + t.Fatalf("expected no resource metadata, got %q", request.ResourceMetadata) + } + if request.WwwAuthenticateParams != nil { + t.Fatalf("expected no WWW-Authenticate params, got %#v", request.WwwAuthenticateParams) + } +} + +func TestMCPOauthRequiredDataAllowsOptionalMetadata(t *testing.T) { + var withMetadata rpc.MCPOauthRequiredData + if err := json.Unmarshal([]byte(`{ + "requestId": "oauth-request", + "reason": "initial", + "serverName": "oauth-server", + "serverUrl": "https://example.com/mcp", + "wwwAuthenticateParams": { + "resourceMetadataUrl": "https://example.com/.well-known/oauth-protected-resource" + }, + "resourceMetadata": "{\"resource\":\"https://example.com/mcp\"}", + "staticClientConfig": { + "clientId": "static-client", + "clientSecret": "static-secret", + "publicClient": false + } + }`), &withMetadata); err != nil { + t.Fatal(err) + } + if withMetadata.ResourceMetadata == nil || *withMetadata.ResourceMetadata != `{"resource":"https://example.com/mcp"}` { + t.Fatalf("expected resource metadata, got %#v", withMetadata.ResourceMetadata) + } + if withMetadata.WwwAuthenticateParams == nil { + t.Fatal("expected WWW-Authenticate params") + } + if withMetadata.StaticClientConfig == nil || withMetadata.StaticClientConfig.ClientSecret == nil || *withMetadata.StaticClientConfig.ClientSecret != "static-secret" { + t.Fatalf("expected static client secret, got %#v", withMetadata.StaticClientConfig) + } + + var withoutMetadata rpc.MCPOauthRequiredData + if err := json.Unmarshal([]byte(`{ + "requestId": "oauth-request", + "reason": "initial", + "serverName": "oauth-server", + "serverUrl": "https://example.com/mcp" + }`), &withoutMetadata); err != nil { + t.Fatal(err) + } + if withoutMetadata.ResourceMetadata != nil { + t.Fatalf("expected no resource metadata, got %#v", withoutMetadata.ResourceMetadata) + } + if withoutMetadata.WwwAuthenticateParams != nil { + t.Fatalf("expected no WWW-Authenticate params, got %#v", withoutMetadata.WwwAuthenticateParams) + } +} + func captureSetModelRequest(t *testing.T, opts *SetModelOptions) map[string]any { t.Helper() diff --git a/go/types.go b/go/types.go index 8a7df3c46a..fd623ab935 100644 --- a/go/types.go +++ b/go/types.go @@ -326,6 +326,53 @@ type PermissionInvocation struct { SessionID string } +// MCPAuthWwwAuthenticateParams contains parsed parameters from an MCP server's WWW-Authenticate response. +type MCPAuthWwwAuthenticateParams struct { + ResourceMetadataURL *string `json:"resourceMetadataUrl,omitempty"` + Scope string `json:"scope,omitempty"` + Error string `json:"error,omitempty"` +} + +// MCPAuthStaticClientConfig is static OAuth client configuration supplied by an MCP server. +type MCPAuthStaticClientConfig struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret,omitempty"` + GrantType string `json:"grantType,omitempty"` + PublicClient *bool `json:"publicClient,omitempty"` +} + +// MCPAuthRequest describes an MCP OAuth request that the SDK host can satisfy with a token. +type MCPAuthRequest struct { + RequestID string `json:"requestId"` + ServerName string `json:"serverName"` + ServerURL string `json:"serverUrl"` + Reason string `json:"reason"` + WwwAuthenticateParams *MCPAuthWwwAuthenticateParams `json:"wwwAuthenticateParams,omitempty"` + ResourceMetadata string `json:"resourceMetadata,omitempty"` + StaticClientConfig *MCPAuthStaticClientConfig `json:"staticClientConfig,omitempty"` +} + +// MCPAuthToken is host-provided OAuth token data for a pending MCP OAuth request. +type MCPAuthToken struct { + AccessToken string `json:"accessToken"` + TokenType *string `json:"tokenType,omitempty"` + ExpiresIn *int64 `json:"expiresIn,omitempty"` +} + +// MCPAuthResult is the result returned by an MCP auth request handler. +type MCPAuthResult struct { + Kind string + Token *MCPAuthToken +} + +// MCPAuthInvocation provides context about an MCP auth handler invocation. +type MCPAuthInvocation struct { + SessionID string +} + +// MCPAuthHandler handles MCP OAuth requests from the runtime. +type MCPAuthHandler func(request MCPAuthRequest, invocation MCPAuthInvocation) (*MCPAuthResult, error) + // UserInputRequest represents a request for user input from the agent type UserInputRequest struct { Question string @@ -975,6 +1022,10 @@ type SessionConfig struct { // When nil, permission requests are surfaced as events and left pending for the // consumer to resolve via pending permission RPCs. OnPermissionRequest PermissionHandlerFunc + // OnMCPAuthRequest is an optional handler for MCP OAuth requests from MCP servers. + // When provided, the SDK can satisfy MCP server OAuth requests with host-provided + // token data or cancellation. + OnMCPAuthRequest MCPAuthHandler // 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 @@ -1405,6 +1456,9 @@ type ResumeSessionConfig struct { // When nil, permission requests are surfaced as events and left pending for the // consumer to resolve via pending permission RPCs. OnPermissionRequest PermissionHandlerFunc + // OnMCPAuthRequest is an optional handler for MCP OAuth requests from MCP servers. + // See SessionConfig.OnMCPAuthRequest. + OnMCPAuthRequest MCPAuthHandler // 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 diff --git a/go/zsession_events.go b/go/zsession_events.go index 75a22cc997..83ac901d6e 100644 --- a/go/zsession_events.go +++ b/go/zsession_events.go @@ -9,6 +9,7 @@ import "github.com/github/copilot-sdk/go/rpc" type ( AbortData = rpc.AbortData AbortReason = rpc.AbortReason + AssistantIdleData = rpc.AssistantIdleData AssistantIntentData = rpc.AssistantIntentData AssistantMessageData = rpc.AssistantMessageData AssistantMessageDeltaData = rpc.AssistantMessageDeltaData @@ -96,8 +97,13 @@ type ( MCPAppToolCallCompleteError = rpc.MCPAppToolCallCompleteError MCPAppToolCallCompleteToolMeta = rpc.MCPAppToolCallCompleteToolMeta MCPAppToolCallCompleteToolMetaUI = rpc.MCPAppToolCallCompleteToolMetaUI + MCPHeadersRefreshCompletedData = rpc.MCPHeadersRefreshCompletedData + MCPHeadersRefreshCompletedOutcome = rpc.MCPHeadersRefreshCompletedOutcome + MCPHeadersRefreshRequiredData = rpc.MCPHeadersRefreshRequiredData + MCPHeadersRefreshRequiredReason = rpc.MCPHeadersRefreshRequiredReason MCPOauthCompletedData = rpc.MCPOauthCompletedData MCPOauthCompletionOutcome = rpc.MCPOauthCompletionOutcome + MCPOauthRequestReason = rpc.MCPOauthRequestReason MCPOauthRequiredData = rpc.MCPOauthRequiredData MCPOauthRequiredStaticClientConfig = rpc.MCPOauthRequiredStaticClientConfig MCPOauthRequiredStaticClientConfigGrantType = rpc.MCPOauthRequiredStaticClientConfigGrantType @@ -174,6 +180,7 @@ type ( RawSystemNotification = rpc.RawSystemNotification RawToolExecutionCompleteContent = rpc.RawToolExecutionCompleteContent ReasoningSummary = rpc.ReasoningSummary + ResponseBudgetConfig = rpc.ResponseBudgetConfig SamplingCompletedData = rpc.SamplingCompletedData SamplingRequestedData = rpc.SamplingRequestedData SessionAutopilotObjectiveChangedData = rpc.SessionAutopilotObjectiveChangedData @@ -282,6 +289,7 @@ type ( ToolExecutionPartialResultData = rpc.ToolExecutionPartialResultData ToolExecutionProgressData = rpc.ToolExecutionProgressData ToolExecutionStartData = rpc.ToolExecutionStartData + ToolExecutionStartShellToolInfo = rpc.ToolExecutionStartShellToolInfo ToolExecutionStartToolDescription = rpc.ToolExecutionStartToolDescription ToolExecutionStartToolDescriptionMeta = rpc.ToolExecutionStartToolDescriptionMeta ToolExecutionStartToolDescriptionMetaUI = rpc.ToolExecutionStartToolDescriptionMetaUI @@ -291,6 +299,7 @@ type ( UserInputRequestedData = rpc.UserInputRequestedData UserMessageAgentMode = rpc.UserMessageAgentMode UserMessageData = rpc.UserMessageData + UserMessageDelivery = rpc.UserMessageDelivery UserToolSessionApproval = rpc.UserToolSessionApproval UserToolSessionApprovalCommands = rpc.UserToolSessionApprovalCommands UserToolSessionApprovalCustomTool = rpc.UserToolSessionApprovalCustomTool @@ -368,8 +377,18 @@ const ( ExtensionsLoadedExtensionStatusStarting = rpc.ExtensionsLoadedExtensionStatusStarting HandoffSourceTypeLocal = rpc.HandoffSourceTypeLocal HandoffSourceTypeRemote = rpc.HandoffSourceTypeRemote + MCPHeadersRefreshCompletedOutcomeHeaders = rpc.MCPHeadersRefreshCompletedOutcomeHeaders + MCPHeadersRefreshCompletedOutcomeNone = rpc.MCPHeadersRefreshCompletedOutcomeNone + MCPHeadersRefreshCompletedOutcomeTimeout = rpc.MCPHeadersRefreshCompletedOutcomeTimeout + MCPHeadersRefreshRequiredReasonAuthFailed = rpc.MCPHeadersRefreshRequiredReasonAuthFailed + MCPHeadersRefreshRequiredReasonStartup = rpc.MCPHeadersRefreshRequiredReasonStartup + MCPHeadersRefreshRequiredReasonTtlExpired = rpc.MCPHeadersRefreshRequiredReasonTtlExpired MCPOauthCompletionOutcomeCancelled = rpc.MCPOauthCompletionOutcomeCancelled MCPOauthCompletionOutcomeToken = rpc.MCPOauthCompletionOutcomeToken + MCPOauthRequestReasonInitial = rpc.MCPOauthRequestReasonInitial + MCPOauthRequestReasonReauth = rpc.MCPOauthRequestReasonReauth + MCPOauthRequestReasonRefresh = rpc.MCPOauthRequestReasonRefresh + MCPOauthRequestReasonUpscope = rpc.MCPOauthRequestReasonUpscope MCPOauthRequiredStaticClientConfigGrantTypeClientCredentials = rpc.MCPOauthRequiredStaticClientConfigGrantTypeClientCredentials MCPServerSourceBuiltin = rpc.MCPServerSourceBuiltin MCPServerSourcePlugin = rpc.MCPServerSourcePlugin @@ -442,6 +461,7 @@ const ( ReasoningSummaryDetailed = rpc.ReasoningSummaryDetailed ReasoningSummaryNone = rpc.ReasoningSummaryNone SessionEventTypeAbort = rpc.SessionEventTypeAbort + SessionEventTypeAssistantIdle = rpc.SessionEventTypeAssistantIdle SessionEventTypeAssistantIntent = rpc.SessionEventTypeAssistantIntent SessionEventTypeAssistantMessage = rpc.SessionEventTypeAssistantMessage SessionEventTypeAssistantMessageDelta = rpc.SessionEventTypeAssistantMessageDelta @@ -469,6 +489,8 @@ const ( SessionEventTypeHookProgress = rpc.SessionEventTypeHookProgress SessionEventTypeHookStart = rpc.SessionEventTypeHookStart SessionEventTypeMCPAppToolCallComplete = rpc.SessionEventTypeMCPAppToolCallComplete + SessionEventTypeMCPHeadersRefreshCompleted = rpc.SessionEventTypeMCPHeadersRefreshCompleted + SessionEventTypeMCPHeadersRefreshRequired = rpc.SessionEventTypeMCPHeadersRefreshRequired SessionEventTypeMCPOauthCompleted = rpc.SessionEventTypeMCPOauthCompleted SessionEventTypeMCPOauthRequired = rpc.SessionEventTypeMCPOauthRequired SessionEventTypeModelCallFailure = rpc.SessionEventTypeModelCallFailure @@ -577,6 +599,9 @@ const ( UserMessageAgentModeInteractive = rpc.UserMessageAgentModeInteractive UserMessageAgentModePlan = rpc.UserMessageAgentModePlan UserMessageAgentModeShell = rpc.UserMessageAgentModeShell + UserMessageDeliveryIdle = rpc.UserMessageDeliveryIdle + UserMessageDeliveryQueued = rpc.UserMessageDeliveryQueued + UserMessageDeliverySteering = rpc.UserMessageDeliverySteering UserToolSessionApprovalKindCommands = rpc.UserToolSessionApprovalKindCommands UserToolSessionApprovalKindCustomTool = rpc.UserToolSessionApprovalKindCustomTool UserToolSessionApprovalKindExtensionManagement = rpc.UserToolSessionApprovalKindExtensionManagement diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 1a49941895..56a9ab4252 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -27,6 +27,7 @@ import com.github.copilot.generated.rpc.SessionInstalledPlugin; import com.github.copilot.generated.rpc.ConnectParams; import com.github.copilot.generated.rpc.ServerRpc; +import com.github.copilot.generated.rpc.SessionEventLogRegisterInterestParams; import com.github.copilot.rpc.DeleteSessionResponse; import com.github.copilot.rpc.GetAuthStatusResponse; import com.github.copilot.rpc.GetLastSessionIdResponse; @@ -638,14 +639,19 @@ public CompletableFuture createSession(SessionConfig config) { ? preRegisteredSessionHolder[0] : initializeSession.apply(returnedId); registeredIdHolder[0] = returnedId; + CompletableFuture interest = config.getOnMcpAuthRequest() != null + ? session.getRpc().eventLog.registerInterest( + new SessionEventLogRegisterInterestParams(returnedId, "mcp.oauth_required")) + : CompletableFuture.completedFuture(null); session.setWorkspacePath(response.workspacePath()); session.setCapabilities(response.capabilities()); session.setOpenCanvases(response.openCanvases()); - return updateSessionOptionsForMode(session, config.getSkipCustomInstructions().orElse(null), + return interest.thenCompose(unusedResult -> updateSessionOptionsForMode(session, + config.getSkipCustomInstructions().orElse(null), config.getCustomAgentsLocalOnly().orElse(null), config.getCoauthorEnabled().orElse(null), - config.getManageScheduleEnabled().orElse(null)).thenApply(v -> { + config.getManageScheduleEnabled().orElse(null))).thenApply(v -> { LoggingHelpers.logTiming(LOG, Level.FINE, "CopilotClient.createSession complete. Elapsed={Elapsed}, SessionId=" + session.getSessionId(), @@ -714,6 +720,10 @@ public CompletableFuture resumeSession(String sessionId, ResumeS if (extracted.transformCallbacks() != null) { session.registerTransformCallbacks(extracted.transformCallbacks()); } + CompletableFuture interest = config.getOnMcpAuthRequest() != null + ? session.getRpc().eventLog.registerInterest( + new SessionEventLogRegisterInterestParams(sessionId, "mcp.oauth_required")) + : CompletableFuture.completedFuture(null); var request = SessionRequestBuilder.buildResumeRequest(sessionId, config); if (extracted.wireSystemMessage() != config.getSystemMessage()) { @@ -760,7 +770,8 @@ public CompletableFuture resumeSession(String sessionId, ResumeS } long rpcNanos = System.nanoTime(); - return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class) + return interest.thenCompose( + unusedResult -> connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class)) .thenCompose(response -> { LoggingHelpers.logTiming(LOG, Level.FINE, "CopilotClient.resumeSession session resume request completed. Elapsed={Elapsed}, SessionId=" @@ -886,6 +897,7 @@ CompletableFuture updateSessionOptionsForMode(CopilotSession session, Bool null, // sandboxConfig null, // logInteractiveShells null, // envValueMode + null, // allowAllMcpServerInstructions null, // skillDirectories null, // disabledSkills null, // enableOnDemandInstructionDiscovery @@ -914,7 +926,8 @@ CompletableFuture updateSessionOptionsForMode(CopilotSession session, Bool null, // enableHostGitOperations null, // enableSessionStore null, // enableSkills - null // contextTier + null, // contextTier + null // responseBudget ); return session.getRpc().options.update(params).thenCompose(result -> { diff --git a/java/src/main/java/com/github/copilot/CopilotSession.java b/java/src/main/java/com/github/copilot/CopilotSession.java index 90f76b6df5..d33f3e197e 100644 --- a/java/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/src/main/java/com/github/copilot/CopilotSession.java @@ -33,6 +33,7 @@ import com.github.copilot.generated.rpc.SessionCommandsHandlePendingCommandParams; 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.ModelCapabilitiesOverride; import com.github.copilot.generated.rpc.ModelCapabilitiesOverrideLimits; import com.github.copilot.generated.rpc.ModelCapabilitiesOverrideSupports; @@ -49,6 +50,7 @@ import com.github.copilot.generated.CommandExecuteEvent; import com.github.copilot.generated.ElicitationRequestedEvent; import com.github.copilot.generated.ExternalToolRequestedEvent; +import com.github.copilot.generated.McpOauthRequiredEvent; import com.github.copilot.generated.PermissionRequestedEvent; import com.github.copilot.generated.SessionCanvasClosedEvent; import com.github.copilot.generated.SessionCanvasOpenedEvent; @@ -79,6 +81,9 @@ import com.github.copilot.rpc.HookInvocation; import com.github.copilot.rpc.InputOptions; import com.github.copilot.rpc.MessageOptions; +import com.github.copilot.rpc.McpAuthHandler; +import com.github.copilot.rpc.McpAuthRequest; +import com.github.copilot.rpc.McpAuthResult; import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.PermissionInvocation; import com.github.copilot.rpc.PermissionRequest; @@ -171,6 +176,7 @@ public final class CopilotSession implements AutoCloseable { private final Map commandHandlers = new ConcurrentHashMap<>(); private final Map bearerTokenProviders = new ConcurrentHashMap<>(); private final AtomicReference permissionHandler = new AtomicReference<>(); + private final AtomicReference mcpAuthHandler = new AtomicReference<>(); private final AtomicReference userInputHandler = new AtomicReference<>(); private final AtomicReference elicitationHandler = new AtomicReference<>(); private final AtomicReference exitPlanModeHandler = new AtomicReference<>(); @@ -839,6 +845,21 @@ private void handleBroadcastEventAsync(SessionEvent event) { } executePermissionAndRespondAsync(data.requestId(), MAPPER.convertValue(data.permissionRequest(), PermissionRequest.class), handler); + } else if (event instanceof McpOauthRequiredEvent authEvent) { + var data = authEvent.getData(); + if (data == null || data.requestId() == null) { + return; + } + McpAuthHandler handler = mcpAuthHandler.get(); + if (handler == null) { + LOG.warning(() -> "Received MCP OAuth request without a registered MCP auth handler. SessionId=" + + sessionId + ", RequestId=" + data.requestId()); + return; + } + executeMcpAuthAndRespondAsync( + new McpAuthRequest(sessionId, data.requestId(), data.serverName(), data.serverUrl(), data.reason(), + data.wwwAuthenticateParams(), data.resourceMetadata(), data.staticClientConfig()), + handler); } else if (event instanceof CommandExecuteEvent cmdEvent) { var data = cmdEvent.getData(); if (data == null || data.requestId() == null || data.commandName() == null) { @@ -1006,6 +1027,57 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques } } + private void executeMcpAuthAndRespondAsync(McpAuthRequest request, McpAuthHandler handler) { + Runnable task = () -> { + try { + handler.handle(request).thenAccept(result -> sendMcpAuthResponse(request.requestId(), result)) + .exceptionally(ex -> { + sendMcpAuthResponse(request.requestId(), McpAuthResult.cancelled()); + return null; + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error executing MCP auth handler for requestId=" + request.requestId(), e); + sendMcpAuthResponse(request.requestId(), McpAuthResult.cancelled()); + } + }; + try { + if (executor != null) { + CompletableFuture.runAsync(task, executor); + } else { + CompletableFuture.runAsync(task); + } + } catch (RejectedExecutionException e) { + LOG.log(Level.WARNING, + "Executor rejected MCP auth task for requestId=" + request.requestId() + "; running inline", e); + task.run(); + } + } + + private void sendMcpAuthResponse(String requestId, McpAuthResult result) { + try { + Object response; + if (result == null || result.isCancelled() || result.token() == null) { + response = Map.of("kind", "cancelled"); + } else { + var token = result.token(); + var tokenResponse = new java.util.HashMap(); + tokenResponse.put("kind", "token"); + tokenResponse.put("accessToken", token.accessToken()); + if (token.tokenType() != null) { + tokenResponse.put("tokenType", token.tokenType()); + } + if (token.expiresIn() != null) { + tokenResponse.put("expiresIn", token.expiresIn()); + } + response = tokenResponse; + } + getRpc().mcp.oauth.handlePendingRequest( + new SessionMcpOauthHandlePendingRequestParams(sessionId, requestId, response)); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending MCP auth response for requestId=" + requestId, e); + } + } + /** * Registers custom tool handlers for this session. *

@@ -1269,6 +1341,10 @@ void registerPermissionHandler(PermissionHandler handler) { permissionHandler.set(handler); } + void registerMcpAuthHandler(McpAuthHandler handler) { + mcpAuthHandler.set(handler); + } + /** * Handles a permission request from the Copilot CLI. *

diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index 6000bdef82..8a4b016e1b 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -323,6 +323,9 @@ static void configureSession(CopilotSession session, SessionConfig config) { if (config.getOnPermissionRequest() != null) { session.registerPermissionHandler(config.getOnPermissionRequest()); } + if (config.getOnMcpAuthRequest() != null) { + session.registerMcpAuthHandler(config.getOnMcpAuthRequest()); + } if (config.getOnUserInputRequest() != null) { session.registerUserInputHandler(config.getOnUserInputRequest()); } @@ -370,6 +373,9 @@ static void configureSession(CopilotSession session, ResumeSessionConfig config) if (config.getOnPermissionRequest() != null) { session.registerPermissionHandler(config.getOnPermissionRequest()); } + if (config.getOnMcpAuthRequest() != null) { + session.registerMcpAuthHandler(config.getOnMcpAuthRequest()); + } if (config.getOnUserInputRequest() != null) { session.registerUserInputHandler(config.getOnUserInputRequest()); } diff --git a/java/src/main/java/com/github/copilot/rpc/McpAuthHandler.java b/java/src/main/java/com/github/copilot/rpc/McpAuthHandler.java new file mode 100644 index 0000000000..e92ef1dd46 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/McpAuthHandler.java @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.concurrent.CompletableFuture; + +/** + * Handles MCP OAuth requests from the runtime. + * + * @since 1.0.0 + */ +@FunctionalInterface +public interface McpAuthHandler { + /** + * Handles an MCP OAuth request. + * + * @param request + * the MCP OAuth request context + * @return a future resolving to token data or cancellation + */ + CompletableFuture handle(McpAuthRequest request); +} diff --git a/java/src/main/java/com/github/copilot/rpc/McpAuthRequest.java b/java/src/main/java/com/github/copilot/rpc/McpAuthRequest.java new file mode 100644 index 0000000000..db8aa6043b --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/McpAuthRequest.java @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.github.copilot.generated.McpOauthRequiredStaticClientConfig; +import com.github.copilot.generated.McpOauthRequestReason; +import com.github.copilot.generated.McpOauthWWWAuthenticateParams; + +/** + * MCP OAuth request that the SDK host can satisfy with a host-acquired token. + * + * @since 1.0.0 + */ +public record McpAuthRequest(String sessionId, String requestId, String serverName, String serverUrl, + McpOauthRequestReason reason, McpOauthWWWAuthenticateParams wwwAuthenticateParams, String resourceMetadata, + McpOauthRequiredStaticClientConfig staticClientConfig) { +} diff --git a/java/src/main/java/com/github/copilot/rpc/McpAuthResult.java b/java/src/main/java/com/github/copilot/rpc/McpAuthResult.java new file mode 100644 index 0000000000..6b7fda34f9 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/McpAuthResult.java @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +/** + * Result returned by an MCP auth request handler. + * + * @since 1.0.0 + */ +public record McpAuthResult(boolean isCancelled, McpAuthToken token) { + /** + * Creates a token result. + * + * @param token + * the host-provided OAuth token data + * @return token result + */ + public static McpAuthResult token(McpAuthToken token) { + return new McpAuthResult(false, token); + } + + /** + * Creates a cancellation result. + * + * @return cancellation result + */ + public static McpAuthResult cancelled() { + return new McpAuthResult(true, null); + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/McpAuthToken.java b/java/src/main/java/com/github/copilot/rpc/McpAuthToken.java new file mode 100644 index 0000000000..3cf6748fbf --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/McpAuthToken.java @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +/** + * Host-provided OAuth token data for a pending MCP OAuth request. + * + * @since 1.0.0 + */ +public record McpAuthToken(String accessToken, String tokenType, Long expiresIn) { +} diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index e3e79eab01..48e333f05b 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -60,6 +60,7 @@ public class ResumeSessionConfig { private String contextTier; private ModelCapabilitiesOverride modelCapabilities; private PermissionHandler onPermissionRequest; + private McpAuthHandler onMcpAuthRequest; private UserInputHandler onUserInputRequest; private SessionHooks hooks; private String workingDirectory; @@ -635,6 +636,28 @@ public ResumeSessionConfig setOnPermissionRequest(PermissionHandler onPermission return this; } + /** + * Gets the MCP OAuth request handler. + * + * @return the handler, or {@code null} if not set + */ + @JsonIgnore + public McpAuthHandler getOnMcpAuthRequest() { + return onMcpAuthRequest; + } + + /** + * Sets the MCP OAuth request handler. + * + * @param onMcpAuthRequest + * the handler + * @return this config instance for method chaining + */ + public ResumeSessionConfig setOnMcpAuthRequest(McpAuthHandler onMcpAuthRequest) { + this.onMcpAuthRequest = onMcpAuthRequest; + return this; + } + /** * Gets the user input request handler. * @@ -1697,6 +1720,7 @@ public ResumeSessionConfig clone() { copy.onEvent = this.onEvent; copy.commands = this.commands != null ? new ArrayList<>(this.commands) : null; copy.onElicitationRequest = this.onElicitationRequest; + copy.onMcpAuthRequest = this.onMcpAuthRequest; copy.onExitPlanMode = this.onExitPlanMode; copy.onAutoModeSwitch = this.onAutoModeSwitch; copy.enableMcpApps = this.enableMcpApps; diff --git a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java index 38b357e7e3..e5e0e629e1 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -60,6 +60,7 @@ public class SessionConfig { private Boolean coauthorEnabled; private Boolean manageScheduleEnabled; private PermissionHandler onPermissionRequest; + private McpAuthHandler onMcpAuthRequest; private UserInputHandler onUserInputRequest; private SessionHooks hooks; private String workingDirectory; @@ -678,6 +679,31 @@ public SessionConfig setOnPermissionRequest(PermissionHandler onPermissionReques return this; } + /** + * Gets the MCP OAuth request handler. + * + * @return the handler, or {@code null} if not set + */ + @JsonIgnore + public McpAuthHandler getOnMcpAuthRequest() { + return onMcpAuthRequest; + } + + /** + * Sets the MCP OAuth request handler. + *

+ * When provided, the SDK can satisfy MCP server OAuth requests with + * host-provided token data or cancellation. + * + * @param onMcpAuthRequest + * the handler + * @return this config instance for method chaining + */ + public SessionConfig setOnMcpAuthRequest(McpAuthHandler onMcpAuthRequest) { + this.onMcpAuthRequest = onMcpAuthRequest; + return this; + } + /** * Gets the user input request handler. * @@ -1829,6 +1855,7 @@ public SessionConfig clone() { copy.onEvent = this.onEvent; copy.commands = this.commands != null ? new ArrayList<>(this.commands) : null; copy.onElicitationRequest = this.onElicitationRequest; + copy.onMcpAuthRequest = this.onMcpAuthRequest; copy.onExitPlanMode = this.onExitPlanMode; copy.onAutoModeSwitch = this.onAutoModeSwitch; copy.enableMcpApps = this.enableMcpApps; diff --git a/java/src/test/java/com/github/copilot/E2ETestContext.java b/java/src/test/java/com/github/copilot/E2ETestContext.java index 4089e10ff7..db008acbc1 100644 --- a/java/src/test/java/com/github/copilot/E2ETestContext.java +++ b/java/src/test/java/com/github/copilot/E2ETestContext.java @@ -288,6 +288,8 @@ public Map getEnvironment() { env.put("GH_CONFIG_DIR", homeDir.toString()); env.put("XDG_CONFIG_HOME", homeDir.toString()); env.put("XDG_STATE_HOME", homeDir.toString()); + env.put("COPILOT_MCP_APPS", "true"); + env.put("MCP_APPS", "true"); // Configure CONNECT proxy for HTTPS interception if available String connectUrl = proxy.getConnectProxyUrl(); @@ -438,7 +440,15 @@ private static Path findRepoRoot() throws IOException { } private static String getCliPath(Path repoRoot) throws IOException { - // Try environment variable first (explicit override) + Path localRuntimeCliPath = Paths.get( + "/Users/roji/.copilot/repos/copilot-worktrees/copilot-agent-runtime/roji-symmetrical-dollop/dist-cli/index.js"); + if (Files.exists(localRuntimeCliPath)) { + return localRuntimeCliPath.toString(); + } + + // Try environment variable after the temporary local runtime override. Maven + // injects COPILOT_CLI_PATH for tests, so checking it first would bypass the + // sibling runtime branch this E2E suite is validating against. String envPath = System.getenv("COPILOT_CLI_PATH"); if (envPath != null && !envPath.isEmpty()) { return envPath; diff --git a/java/src/test/java/com/github/copilot/McpAuthInterestRegistrationTest.java b/java/src/test/java/com/github/copilot/McpAuthInterestRegistrationTest.java new file mode 100644 index 0000000000..215eb18f14 --- /dev/null +++ b/java/src/test/java/com/github/copilot/McpAuthInterestRegistrationTest.java @@ -0,0 +1,287 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.copilot.generated.McpOauthRequiredEvent; +import com.github.copilot.rpc.CloudSessionOptions; +import com.github.copilot.rpc.CloudSessionRepository; +import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.McpAuthResult; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.ResumeSessionConfig; +import com.github.copilot.rpc.SessionConfig; + +class McpAuthInterestRegistrationTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void mcpOauthRequiredEventExposesOptionalResourceMetadata() throws Exception { + var data = MAPPER.readValue(""" + { + "requestId": "oauth-request", + "reason": "initial", + "serverName": "oauth-server", + "serverUrl": "https://example.com/mcp", + "wwwAuthenticateParams": { + "resourceMetadataUrl": "https://example.com/.well-known/oauth-protected-resource" + }, + "resourceMetadata": "{\\"resource\\":\\"https://example.com/mcp\\"}", + "staticClientConfig": { + "clientId": "static-client", + "clientSecret": "static-secret", + "grantType": "client_credentials", + "publicClient": false + } + } + """, McpOauthRequiredEvent.McpOauthRequiredEventData.class); + + assertEquals("{\"resource\":\"https://example.com/mcp\"}", data.resourceMetadata()); + assertNotNull(data.wwwAuthenticateParams()); + assertNotNull(data.staticClientConfig()); + assertEquals("static-secret", data.staticClientConfig().clientSecret()); + + var withoutMetadata = MAPPER.readValue(""" + { + "requestId": "oauth-request", + "reason": "initial", + "serverName": "oauth-server", + "serverUrl": "https://example.com/mcp" + } + """, McpOauthRequiredEvent.McpOauthRequiredEventData.class); + + assertNull(withoutMetadata.resourceMetadata()); + assertNull(withoutMetadata.wwwAuthenticateParams()); + } + + @Test + void createSessionRegistersMcpAuthInterestOnlyWhenHandlerConfigured() 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).setOnEvent(event -> { + })).get()) { + assertNotNull(session); + } + + assertNoMcpAuthInterest(server.requests()); + assertTrue(server.requests().stream().anyMatch(request -> "session.create".equals(request.method()) + && request.params().path("requestPermission").asBoolean())); + + server.clearRequests(); + + try (var session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnMcpAuthRequest(request -> java.util.concurrent.CompletableFuture + .completedFuture(McpAuthResult.cancelled()))) + .get()) { + assertNotNull(session); + } + + List requests = server.requests(); + assertEquals("session.create", requests.get(0).method()); + assertEquals("session.eventLog.registerInterest", requests.get(1).method()); + assertEquals("mcp.oauth_required", requests.get(1).params().path("eventType").asText()); + } + } + + @Test + void cloudCreateSessionRegistersMcpAuthInterestAfterCreateOnlyWhenHandlerConfigured() throws Exception { + try (var server = new RecordingRuntime(); + var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url()))) { + var cloud = new CloudSessionOptions().setRepository( + new CloudSessionRepository().setOwner("github").setName("copilot-sdk").setBranch("main")); + + try (var session = client + .createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setCloud(cloud)) + .get()) { + assertNotNull(session); + } + + assertNoMcpAuthInterest(server.requests()); + server.clearRequests(); + + try (var session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setCloud(cloud).setOnMcpAuthRequest(request -> java.util.concurrent.CompletableFuture + .completedFuture(McpAuthResult.cancelled()))) + .get()) { + assertNotNull(session); + } + + List requests = server.requests(); + assertEquals("session.create", requests.get(0).method()); + assertEquals("session.eventLog.registerInterest", requests.get(1).method()); + assertEquals("mcp.oauth_required", requests.get(1).params().path("eventType").asText()); + } + } + + @Test + void resumeSessionRegistersMcpAuthInterestOnlyWhenHandlerConfigured() throws Exception { + try (var server = new RecordingRuntime(); + var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url()))) { + try (var session = client.resumeSession("session-without-auth", new ResumeSessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setOnEvent(event -> { + })).get()) { + assertNotNull(session); + } + + assertNoMcpAuthInterest(server.requests()); + assertTrue(server.requests().stream().anyMatch(request -> "session.resume".equals(request.method()) + && request.params().path("requestPermission").asBoolean())); + + server.clearRequests(); + + try (var session = client.resumeSession("session-with-auth", + new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnMcpAuthRequest(request -> java.util.concurrent.CompletableFuture + .completedFuture(McpAuthResult.cancelled()))) + .get()) { + assertNotNull(session); + } + + List requests = server.requests(); + assertEquals("session.eventLog.registerInterest", requests.get(0).method()); + assertEquals("mcp.oauth_required", requests.get(0).params().path("eventType").asText()); + assertEquals("session.resume", requests.get(1).method()); + } + } + + 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()))); + } + + private record RpcRequest(String method, JsonNode params) { + } + + private static final class RecordingRuntime implements AutoCloseable { + private final ServerSocket listener; + private final Thread thread; + private final List requests = new CopyOnWriteArrayList<>(); + private volatile boolean running = true; + + RecordingRuntime() throws Exception { + listener = new ServerSocket(0); + thread = new Thread(this::run, "mcp-auth-interest-test-runtime"); + thread.setDaemon(true); + thread.start(); + } + + String url() { + return "127.0.0.1:" + listener.getLocalPort(); + } + + List requests() { + return List.copyOf(requests); + } + + void clearRequests() { + requests.clear(); + } + + @Override + public void close() throws Exception { + running = false; + listener.close(); + thread.join(2000); + } + + private void run() { + try (Socket socket = listener.accept()) { + var in = socket.getInputStream(); + var out = socket.getOutputStream(); + while (running) { + JsonNode message = readMessage(in); + if (message == null) { + return; + } + String method = message.path("method").asText(); + requests.add(new RpcRequest(method, message.path("params").deepCopy())); + sendResponse(out, message.path("id").asLong(), resultFor(method, message.path("params"))); + } + } catch (Exception ex) { + if (running) { + throw new RuntimeException(ex); + } + } + } + + private static JsonNode resultFor(String method, JsonNode params) { + ObjectNode result = MAPPER.createObjectNode(); + switch (method) { + case "connect" -> { + result.put("ok", true); + result.put("protocolVersion", 3); + result.put("version", "test"); + } + case "session.create", "session.resume" -> { + String sessionId = params.path("sessionId").asText("server-assigned-session"); + if (sessionId.isEmpty()) { + sessionId = "server-assigned-session"; + } + result.put("sessionId", sessionId); + result.putNull("workspacePath"); + result.putNull("capabilities"); + } + case "session.eventLog.registerInterest" -> result.put("id", "interest-1"); + case "session.options.update" -> result.put("success", true); + case "session.skills.reload", "session.destroy" -> { + } + default -> throw new IllegalStateException("Unexpected RPC method " + method); + } + return result; + } + + private static JsonNode readMessage(java.io.InputStream in) throws Exception { + StringBuilder header = new StringBuilder(); + int b; + while ((b = in.read()) != -1) { + header.append((char) b); + if (header.toString().endsWith("\r\n\r\n")) { + break; + } + } + if (b == -1) { + return null; + } + int contentLength = 0; + for (String line : header.toString().split("\r\n")) { + int colon = line.indexOf(':'); + if (colon > 0 && "Content-Length".equals(line.substring(0, colon))) { + contentLength = Integer.parseInt(line.substring(colon + 1).trim()); + } + } + byte[] body = in.readNBytes(contentLength); + return MAPPER.readTree(body); + } + + private static void sendResponse(OutputStream out, long id, JsonNode result) throws Exception { + ObjectNode response = MAPPER.createObjectNode(); + response.put("jsonrpc", "2.0"); + response.put("id", id); + response.set("result", result); + byte[] body = MAPPER.writeValueAsBytes(response); + out.write(("Content-Length: " + body.length + "\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(body); + out.flush(); + } + } +} diff --git a/java/src/test/java/com/github/copilot/McpOAuthE2ETest.java b/java/src/test/java/com/github/copilot/McpOAuthE2ETest.java new file mode 100644 index 0000000000..9b7bc156d1 --- /dev/null +++ b/java/src/test/java/com/github/copilot/McpOAuthE2ETest.java @@ -0,0 +1,291 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.McpOauthRequestReason; +import com.github.copilot.generated.rpc.SessionMcpAppsCallToolParams; +import com.github.copilot.generated.rpc.McpServerStatus; +import com.github.copilot.generated.rpc.SessionMcpListToolsParams; +import com.github.copilot.rpc.McpAuthResult; +import com.github.copilot.rpc.McpAuthToken; +import com.github.copilot.rpc.McpHttpServerConfig; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.SessionConfig; + +public class McpOAuthE2ETest { + private static final String EXPECTED_TOKEN = "sdk-host-token"; + private static final String REFRESH_TOKEN = EXPECTED_TOKEN + "-refresh"; + private static final String UPSCOPE_TOKEN = EXPECTED_TOKEN + "-upscope"; + private static final String REAUTH_TOKEN = EXPECTED_TOKEN + "-reauth"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + @Test + void testShouldSatisfyMcpOauthUsingHostProvidedToken() throws Exception { + try (var oauthServer = OAuthMcpServer.start(ctx.getRepoRoot())) { + var serverName = "oauth-protected-mcp"; + var observedRequest = new java.util.concurrent.atomic.AtomicReference(); + + try (var client = ctx.createClient(); + var session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setOnMcpAuthRequest(request -> { + observedRequest.set(request); + return java.util.concurrent.CompletableFuture.completedFuture( + McpAuthResult.token(new McpAuthToken(EXPECTED_TOKEN, "Bearer", 3600L))); + }).setMcpServers(Map.of(serverName, new McpHttpServerConfig() + .setUrl(oauthServer.url() + "/mcp").setTools(List.of("*"))))) + .get()) { + waitForMcpServerStatus(session, serverName, McpServerStatus.CONNECTED, observedRequest); + var tools = session.getRpc().mcp.listTools(new SessionMcpListToolsParams(null, serverName)).get(30, + TimeUnit.SECONDS); + assertTrue(tools.tools().stream().anyMatch(tool -> "whoami".equals(tool.name()))); + } + + var request = observedRequest.get(); + assertNotNull(request, "MCP auth handler should be invoked"); + assertEquals(serverName, request.serverName()); + assertEquals(oauthServer.url() + "/mcp", request.serverUrl()); + assertEquals(McpOauthRequestReason.INITIAL, request.reason()); + assertNotNull(request.wwwAuthenticateParams()); + assertEquals(oauthServer.url() + "/.well-known/oauth-protected-resource", + request.wwwAuthenticateParams().resourceMetadataUrl()); + assertEquals("mcp.read", request.wwwAuthenticateParams().scope()); + assertEquals("invalid_token", request.wwwAuthenticateParams().error()); + assertEquals(oauthServer.url() + "/mcp", + MAPPER.readTree(request.resourceMetadata()).path("resource").asText()); + + var requests = oauthServer.requests(); + assertTrue(requests.stream().anyMatch(record -> record.authorization() == null)); + assertTrue( + requests.stream().anyMatch(record -> ("Bearer " + EXPECTED_TOKEN).equals(record.authorization()))); + } + } + + @Test + void testShouldRequestReplacementTokensAcrossMcpOauthLifecycle() throws Exception { + try (var oauthServer = OAuthMcpServer.start(ctx.getRepoRoot())) { + var serverName = "oauth-lifecycle-mcp"; + var observedReasons = new CopyOnWriteArrayList(); + var refreshCount = new java.util.concurrent.atomic.AtomicInteger(); + + try (var client = ctx.createClient(); + var session = client.createSession(new SessionConfig().setEnableMcpApps(true) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setOnMcpAuthRequest(request -> { + observedReasons.add(request.reason()); + var result = switch (request.reason()) { + case REFRESH -> { + assertNotNull(request.wwwAuthenticateParams()); + assertNull(request.wwwAuthenticateParams().resourceMetadataUrl()); + assertEquals("invalid_token", request.wwwAuthenticateParams().error()); + if (refreshCount.incrementAndGet() > 1) { + yield McpAuthResult.cancelled(); + } + yield McpAuthResult.token(new McpAuthToken(REFRESH_TOKEN, null, null)); + } + case UPSCOPE -> { + assertNotNull(request.wwwAuthenticateParams()); + assertEquals(oauthServer.url() + "/.well-known/oauth-protected-resource", + request.wwwAuthenticateParams().resourceMetadataUrl()); + assertEquals("mcp.write", request.wwwAuthenticateParams().scope()); + assertEquals("insufficient_scope", request.wwwAuthenticateParams().error()); + yield McpAuthResult.token(new McpAuthToken(UPSCOPE_TOKEN, null, null)); + } + case REAUTH -> McpAuthResult.token(new McpAuthToken(REAUTH_TOKEN, null, null)); + default -> McpAuthResult.token(new McpAuthToken(EXPECTED_TOKEN, null, null)); + }; + return java.util.concurrent.CompletableFuture.completedFuture(result); + }).setMcpServers(Map.of(serverName, new McpHttpServerConfig() + .setUrl(oauthServer.url() + "/mcp").setTools(List.of("*"))))) + .get()) { + waitForMcpServerStatus(session, serverName, McpServerStatus.CONNECTED, + new java.util.concurrent.atomic.AtomicReference<>()); + callWhoami(session, serverName, "refresh"); + callWhoami(session, serverName, "upscope"); + callWhoami(session, serverName, "reauth"); + } + + assertEquals(List.of(McpOauthRequestReason.INITIAL, McpOauthRequestReason.REFRESH, + McpOauthRequestReason.UPSCOPE, McpOauthRequestReason.REFRESH, McpOauthRequestReason.REAUTH), + observedReasons); + + var requests = oauthServer.requests(); + assertTrue( + requests.stream().anyMatch(record -> ("Bearer " + REFRESH_TOKEN).equals(record.authorization()))); + assertTrue( + requests.stream().anyMatch(record -> ("Bearer " + UPSCOPE_TOKEN).equals(record.authorization()))); + assertTrue(requests.stream().anyMatch(record -> ("Bearer " + REAUTH_TOKEN).equals(record.authorization()))); + } + } + + @Test + void testShouldCancelPendingMcpOauthRequest() throws Exception { + try (var oauthServer = OAuthMcpServer.start(ctx.getRepoRoot())) { + var serverName = "oauth-cancelled-mcp"; + var observedRequest = new java.util.concurrent.atomic.AtomicReference(); + + try (var client = ctx.createClient(); + var session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setOnMcpAuthRequest(request -> { + observedRequest.set(request); + return java.util.concurrent.CompletableFuture + .completedFuture(McpAuthResult.cancelled()); + }).setMcpServers(Map.of(serverName, new McpHttpServerConfig() + .setUrl(oauthServer.url() + "/mcp").setTools(List.of("*"))))) + .get()) { + waitForMcpServerStatus(session, serverName, McpServerStatus.FAILED, observedRequest); + } + + var request = observedRequest.get(); + assertNotNull(request, "MCP auth handler should be invoked"); + assertEquals(serverName, request.serverName()); + assertEquals(McpOauthRequestReason.INITIAL, request.reason()); + } + } + + private static void callWhoami(CopilotSession session, String serverName, String scenario) throws Exception { + var result = session.getRpc().mcp.apps.callTool( + new SessionMcpAppsCallToolParams(null, serverName, "whoami", Map.of("scenario", scenario), serverName)) + .get(30, TimeUnit.SECONDS); + var content = result.path("content"); + assertEquals(1, content.size()); + assertEquals("oauth-test-user", content.get(0).path("text").asText()); + } + + private static void waitForMcpServerStatus(CopilotSession session, String serverName, McpServerStatus status, + java.util.concurrent.atomic.AtomicReference observedRequest) + throws Exception { + var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(60); + var lastStatus = ""; + while (System.nanoTime() < deadline) { + var result = session.getRpc().mcp.list().get(5, TimeUnit.SECONDS); + var server = result.servers().stream().filter(candidate -> serverName.equals(candidate.name())).findFirst(); + if (server.isPresent()) { + lastStatus = String.valueOf(server.get().status()); + } + if (server.isPresent() && status.equals(server.get().status())) { + return; + } + Thread.sleep(200); + } + fail(serverName + " did not reach " + status + "; last status was " + lastStatus + "; auth handler invoked=" + + (observedRequest.get() != null)); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record OAuthMcpRequest(String authorization) { + } + + private record OAuthMcpServer(Process process, String url) implements AutoCloseable { + static OAuthMcpServer start(Path repoRoot) throws Exception { + var script = repoRoot.resolve("test").resolve("harness").resolve("test-mcp-oauth-server.mjs"); + var processBuilder = new ProcessBuilder(resolveExecutable("node"), script.toString()); + processBuilder.environment().put("EXPECTED_TOKEN", EXPECTED_TOKEN); + var process = processBuilder.start(); + var stderr = new StringBuilder(); + Thread stderrThread = new Thread(() -> { + try (var reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + reader.lines().forEach(stderr::append); + } catch (IOException ex) { + stderr.append(ex.getMessage()); + } + }); + stderrThread.setDaemon(true); + stderrThread.start(); + try (var reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (System.nanoTime() < deadline) { + if (reader.ready()) { + var line = reader.readLine(); + if (line != null && line.startsWith("Listening: ")) { + return new OAuthMcpServer(process, line.substring("Listening: ".length())); + } + } + Thread.sleep(50); + } + } + process.destroyForcibly(); + throw new AssertionError("Timed out waiting for OAuth MCP server: " + stderr); + } + + List requests() throws Exception { + var client = HttpClient.newHttpClient(); + var response = client.send(HttpRequest.newBuilder(URI.create(url + "/__requests")) + .timeout(Duration.ofSeconds(10)).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + return MAPPER.readValue(response.body(), new TypeReference>() { + }); + } + + private static String resolveExecutable(String executable) { + var path = System.getenv("PATH"); + if (path == null || path.isBlank()) { + throw new IllegalStateException("PATH is not configured; cannot find " + executable); + } + + var extensions = isWindows() + ? System.getenv().getOrDefault("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";") + : new String[]{""}; + for (var directory : path.split(java.util.regex.Pattern.quote(File.pathSeparator))) { + if (directory.isBlank()) { + continue; + } + for (var extension : extensions) { + var candidate = Path.of(directory).resolve(executable + extension).toAbsolutePath().normalize(); + if (Files.isRegularFile(candidate) && Files.isExecutable(candidate)) { + return candidate.toString(); + } + } + } + throw new IllegalStateException("Could not find " + executable + " on PATH."); + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).contains("win"); + } + + @Override + public void close() { + process.destroyForcibly(); + } + } +} diff --git a/java/src/test/java/com/github/copilot/SessionEventHandlingTest.java b/java/src/test/java/com/github/copilot/SessionEventHandlingTest.java index 3ca56b817d..6e80b7593c 100644 --- a/java/src/test/java/com/github/copilot/SessionEventHandlingTest.java +++ b/java/src/test/java/com/github/copilot/SessionEventHandlingTest.java @@ -180,7 +180,7 @@ void testHandlerReceivesCorrectEventData() { SessionStartEvent startEvent = createSessionStartEvent(); startEvent.setData(new SessionStartEvent.SessionStartEventData("my-session-123", null, null, null, null, null, - null, null, null, null, null, null, null)); + null, null, null, null, null, null, null, null)); dispatchEvent(startEvent); AssistantMessageEvent msgEvent = createAssistantMessageEvent("Test content"); @@ -857,7 +857,7 @@ private SessionStartEvent createSessionStartEvent() { private SessionStartEvent createSessionStartEvent(String sessionId) { var event = new SessionStartEvent(); var data = new SessionStartEvent.SessionStartEventData(sessionId, null, null, null, null, null, null, null, - null, null, null, null, null); + null, null, null, null, null, null); event.setData(data); return event; } diff --git a/java/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java b/java/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java index b5e83f17dc..7df3562e32 100644 --- a/java/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java +++ b/java/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java @@ -803,7 +803,7 @@ void modelsListResult_nested() { var limits = new ModelCapabilitiesLimits(100000L, 8192L, 128000L, null); var capabilities = new ModelCapabilities(supports, limits); var policy = new ModelPolicy(ModelPolicyState.ENABLED, null); - var billing = new ModelBilling(1.0, null); + var billing = new ModelBilling(1.0, null, null); var modelItem = new Model("gpt-5", "GPT-5", capabilities, policy, billing, null, null, null, null); var result = new ModelsListResult(List.of(modelItem)); diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 53686a6ca3..613985103f 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1326,7 +1326,8 @@ export class CopilotClient { sessionId, this.connection!, undefined, - this.onGetTraceContext + this.onGetTraceContext, + { mcpAuthHandler: config.onMcpAuthRequest } ); s.registerTools(config.tools); s.registerCanvases(config.canvases); @@ -1473,6 +1474,12 @@ export class CopilotClient { session = initializeSession(returnedSessionId); registeredId = returnedSessionId; } + if (config.onMcpAuthRequest) { + await this.connection!.sendRequest("session.eventLog.registerInterest", { + sessionId: returnedSessionId, + eventType: "mcp.oauth_required", + }); + } session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); @@ -1522,7 +1529,8 @@ export class CopilotClient { sessionId, this.connection!, undefined, - this.onGetTraceContext + this.onGetTraceContext, + { mcpAuthHandler: config.onMcpAuthRequest } ); session.registerTools(config.tools); session.registerCanvases(config.canvases); @@ -1567,6 +1575,12 @@ export class CopilotClient { } this.sessions.set(sessionId, session); this.setupSessionFs(session, config); + if (config.onMcpAuthRequest) { + await this.connection!.sendRequest("session.eventLog.registerInterest", { + sessionId, + eventType: "mcp.oauth_required", + }); + } const toolFilterOptions = this.resolveToolFilterOptions(config); diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 38f77412d9..b3339fffef 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -5,7 +5,7 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; -import type { AbortReason, Attachment, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpServerSource, McpServerStatus, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionEvent, SessionMode, ShutdownType, SkillSource, UserToolSessionApproval } from "./session-events.js"; +import type { AbortReason, Attachment, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpServerSource, McpServerStatus, PermissionPromptRequest, PermissionRule, ReasoningSummary, ResponseBudgetConfig, SessionEvent, SessionMode, ShutdownType, SkillSource, UserToolSessionApproval } from "./session-events.js"; /** * Initial authentication info for the session. @@ -681,6 +681,26 @@ export type McpServerConfigHttpOauthGrantType = | "authorization_code" /** Headless client credentials flow using the configured OAuth client. */ | "client_credentials"; +/** + * Host response: supply dynamic headers or decline this refresh. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpHeadersHandlePendingHeadersRefreshRequest". + */ +/** @experimental */ +export type McpHeadersHandlePendingHeadersRefreshRequest = + | { + /** + * Headers to overlay onto the MCP request. Dynamic headers override static config headers but do not replace SDK-managed request headers. + */ + headers: { + [k: string]: string | undefined; + }; + kind: "headers"; + } + | { + kind: "none"; + }; /** * Host response to the pending OAuth request. * @@ -698,10 +718,6 @@ export type McpOauthPendingRequestResponse = * OAuth token type. Defaults to Bearer when omitted. */ tokenType?: string; - /** - * Refresh token supplied by the host, if available. - */ - refreshToken?: string; /** * Token lifetime in seconds, if known. */ @@ -1580,7 +1596,7 @@ export type SkillDiscoveryScope = /** A configured custom skill directory. */ | "custom"; /** - * Result of invoking the slash command (text output, prompt to send to the agent, or completion). + * Result of invoking the slash command (text output, prompt to send to the agent, completion, or subcommand selection). * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SlashCommandInvocationResult". @@ -1609,6 +1625,14 @@ export type SubagentSettings = { * Names of subagents the user has turned off; they cannot be dispatched */ disabledSubagents?: string[]; + /** + * Maximum number of subagents that can run concurrently; applies to usage-based billing users only + */ + maxConcurrency?: number; + /** + * Maximum subagent nesting depth; applies to usage-based billing users only + */ + maxDepth?: number; } | null; /** * Context tier override for matching subagents @@ -5539,6 +5563,33 @@ export interface McpFilteredServer { */ enterpriseName?: string; } +/** + * MCP headers refresh request id and the host response. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpHeadersHandlePendingHeadersRefreshRequestRequest". + */ +/** @experimental */ +export interface McpHeadersHandlePendingHeadersRefreshRequestRequest { + /** + * Headers refresh request identifier from mcp.headers_refresh_required + */ + requestId: string; + result: McpHeadersHandlePendingHeadersRefreshRequest; +} +/** + * Indicates whether the pending MCP headers refresh response was accepted. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpHeadersHandlePendingHeadersRefreshRequestResult". + */ +/** @experimental */ +export interface McpHeadersHandlePendingHeadersRefreshRequestResult { + /** + * Whether the response was accepted. False if the request was unknown, timed out, or already resolved. + */ + success: boolean; +} /** * Host-level state, omitted when no MCP host is initialized. * @@ -6357,6 +6408,10 @@ export interface ModelBilling { */ multiplier?: number; tokenPrices?: ModelBillingTokenPrices; + /** + * Whole-number percentage discount (0-100) applied to usage billed through this model. Populated for the synthetic `auto` model, where requests routed by auto-mode are billed at a reduced rate; absent for concrete models. + */ + discountPercent?: number; } /** * Token-level pricing information for this model @@ -10617,6 +10672,10 @@ export interface SessionOpenOptions { */ logInteractiveShells?: boolean; envValueMode?: SessionOpenOptionsEnvValueMode; + /** + * Whether to include instructions from every MCP server in the system prompt instead of only allowlisted servers. + */ + allowAllMcpServerInstructions?: boolean; /** * Additional directories to search for skills. */ @@ -10684,6 +10743,7 @@ export interface SessionOpenOptions { */ maxInlineBinaryBytes?: number; modelCapabilitiesOverrides?: ModelCapabilitiesOverride; + responseBudget?: ResponseBudgetConfig; /** * Runtime context discriminator for agent filtering. */ @@ -11580,6 +11640,10 @@ export interface SessionUpdateOptionsParams { */ logInteractiveShells?: boolean; envValueMode?: OptionsUpdateEnvValueMode; + /** + * Whether to include instructions from every MCP server in the system prompt instead of only allowlisted servers. + */ + allowAllMcpServerInstructions?: boolean; /** * Additional directories to search for skills. */ @@ -11695,6 +11759,10 @@ export interface SessionUpdateOptionsParams { */ enableSkills?: boolean; contextTier?: OptionsUpdateContextTier; + /** + * Optional experimental response budget limits. Pass null to clear the response budget. + */ + responseBudget?: ResponseBudgetConfig | null; } /** * Indicates whether the session options patch was applied successfully. @@ -15018,6 +15086,18 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin connection.sendRequest("session.mcp.oauth.login", { sessionId, ...params }), }, /** @experimental */ + headers: { + /** + * Responds to a pending MCP dynamic headers refresh request. Hosts that subscribe to `mcp.headers_refresh_required` use this to provide short-lived per-server headers or to indicate that no dynamic headers are available for this refresh. + * + * @param params MCP headers refresh request id and the host response. + * + * @returns Indicates whether the pending MCP headers refresh response was accepted. + */ + handlePendingHeadersRefreshRequest: async (params: McpHeadersHandlePendingHeadersRefreshRequestRequest): Promise => + connection.sendRequest("session.mcp.headers.handlePendingHeadersRefreshRequest", { sessionId, ...params }), + }, + /** @experimental */ apps: { /** * Fetch an MCP resource (typically a `ui://` MCP App bundle, per SEP-1865) from a connected server. Requires the `mcp-apps` session capability. @@ -15218,7 +15298,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin * * @param params Slash command name and optional raw input string to invoke. * - * @returns Result of invoking the slash command (text output, prompt to send to the agent, or completion). + * @returns Result of invoking the slash command (text output, prompt to send to the agent, completion, or subcommand selection). */ invoke: async (params: CommandsInvokeRequest): Promise => connection.sendRequest("session.commands.invoke", { sessionId, ...params }), diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 96a3bddac7..08c9fb3f84 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -45,6 +45,7 @@ export type SessionEvent = | AssistantMessageStartEvent | AssistantMessageDeltaEvent | AssistantTurnEndEvent + | AssistantIdleEvent | AssistantUsageEvent | ModelCallFailureEvent | AbortEvent @@ -75,6 +76,8 @@ export type SessionEvent = | SamplingCompletedEvent | McpOauthRequiredEvent | McpOauthCompletedEvent + | McpHeadersRefreshRequiredEvent + | McpHeadersRefreshCompletedEvent | CustomNotificationEvent | ExternalToolRequestedEvent | ExternalToolCompletedEvent @@ -234,6 +237,16 @@ export type AttachmentGitHubReferenceType = | "pr" /** GitHub discussion reference. */ | "discussion"; +/** + * How this user message was delivered to the agentic loop, relative to whether the loop was already running. This is the timing axis only; the message's origin (human vs. system/command/schedule/skill/etc.) is carried separately by `source`. A system-injected message has a delivery too — e.g. a background-task notification waking an idle agent is `idle`, the same mechanism as a human starting a fresh turn. + */ +export type UserMessageDelivery = + /** Delivered while the loop was idle; starts its own run immediately (a human's fresh turn, or a system notification waking an idle agent). */ + | "idle" + /** Injected into the current in-flight run while the agent was busy (immediate mode). */ + | "steering" + /** Enqueued while the agent was busy; processed as its own run afterward. */ + | "queued"; /** * The system that produced a citation. */ @@ -507,6 +520,18 @@ export type ElicitationCompletedAction = | "decline" /** The user dismissed the request. */ | "cancel"; +/** + * Reason the runtime is requesting host-provided MCP OAuth credentials + */ +export type McpOauthRequestReason = + /** Initial credentials are required before connecting to the MCP server. */ + | "initial" + /** The current host-provided credential was rejected and a replacement is requested. */ + | "refresh" + /** The server requires a new host authorization flow before continuing. */ + | "reauth" + /** The server requires a credential with additional scope or audience. */ + | "upscope"; /** * How the pending MCP OAuth request was completed */ @@ -515,6 +540,26 @@ export type McpOauthCompletionOutcome = | "token" /** The request completed without an OAuth provider. */ | "cancelled"; +/** + * Why dynamic headers are being requested. + */ +export type McpHeadersRefreshRequiredReason = + /** The transport is making its first dynamic header request for this server. */ + | "startup" + /** The previously cached dynamic headers expired. */ + | "ttl-expired" + /** The server returned 401 and stale dynamic headers were invalidated. */ + | "auth-failed"; +/** + * How the pending MCP headers refresh request resolved. + */ +export type McpHeadersRefreshCompletedOutcome = + /** The host supplied dynamic headers. */ + | "headers" + /** The host responded with no dynamic headers. */ + | "none" + /** No response arrived within the bounded window. */ + | "timeout"; /** * The user's auto-mode-switch choice */ @@ -684,6 +729,7 @@ export interface StartData { * Whether this session supports remote steering via GitHub */ remoteSteerable?: boolean; + responseBudget?: ResponseBudgetConfig; /** * Model selected at session creation time, if any */ @@ -735,6 +781,19 @@ export interface WorkingDirectoryContext { */ repositoryHost?: string; } +/** + * Optional response budget limits. + */ +export interface ResponseBudgetConfig { + /** + * Maximum AI Credits allowed while responding to one top-level user message. + */ + maxAiCredits?: number; + /** + * Maximum model-call iterations allowed while responding to one top-level user message. + */ + maxModelIterations?: number; +} /** * Session event "session.resume". Session resume metadata including current context and event count */ @@ -799,6 +858,10 @@ export interface ResumeData { * Whether this session supports remote steering via GitHub */ remoteSteerable?: boolean; + /** + * Response budget limits currently configured at resume time; null when no budget is active + */ + responseBudget?: ResponseBudgetConfig | null; /** * ISO 8601 timestamp when the session was resumed */ @@ -2322,6 +2385,7 @@ export interface UserMessageData { * The user's message text as displayed in the timeline */ content: string; + delivery?: UserMessageDelivery; /** * CAPI interaction ID for correlating this user message with its turn */ @@ -3219,6 +3283,45 @@ export interface AssistantTurnEndData { */ turnId: string; } +/** + * Session event "assistant.idle". Payload emitted whenever the main agent's processing loop goes idle, including while related background work (running agents or in-flight attached shell commands) is still pending and the session-level idle event is therefore deferred + */ +export interface AssistantIdleEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: AssistantIdleData; + /** + * Always true for events that are transient and not persisted to the session event log on disk. + */ + ephemeral: true; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * Type discriminator. Always "assistant.idle". + */ + type: "assistant.idle"; +} +/** + * Payload emitted whenever the main agent's processing loop goes idle, including while related background work (running agents or in-flight attached shell commands) is still pending and the session-level idle event is therefore deferred + */ +export interface AssistantIdleData { + /** + * True when the preceding agentic loop was cancelled via abort signal + */ + aborted?: boolean; +} /** * Session event "assistant.usage". LLM API call usage metrics including tokens, costs, quotas, and billing information */ @@ -3712,6 +3815,7 @@ export interface ToolExecutionStartData { * Tool call ID of the parent tool invocation when this event originates from a sub-agent */ parentToolCallId?: string; + shellToolInfo?: ToolExecutionStartShellToolInfo; /** * Unique identifier for this tool call */ @@ -3726,6 +3830,19 @@ export interface ToolExecutionStartData { */ turnId?: string; } +/** + * Shell-aware path hints for a shell tool's command, captured at start time so consumers can snapshot a file's pre-image before the tool runs. + */ +export interface ToolExecutionStartShellToolInfo { + /** + * Whether the command includes a file write redirection (e.g., > or >>). + */ + hasWriteFileRedirection: boolean; + /** + * File paths the command may read or write, derived from the command at start time. Produced by the same shell-aware extractor as PermissionRequestShell.possiblePaths, so it is present even when the command is auto-approved and no permission request fires. + */ + possiblePaths: string[]; +} /** * Tool definition metadata, present for MCP tools with MCP Apps support */ @@ -6407,6 +6524,7 @@ export interface McpOauthRequiredEvent { * OAuth authentication request for an MCP server */ export interface McpOauthRequiredData { + reason: McpOauthRequestReason; /** * Unique identifier for this OAuth request; used to respond via session.mcp.oauth.handlePendingRequest */ @@ -6434,6 +6552,10 @@ export interface McpOauthRequiredStaticClientConfig { * OAuth client ID for the server */ clientId: string; + /** + * Optional OAuth client secret for confidential static clients, when the runtime can resolve one + */ + clientSecret?: string; /** * Optional non-default OAuth grant type. When set to 'client_credentials', the OAuth flow runs headlessly using the client_id + keychain-stored secret (no browser, no callback server). */ @@ -6452,9 +6574,9 @@ export interface McpOauthWWWAuthenticateParams { */ error?: string; /** - * Protected resource metadata URL from the WWW-Authenticate resource_metadata parameter + * Protected resource metadata URL from the WWW-Authenticate resource_metadata parameter, if present */ - resourceMetadataUrl: string; + resourceMetadataUrl?: string; /** * Requested OAuth scopes from the WWW-Authenticate scope parameter, if present */ @@ -6500,6 +6622,94 @@ export interface McpOauthCompletedData { */ requestId: string; } +/** + * Session event "mcp.headers_refresh_required". Dynamic headers refresh request for a remote MCP server + */ +export interface McpHeadersRefreshRequiredEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: McpHeadersRefreshRequiredData; + /** + * Always true for events that are transient and not persisted to the session event log on disk. + */ + ephemeral: true; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * Type discriminator. Always "mcp.headers_refresh_required". + */ + type: "mcp.headers_refresh_required"; +} +/** + * Dynamic headers refresh request for a remote MCP server + */ +export interface McpHeadersRefreshRequiredData { + reason: McpHeadersRefreshRequiredReason; + /** + * Unique identifier for this headers refresh request; used to respond via session.mcp.headers.handlePendingHeadersRefreshRequest() + */ + requestId: string; + /** + * Display name of the remote MCP server requesting headers + */ + serverName: string; + /** + * URL of the remote MCP server requesting headers + */ + serverUrl: string; +} +/** + * Session event "mcp.headers_refresh_completed". MCP headers refresh request completion notification + */ +export interface McpHeadersRefreshCompletedEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: McpHeadersRefreshCompletedData; + /** + * Always true for events that are transient and not persisted to the session event log on disk. + */ + ephemeral: true; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * Type discriminator. Always "mcp.headers_refresh_completed". + */ + type: "mcp.headers_refresh_completed"; +} +/** + * MCP headers refresh request completion notification + */ +export interface McpHeadersRefreshCompletedData { + outcome: McpHeadersRefreshCompletedOutcome; + /** + * Request ID of the resolved headers refresh request + */ + requestId: string; +} /** * Session event "session.custom_notification". Opaque custom notification data. Consumers may branch on source and name, but payload semantics are source-defined. */ diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 8bf9589c39..8d8fc6714f 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -10,7 +10,11 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; import { ConnectionError, ErrorCodes, ResponseError } from "vscode-jsonrpc/node.js"; import { createSessionRpc } from "./generated/rpc.js"; -import type { ClientSessionApiHandlers, CanvasActionInvokeResult } from "./generated/rpc.js"; +import type { + ClientSessionApiHandlers, + CanvasActionInvokeResult, + McpOauthPendingRequestResponse, +} from "./generated/rpc.js"; import { type Canvas, CanvasError } from "./canvas.js"; import type { OpenCanvasInstance } from "./generated/rpc.js"; import { getTraceContext } from "./telemetry.js"; @@ -29,6 +33,8 @@ import type { BearerTokenProvider, UiInputOptions, MessageOptions, + McpAuthHandler, + McpAuthRequest, PermissionHandler, PermissionRequest, ContextTier, @@ -124,6 +130,7 @@ export class CopilotSession { private bearerTokenProviders: Map = new Map(); private commandHandlers: Map = new Map(); private permissionHandler?: PermissionHandler; + private mcpAuthHandler?: McpAuthHandler; private userInputHandler?: UserInputHandler; private elicitationHandler?: ElicitationHandler; private exitPlanModeHandler?: ExitPlanModeHandler; @@ -152,9 +159,11 @@ export class CopilotSession { public readonly sessionId: string, private connection: MessageConnection, private _workspacePath?: string, - traceContextProvider?: TraceContextProvider + traceContextProvider?: TraceContextProvider, + options?: { mcpAuthHandler?: McpAuthHandler } ) { this.traceContextProvider = traceContextProvider; + this.mcpAuthHandler = options?.mcpAuthHandler; } /** @@ -499,6 +508,19 @@ export class CopilotSession { if (this.permissionHandler) { void this._executePermissionAndRespond(requestId, permissionRequest); } + } else if (event.type === "mcp.oauth_required") { + const data = event.data as McpAuthRequest | undefined; + if (!data?.requestId) { + return; + } + if (!this.mcpAuthHandler) { + console.warn( + "Received MCP OAuth request without a registered MCP auth handler. " + + `SessionId=${this.sessionId}, RequestId=${data.requestId}` + ); + return; + } + void this._executeMcpAuthAndRespond(data); } else if (event.type === "command.execute") { const { requestId, commandName, command, args } = event.data as { requestId: string; @@ -661,6 +683,35 @@ export class CopilotSession { } } + /** + * Executes an MCP auth handler and sends the result back via RPC. + * @internal + */ + private async _executeMcpAuthAndRespond(request: McpAuthRequest): Promise { + try { + const result = await this.mcpAuthHandler!(request, { sessionId: this.sessionId }); + const response: McpOauthPendingRequestResponse = + result && "accessToken" in result + ? { kind: "token", ...result } + : { kind: "cancelled" }; + await this.rpc.mcp.oauth.handlePendingRequest({ + requestId: request.requestId, + result: response, + }); + } catch (_error) { + try { + await this.rpc.mcp.oauth.handlePendingRequest({ + requestId: request.requestId, + result: { kind: "cancelled" }, + }); + } 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 e354bd8218..4adb35b25a 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1615,6 +1615,76 @@ export type ReasoningEffort = "low" | "medium" | "high" | "xhigh"; */ export type ContextTier = "default" | "long_context"; +/** Parsed parameters from an MCP server's WWW-Authenticate response. */ +export interface McpAuthWwwAuthenticateParams { + /** Parsed resource_metadata URL used for protected-resource metadata discovery, if present. */ + resourceMetadataUrl?: string; + /** Parsed OAuth scope, if present. */ + scope?: string; + /** Parsed OAuth error, if present. */ + error?: string; +} + +/** Static OAuth client configuration supplied by the MCP server, if available. */ +export interface McpAuthStaticClientConfig { + /** OAuth client ID for the server. */ + clientId: string; + /** Optional OAuth client secret for confidential static clients. */ + clientSecret?: string; + /** Optional non-default OAuth grant type. */ + grantType?: "client_credentials"; + /** Whether this is a public OAuth client. */ + publicClient?: boolean; +} + +/** MCP OAuth request that the SDK host can satisfy with a host-acquired token. */ +export interface McpAuthRequest { + /** Unique request identifier used by the SDK when responding. */ + requestId: string; + /** Display name of the MCP server that requires OAuth. */ + serverName: string; + /** URL of the MCP server that requires OAuth. */ + serverUrl: string; + /** Why the runtime is requesting host-provided OAuth credentials. */ + reason: "initial" | "refresh" | "reauth" | "upscope"; + /** Parsed WWW-Authenticate parameters from the MCP server. */ + wwwAuthenticateParams?: McpAuthWwwAuthenticateParams; + /** Raw RFC 9728 protected-resource metadata JSON fetched by the runtime, if available. */ + resourceMetadata?: string; + /** Static OAuth client configuration, if the server specifies one. */ + staticClientConfig?: McpAuthStaticClientConfig; +} + +/** Host-provided OAuth token data for a pending MCP OAuth request. */ +export interface McpAuthToken { + /** Access token acquired by the SDK host. */ + accessToken: string; + /** OAuth token type. Defaults to Bearer when omitted. */ + tokenType?: string; + /** Token lifetime in seconds, if known. */ + expiresIn?: number; +} + +/** + * Result returned by an MCP auth request handler. + * + * Return `null`/`undefined` or `{ kind: "cancelled" }` to cancel the pending + * OAuth request. Return `{ kind: "token", ... }` to provide host-acquired + * OAuth token data. + */ +export type McpAuthResult = ({ kind: "token" } & McpAuthToken) | { kind: "cancelled" }; + +/** Callback invoked when an MCP server requires OAuth and the SDK host opted in. */ +export type McpAuthHandler = ( + request: McpAuthRequest, + context: { sessionId: string } +) => + | McpAuthResult + | McpAuthToken + | null + | undefined + | Promise; + /** * Stable extension identity for session participants that provide canvases. */ @@ -1898,6 +1968,13 @@ export interface SessionConfigBase { */ onPermissionRequest?: PermissionHandler; + /** + * Optional handler for MCP OAuth requests from MCP servers. + * When provided, the SDK can satisfy MCP server OAuth requests with + * host-provided token data or cancellation. + */ + onMcpAuthRequest?: McpAuthHandler; + /** * Handler for user input requests from the agent. * When provided, enables the ask_user tool allowing the agent to ask questions. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 96d7da30cf..1ab3da604d 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -41,6 +41,228 @@ describe("CopilotClient", () => { expect(spy).not.toHaveBeenCalled(); }); + it("responds to MCP OAuth requests with host token data", async () => { + const sendRequest = vi.fn(async () => ({ success: true })); + let observedRequest: any; + const session = new CopilotSession( + "session-1", + { sendRequest } as any, + undefined, + undefined, + { + mcpAuthHandler: async (request) => { + observedRequest = request; + return { + accessToken: "host-token", + tokenType: "Bearer", + expiresIn: 3600, + }; + }, + } + ); + + await (session as any)._executeMcpAuthAndRespond({ + requestId: "oauth-request", + serverName: "oauth-server", + serverUrl: "https://example.com/mcp", + reason: "initial", + wwwAuthenticateParams: { + resourceMetadataUrl: "https://example.com/.well-known/oauth-protected-resource", + }, + resourceMetadata: '{"resource":"https://example.com/mcp"}', + staticClientConfig: { + clientId: "static-client", + clientSecret: "static-secret", + grantType: "client_credentials", + publicClient: false, + }, + }); + + expect(observedRequest.resourceMetadata).toBe('{"resource":"https://example.com/mcp"}'); + expect(observedRequest.staticClientConfig).toEqual({ + clientId: "static-client", + clientSecret: "static-secret", + grantType: "client_credentials", + publicClient: false, + }); + expect(sendRequest).toHaveBeenCalledWith("session.mcp.oauth.handlePendingRequest", { + sessionId: "session-1", + requestId: "oauth-request", + result: { + kind: "token", + accessToken: "host-token", + tokenType: "Bearer", + expiresIn: 3600, + }, + }); + }); + + it("passes MCP OAuth requests through when optional metadata is absent", async () => { + let observedRequest: any; + const session = new CopilotSession( + "session-1", + { sendRequest: vi.fn(async () => ({ success: true })) } as any, + undefined, + undefined, + { + mcpAuthHandler: async (request) => { + observedRequest = request; + return { kind: "cancelled" }; + }, + } + ); + + await (session as any)._executeMcpAuthAndRespond({ + requestId: "oauth-request", + serverName: "oauth-server", + serverUrl: "https://example.com/mcp", + reason: "initial", + }); + + expect(observedRequest.reason).toBe("initial"); + expect(observedRequest.resourceMetadata).toBeUndefined(); + expect(observedRequest.wwwAuthenticateParams).toBeUndefined(); + }); + + it("registers interest in MCP OAuth required events after create when an auth handler is configured", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.eventLog.registerInterest") { + return { id: "interest-1" }; + } + if (method === "session.create") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.createSession({ + onPermissionRequest: approveAll, + onMcpAuthRequest: () => ({ kind: "cancelled" }), + }); + + expect(spy.mock.calls[0][0]).toBe("session.create"); + expect(spy.mock.calls[1]).toEqual([ + "session.eventLog.registerInterest", + expect.objectContaining({ eventType: "mcp.oauth_required" }), + ]); + expect(spy.mock.calls[1][1].sessionId).toBe(spy.mock.calls[0][1].sessionId); + }); + + it("does not register MCP OAuth interest without an auth handler", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.createSession({ + onPermissionRequest: approveAll, + onEvent: () => {}, + }); + + expect(spy).not.toHaveBeenCalledWith( + "session.eventLog.registerInterest", + expect.objectContaining({ eventType: "mcp.oauth_required" }) + ); + expect(spy).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ requestPermission: true }) + ); + }); + + it("registers MCP OAuth interest after cloud create only when an auth handler is configured", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + let cloudCreateCount = 0; + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.eventLog.registerInterest") { + return { id: "interest-1" }; + } + if (method === "session.create") + return { sessionId: `server-assigned-session-${++cloudCreateCount}` }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.createSession({ + onPermissionRequest: approveAll, + cloud: { repository: { owner: "github", name: "copilot-sdk", branch: "main" } }, + }); + + expect(spy).not.toHaveBeenCalledWith( + "session.eventLog.registerInterest", + expect.objectContaining({ eventType: "mcp.oauth_required" }) + ); + + spy.mockClear(); + await client.createSession({ + onPermissionRequest: approveAll, + onMcpAuthRequest: () => ({ kind: "cancelled" }), + cloud: { repository: { owner: "github", name: "copilot-sdk", branch: "main" } }, + }); + + expect(spy.mock.calls[0][0]).toBe("session.create"); + expect(spy.mock.calls[1]).toEqual([ + "session.eventLog.registerInterest", + { sessionId: "server-assigned-session-2", eventType: "mcp.oauth_required" }, + ]); + }); + + it("registers MCP OAuth interest before resuming only when an auth handler is configured", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.eventLog.registerInterest") { + return { id: "interest-1" }; + } + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.resumeSession("session-with-auth", { + onPermissionRequest: approveAll, + onMcpAuthRequest: () => ({ kind: "cancelled" }), + }); + + expect(spy.mock.calls[0]).toEqual([ + "session.eventLog.registerInterest", + { sessionId: "session-with-auth", eventType: "mcp.oauth_required" }, + ]); + expect(spy.mock.calls[1][0]).toBe("session.resume"); + expect(spy.mock.calls[1][1]).toEqual(expect.objectContaining({ requestPermission: true })); + + spy.mockClear(); + await client.resumeSession("session-without-auth", { + onPermissionRequest: approveAll, + onEvent: () => {}, + }); + + expect(spy).not.toHaveBeenCalledWith( + "session.eventLog.registerInterest", + expect.objectContaining({ eventType: "mcp.oauth_required" }) + ); + expect(spy).toHaveBeenCalledWith( + "session.resume", + expect.objectContaining({ sessionId: "session-without-auth", requestPermission: true }) + ); + }); + it("forwards canvas declarations and request flags in session.create", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index cd6494cad3..373b25352e 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -19,6 +19,18 @@ export const DEFAULT_GITHUB_TOKEN = "fake-token-for-e2e-tests"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const SNAPSHOTS_DIR = resolve(__dirname, "../../../../test/snapshots"); +const LOCAL_RUNTIME_CLI_PATH = + "/Users/roji/.copilot/repos/copilot-worktrees/copilot-agent-runtime/roji-symmetrical-dollop/dist-cli/index.js"; + +function getCliPathForTests(): string | undefined { + if (process.env.COPILOT_CLI_PATH) { + return process.env.COPILOT_CLI_PATH; + } + if (fs.existsSync(LOCAL_RUNTIME_CLI_PATH)) { + return LOCAL_RUNTIME_CLI_PATH; + } + return undefined; +} export async function createSdkTestContext({ logLevel, @@ -39,6 +51,7 @@ export async function createSdkTestContext({ await openAiEndpoint.setCopilotUserByToken(DEFAULT_GITHUB_TOKEN, { login: "e2e-test-user", copilot_plan: "individual_pro", + is_mcp_enabled: true, endpoints: { api: proxyUrl, telemetry: "https://localhost:1/telemetry", @@ -72,6 +85,7 @@ export async function createSdkTestContext({ }; const userConn = copilotClientOptions?.connection; + const cliPath = getCliPathForTests(); let connection: RuntimeConnection; if (userConn) { // Caller supplied a RuntimeConnection — merge in the harness-managed @@ -82,13 +96,13 @@ export async function createSdkTestContext({ const { kind: _k, ...tcp } = userConn; connection = RuntimeConnection.forTcp({ ...tcp, - path: tcp.path ?? process.env.COPILOT_CLI_PATH, + path: tcp.path ?? cliPath, }); } else if (userConn.kind === "stdio") { const { kind: _k, ...stdio } = userConn; connection = RuntimeConnection.forStdio({ ...stdio, - path: stdio.path ?? process.env.COPILOT_CLI_PATH, + path: stdio.path ?? cliPath, }); } else { connection = userConn; @@ -96,15 +110,18 @@ export async function createSdkTestContext({ } else { connection = useStdio === false - ? RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH }) - : RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }); + ? RuntimeConnection.forTcp({ path: cliPath }) + : RuntimeConnection.forStdio({ path: cliPath }); } - const { connection: _ignoredConnection, ...remainingClientOptions } = - copilotClientOptions ?? {}; + const { + connection: _ignoredConnection, + env: userEnv, + ...remainingClientOptions + } = copilotClientOptions ?? {}; const copilotClient = new CopilotClient({ workingDirectory: workDir, - env, + env: { ...env, ...userEnv }, logLevel: logLevel || "error", connection, gitHubToken: authTokenToUse, diff --git a/nodejs/test/e2e/mcp_oauth.e2e.test.ts b/nodejs/test/e2e/mcp_oauth.e2e.test.ts new file mode 100644 index 0000000000..29ed089edb --- /dev/null +++ b/nodejs/test/e2e/mcp_oauth.e2e.test.ts @@ -0,0 +1,311 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, onTestFinished } from "vitest"; +import type { CopilotSession, MCPServerConfig, McpAuthRequest } from "../../src/index.js"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { waitForCondition } from "./harness/sdkTestHelper.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const TEST_MCP_OAUTH_SERVER = resolve(__dirname, "../../../test/harness/test-mcp-oauth-server.mjs"); +const EXPECTED_TOKEN = "sdk-host-token"; +const REFRESH_TOKEN = `${EXPECTED_TOKEN}-refresh`; +const UPSCOPE_TOKEN = `${EXPECTED_TOKEN}-upscope`; +const REAUTH_TOKEN = `${EXPECTED_TOKEN}-reauth`; + +describe("MCP OAuth host auth", async () => { + const { copilotClient: client } = await createSdkTestContext({ + copilotClientOptions: { + env: { + COPILOT_MCP_APPS: "true", + MCP_APPS: "true", + }, + }, + }); + + it("should satisfy MCP OAuth using host-provided token", { timeout: 120_000 }, async () => { + const oauthServer = await startOAuthMcpServer(); + const serverName = "oauth-protected-mcp"; + let authRequest: McpAuthRequest | undefined; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + enableMcpApps: true, + onMcpAuthRequest: async (request) => { + authRequest = request; + return { + kind: "token", + accessToken: EXPECTED_TOKEN, + tokenType: "Bearer", + expiresIn: 3600, + }; + }, + mcpServers: { + [serverName]: { + type: "http", + url: `${oauthServer.url}/mcp`, + tools: ["*"], + oauthClientId: "sdk-e2e-client", + oauthPublicClient: true, + } as unknown as MCPServerConfig, + }, + }); + onTestFinished(() => disconnectSession(session)); + + await waitForMcpServerStatus(session, serverName); + + const tools = await session.rpc.mcp.listTools({ serverName }); + expect(tools.tools.map((tool) => tool.name)).toContain("whoami"); + + expect(authRequest).toMatchObject({ + requestId: expect.any(String), + serverName, + serverUrl: `${oauthServer.url}/mcp`, + reason: "initial", + wwwAuthenticateParams: { + resourceMetadataUrl: `${oauthServer.url}/.well-known/oauth-protected-resource`, + scope: "mcp.read", + error: "invalid_token", + }, + resourceMetadata: JSON.stringify({ + resource: `${oauthServer.url}/mcp`, + authorization_servers: [oauthServer.url], + scopes_supported: ["mcp.read"], + bearer_methods_supported: ["header"], + }), + }); + + const requests = await oauthServer.requests(); + expect(requests.some((request) => request.authorization === null)).toBe(true); + expect( + requests.some((request) => request.authorization === `Bearer ${EXPECTED_TOKEN}`) + ).toBe(true); + }); + + it( + "should request host-owned replacement tokens across the MCP OAuth lifecycle", + { timeout: 120_000 }, + async () => { + const oauthServer = await startOAuthMcpServer(); + const serverName = "oauth-lifecycle-mcp"; + const authRequests: McpAuthRequest[] = []; + let refreshCount = 0; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + enableMcpApps: true, + onMcpAuthRequest: async (request) => { + authRequests.push(request); + switch (request.reason) { + case "initial": + return { kind: "token", accessToken: EXPECTED_TOKEN }; + case "refresh": + refreshCount++; + if (refreshCount === 1) { + return { kind: "token", accessToken: REFRESH_TOKEN }; + } + return { kind: "cancelled" }; + case "upscope": + return { kind: "token", accessToken: UPSCOPE_TOKEN }; + case "reauth": + return { kind: "token", accessToken: REAUTH_TOKEN }; + } + }, + mcpServers: { + [serverName]: { + type: "http", + url: `${oauthServer.url}/mcp`, + tools: ["*"], + oauthClientId: "sdk-e2e-client", + oauthPublicClient: true, + } as unknown as MCPServerConfig, + }, + }); + onTestFinished(() => disconnectSession(session)); + + await waitForMcpServerStatus(session, serverName); + await callWhoami(session, serverName, "refresh"); + await callWhoami(session, serverName, "upscope"); + await callWhoami(session, serverName, "reauth"); + + expect(authRequests.map((request) => request.reason)).toEqual([ + "initial", + "refresh", + "upscope", + "refresh", + "reauth", + ]); + + const upscopeRequest = authRequests.find((request) => request.reason === "upscope"); + expect(upscopeRequest?.wwwAuthenticateParams).toEqual({ + resourceMetadataUrl: `${oauthServer.url}/.well-known/oauth-protected-resource`, + scope: "mcp.write", + error: "insufficient_scope", + }); + expect(upscopeRequest?.resourceMetadata).toBe( + JSON.stringify({ + resource: `${oauthServer.url}/mcp`, + authorization_servers: [oauthServer.url], + scopes_supported: ["mcp.read"], + bearer_methods_supported: ["header"], + }) + ); + + const requests = await oauthServer.requests(); + for (const token of [EXPECTED_TOKEN, REFRESH_TOKEN, UPSCOPE_TOKEN, REAUTH_TOKEN]) { + expect( + requests.some((request) => request.authorization === `Bearer ${token}`) + ).toBe(true); + } + } + ); + + it( + "should cancel pending MCP OAuth requests when the host declines", + { timeout: 120_000 }, + async () => { + const oauthServer = await startOAuthMcpServer(); + const serverName = "oauth-cancelled-mcp"; + let authRequest: McpAuthRequest | undefined; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + onMcpAuthRequest: async (request) => { + authRequest = request; + return { kind: "cancelled" }; + }, + mcpServers: { + [serverName]: { + type: "http", + url: `${oauthServer.url}/mcp`, + tools: ["*"], + oauthClientId: "sdk-e2e-client", + oauthPublicClient: true, + } as unknown as MCPServerConfig, + }, + }); + onTestFinished(() => disconnectSession(session)); + + await waitForMcpServerStatus(session, serverName, "failed"); + + expect(authRequest).toMatchObject({ + serverName, + reason: "initial", + }); + } + ); +}); + +async function waitForMcpServerStatus( + session: CopilotSession, + serverName: string, + expectedStatus = "connected" +): Promise { + let lastStatus = ""; + await waitForCondition( + async () => { + const result = await session.rpc.mcp.list(); + const server = result.servers.find((entry) => entry.name === serverName); + lastStatus = server?.status ?? ""; + return server?.status === expectedStatus; + }, + { + timeoutMs: 60_000, + intervalMs: 200, + timeoutMessage: `${serverName} did not reach ${expectedStatus}; last status was ${lastStatus}`, + } + ); +} + +async function callWhoami( + session: CopilotSession, + serverName: string, + scenario: "refresh" | "upscope" | "reauth" +): Promise { + const result = await session.rpc.mcp.apps.callTool({ + serverName, + originServerName: serverName, + toolName: "whoami", + arguments: { scenario }, + }); + expect(result.content).toEqual([{ type: "text", text: "oauth-test-user" }]); +} + +async function startOAuthMcpServer(): Promise<{ + url: string; + requests: () => Promise>; +}> { + const child = spawn(process.execPath, [TEST_MCP_OAUTH_SERVER], { + env: { ...process.env, EXPECTED_TOKEN }, + stdio: ["ignore", "pipe", "pipe"], + }); + onTestFinished(() => stopChild(child)); + + const stderr: string[] = []; + child.stderr.on("data", (chunk) => stderr.push(String(chunk))); + + const url = await new Promise((resolvePromise, reject) => { + const rl = createInterface({ input: child.stdout }); + const timeout = setTimeout(() => { + rl.close(); + reject(new Error(`Timed out waiting for OAuth MCP server. ${stderr.join("")}`)); + }, 10_000); + + child.once("exit", (code, signal) => { + clearTimeout(timeout); + rl.close(); + reject( + new Error( + `OAuth MCP server exited before listening. code=${code} signal=${signal} ${stderr.join("")}` + ) + ); + }); + + rl.on("line", (line) => { + const match = /^Listening: (.+)$/.exec(line); + if (!match) { + return; + } + clearTimeout(timeout); + rl.close(); + resolvePromise(match[1]); + }); + }); + + return { + url, + requests: async () => { + const response = await fetch(`${url}/__requests`); + if (!response.ok) { + throw new Error(`Failed to fetch OAuth MCP requests: ${response.status}`); + } + return response.json(); + }, + }; +} + +async function disconnectSession(session: CopilotSession): Promise { + try { + await session.disconnect(); + } catch { + // Best-effort cleanup. + } +} + +function stopChild(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.killed) { + return Promise.resolve(); + } + const exitPromise = new Promise((resolvePromise) => { + child.once("exit", () => resolvePromise()); + }); + child.kill("SIGTERM"); + return exitPromise; +} diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index ff13d47de3..3ebc1786fa 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -107,6 +107,12 @@ MCPHTTPServerConfig, MCPServerConfig, MCPStdioServerConfig, + McpAuthHandler, + McpAuthRequest, + McpAuthResult, + McpAuthStaticClientConfig, + McpAuthToken, + McpAuthWwwAuthenticateParams, ModelCapabilitiesOverride, ModelLimitsOverride, ModelSupportsOverride, @@ -226,6 +232,12 @@ "MCPHTTPServerConfig", "MCPServerConfig", "MCPStdioServerConfig", + "McpAuthHandler", + "McpAuthRequest", + "McpAuthResult", + "McpAuthStaticClientConfig", + "McpAuthToken", + "McpAuthWwwAuthenticateParams", "ModelBilling", "ModelBillingTokenPrices", "ModelBillingTokenPricesLongContext", diff --git a/python/copilot/client.py b/python/copilot/client.py index c7d11d12b1..72c74a7c0a 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -94,6 +94,7 @@ LargeToolOutputConfig, MCPServerConfig, MemoryConfiguration, + McpAuthHandler, ModelCapabilitiesOverride, NamedProviderConfig, ProviderConfig, @@ -1697,6 +1698,7 @@ async def create_session( on_event: Callable[[SessionEvent], None] | None = None, commands: list[CommandDefinition] | None = None, on_elicitation_request: ElicitationHandler | None = None, + on_mcp_auth_request: McpAuthHandler | None = None, enable_mcp_apps: bool = False, on_exit_plan_mode_request: ExitPlanModeHandler | None = None, on_auto_mode_switch_request: AutoModeSwitchHandler | None = None, @@ -2149,6 +2151,7 @@ def _initialize_session(sid: str) -> CopilotSession: s._register_tools(tools) s._register_commands(commands) s._register_permission_handler(on_permission_request) + s._register_mcp_auth_handler(on_mcp_auth_request) if on_user_input_request: s._register_user_input_handler(on_user_input_request) if on_elicitation_request: @@ -2229,6 +2232,11 @@ def _register_inline(raw_response: Any) -> None: f"session.create returned sessionId {response.get('sessionId')} " f"but the caller requested {local_session_id}" ) + if on_mcp_auth_request is not None: + await self._client.request( + "session.eventLog.registerInterest", + {"sessionId": session.session_id, "eventType": "mcp.oauth_required"}, + ) session._workspace_path = response.get("workspacePath") capabilities = response.get("capabilities") session._set_capabilities(capabilities) @@ -2319,6 +2327,7 @@ async def resume_session( on_event: Callable[[SessionEvent], None] | None = None, commands: list[CommandDefinition] | None = None, on_elicitation_request: ElicitationHandler | None = None, + on_mcp_auth_request: McpAuthHandler | None = None, enable_mcp_apps: bool = False, on_exit_plan_mode_request: ExitPlanModeHandler | None = None, on_auto_mode_switch_request: AutoModeSwitchHandler | None = None, @@ -2723,6 +2732,7 @@ async def resume_session( session._register_tools(tools) session._register_commands(commands) session._register_permission_handler(on_permission_request) + session._register_mcp_auth_handler(on_mcp_auth_request) if on_user_input_request: session._register_user_input_handler(on_user_input_request) if on_elicitation_request: @@ -2744,6 +2754,11 @@ async def resume_session( session.on(on_event) with self._sessions_lock: self._sessions[session_id] = session + if on_mcp_auth_request is not None: + await self._client.request( + "session.eventLog.registerInterest", + {"sessionId": session_id, "eventType": "mcp.oauth_required"}, + ) log_timing( logger, logging.DEBUG, diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index b38c12ff39..23db13b4d7 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -6,7 +6,7 @@ from typing import ClassVar, TYPE_CHECKING -from .session_events import AbortReason, Attachment, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpServerSource, McpServerStatus, PermissionPromptRequest, PermissionRule, ReasoningSummary, SessionEvent, SessionMode, ShutdownType, SkillSource, UserToolSessionApproval +from .session_events import AbortReason, Attachment, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpServerSource, McpServerStatus, PermissionPromptRequest, PermissionRule, ReasoningSummary, ResponseBudgetConfig, SessionEvent, SessionMode, ShutdownType, SkillSource, UserToolSessionApproval if TYPE_CHECKING: from .._jsonrpc import JsonRpcClient @@ -2982,6 +2982,31 @@ def to_dict(self) -> dict: result["redactedReason"] = from_union([from_str, from_none], self.redacted_reason) return result +class MCPHeadersHandlePendingHeadersRefreshRequestKind(Enum): + HEADERS = "headers" + NONE = "none" + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPHeadersHandlePendingHeadersRefreshRequestResult: + """Indicates whether the pending MCP headers refresh response was accepted.""" + + success: bool + """Whether the response was accepted. False if the request was unknown, timed out, or + already resolved. + """ + + @staticmethod + def from_dict(obj: Any) -> 'MCPHeadersHandlePendingHeadersRefreshRequestResult': + assert isinstance(obj, dict) + success = from_bool(obj.get("success")) + return MCPHeadersHandlePendingHeadersRefreshRequestResult(success) + + def to_dict(self) -> dict: + result: dict = {} + result["success"] = from_bool(self.success) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MCPServerFailureInfo: @@ -11402,6 +11427,31 @@ def to_dict(self) -> dict: result["allowedServers"] = from_union([lambda x: from_list(lambda x: to_class(MCPAllowedServer, x), x), from_none], self.allowed_servers) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPHeadersHandlePendingHeadersRefreshRequest: + """Host response: supply dynamic headers or decline this refresh.""" + + kind: MCPHeadersHandlePendingHeadersRefreshRequestKind + headers: dict[str, str] | None = None + """Headers to overlay onto the MCP request. Dynamic headers override static config headers + but do not replace SDK-managed request headers. + """ + + @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) + + 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) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MCPHostState: @@ -11463,9 +11513,6 @@ class MCPOauthPendingRequestResponse: expires_in: int | None = None """Token lifetime in seconds, if known.""" - refresh_token: str | None = None - """Refresh token supplied by the host, if available.""" - token_type: str | None = None """OAuth token type. Defaults to Bearer when omitted.""" @@ -11475,9 +11522,8 @@ def from_dict(obj: Any) -> 'MCPOauthPendingRequestResponse': kind = MCPOauthPendingRequestResponseKind(obj.get("kind")) access_token = from_union([from_str, from_none], obj.get("accessToken")) expires_in = from_union([from_int, from_none], obj.get("expiresIn")) - refresh_token = from_union([from_str, from_none], obj.get("refreshToken")) token_type = from_union([from_str, from_none], obj.get("tokenType")) - return MCPOauthPendingRequestResponse(kind, access_token, expires_in, refresh_token, token_type) + return MCPOauthPendingRequestResponse(kind, access_token, expires_in, token_type) def to_dict(self) -> dict: result: dict = {} @@ -11486,8 +11532,6 @@ def to_dict(self) -> dict: result["accessToken"] = from_union([from_str, from_none], self.access_token) if self.expires_in is not None: result["expiresIn"] = from_union([from_int, from_none], self.expires_in) - if self.refresh_token is not None: - result["refreshToken"] = from_union([from_str, from_none], self.refresh_token) if self.token_type is not None: result["tokenType"] = from_union([from_str, from_none], self.token_type) return result @@ -16791,6 +16835,30 @@ def to_dict(self) -> dict: result["serverName"] = from_str(self.server_name) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPHeadersHandlePendingHeadersRefreshRequestRequest: + """MCP headers refresh request id and the host response.""" + + request_id: str + """Headers refresh request identifier from mcp.headers_refresh_required""" + + result: MCPHeadersHandlePendingHeadersRefreshRequest + """Host response: supply dynamic headers or decline this refresh.""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPHeadersHandlePendingHeadersRefreshRequestRequest': + assert isinstance(obj, dict) + request_id = from_str(obj.get("requestId")) + result = MCPHeadersHandlePendingHeadersRefreshRequest.from_dict(obj.get("result")) + return MCPHeadersHandlePendingHeadersRefreshRequestRequest(request_id, result) + + def to_dict(self) -> dict: + result: dict = {} + result["requestId"] = from_str(self.request_id) + result["result"] = to_class(MCPHeadersHandlePendingHeadersRefreshRequest, self.result) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MCPOauthHandlePendingRequest: @@ -16819,6 +16887,11 @@ def to_dict(self) -> dict: class ModelBilling: """Billing information""" + discount_percent: int | None = None + """Whole-number percentage discount (0-100) applied to usage billed through this model. + Populated for the synthetic `auto` model, where requests routed by auto-mode are billed + at a reduced rate; absent for concrete models. + """ multiplier: float | None = None """Billing cost multiplier relative to the base rate""" @@ -16828,12 +16901,15 @@ class ModelBilling: @staticmethod def from_dict(obj: Any) -> 'ModelBilling': assert isinstance(obj, dict) + discount_percent = from_union([from_int, from_none], obj.get("discountPercent")) multiplier = from_union([from_float, from_none], obj.get("multiplier")) token_prices = from_union([ModelBillingTokenPrices.from_dict, from_none], obj.get("tokenPrices")) - return ModelBilling(multiplier, token_prices) + return ModelBilling(discount_percent, multiplier, token_prices) def to_dict(self) -> dict: result: dict = {} + if self.discount_percent is not None: + result["discountPercent"] = from_union([from_int, from_none], self.discount_percent) if self.multiplier is not None: result["multiplier"] = from_union([to_float, from_none], self.multiplier) if self.token_prices is not None: @@ -19165,6 +19241,10 @@ class SessionOpenOptions: agent_context: str | None = None """Runtime context discriminator for agent filtering.""" + allow_all_mcp_server_instructions: bool | None = None + """Whether to include instructions from every MCP server in the system prompt instead of + only allowlisted servers. + """ ask_user_disabled: bool | None = None """Whether ask_user is explicitly disabled.""" @@ -19300,6 +19380,9 @@ class SessionOpenOptions: remote_steerable: bool | None = None """Whether this session supports remote steering.""" + response_budget: ResponseBudgetConfig | None = None + """Initial experimental response budget limits for the session.""" + running_in_interactive_mode: bool | None = None """Whether the host is an interactive UI.""" @@ -19338,6 +19421,7 @@ def from_dict(obj: Any) -> 'SessionOpenOptions': assert isinstance(obj, dict) additional_content_exclusion_policies = from_union([lambda x: from_list(SessionOpenOptionsAdditionalContentExclusionPolicy.from_dict, x), from_none], obj.get("additionalContentExclusionPolicies")) agent_context = from_union([from_str, from_none], obj.get("agentContext")) + allow_all_mcp_server_instructions = from_union([from_bool, from_none], obj.get("allowAllMcpServerInstructions")) ask_user_disabled = from_union([from_bool, from_none], obj.get("askUserDisabled")) auth_info = from_union([_load_AuthInfo, from_none], obj.get("authInfo")) available_tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("availableTools")) @@ -19380,6 +19464,7 @@ def from_dict(obj: Any) -> 'SessionOpenOptions': remote_defaulted_on = from_union([from_bool, from_none], obj.get("remoteDefaultedOn")) remote_exporting = from_union([from_bool, from_none], obj.get("remoteExporting")) remote_steerable = from_union([from_bool, from_none], obj.get("remoteSteerable")) + response_budget = from_union([ResponseBudgetConfig.from_dict, from_none], obj.get("responseBudget")) running_in_interactive_mode = from_union([from_bool, from_none], obj.get("runningInInteractiveMode")) sandbox_config = from_union([SandboxConfig.from_dict, from_none], obj.get("sandboxConfig")) session_capabilities = from_union([lambda x: from_list(SessionCapability, x), from_none], obj.get("sessionCapabilities")) @@ -19391,7 +19476,7 @@ def from_dict(obj: Any) -> 'SessionOpenOptions': trajectory_file = from_union([from_str, from_none], obj.get("trajectoryFile")) 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, agent_context, 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_skills, enable_citations, enable_on_demand_instruction_discovery, enable_script_safety, enable_streaming, env_value_mode, events_log_directory, excluded_tools, exp_assignments, feature_flags, installed_plugins, integration_id, is_experimental_mode, log_interactive_shells, lsp_client_name, 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, session_capabilities, session_id, shell_init_profile, shell_process_flags, skill_directories, skip_custom_instructions, trajectory_file, working_directory, working_directory_context) + return SessionOpenOptions(additional_content_exclusion_policies, 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_skills, enable_citations, enable_on_demand_instruction_discovery, enable_script_safety, enable_streaming, env_value_mode, events_log_directory, excluded_tools, exp_assignments, feature_flags, installed_plugins, integration_id, is_experimental_mode, log_interactive_shells, lsp_client_name, max_inline_binary_bytes, memory, model, model_capabilities_overrides, models, name, provider, providers, reasoning_effort, reasoning_summary, remote_defaulted_on, remote_exporting, remote_steerable, response_budget, running_in_interactive_mode, sandbox_config, session_capabilities, session_id, shell_init_profile, shell_process_flags, skill_directories, skip_custom_instructions, trajectory_file, working_directory, working_directory_context) def to_dict(self) -> dict: result: dict = {} @@ -19399,6 +19484,8 @@ def to_dict(self) -> dict: result["additionalContentExclusionPolicies"] = from_union([lambda x: from_list(lambda x: to_class(SessionOpenOptionsAdditionalContentExclusionPolicy, x), x), from_none], self.additional_content_exclusion_policies) if self.agent_context is not None: result["agentContext"] = from_union([from_str, from_none], self.agent_context) + if self.allow_all_mcp_server_instructions is not None: + result["allowAllMcpServerInstructions"] = from_union([from_bool, from_none], self.allow_all_mcp_server_instructions) if self.ask_user_disabled is not None: result["askUserDisabled"] = from_union([from_bool, from_none], self.ask_user_disabled) if self.auth_info is not None: @@ -19483,6 +19570,8 @@ def to_dict(self) -> dict: result["remoteExporting"] = from_union([from_bool, from_none], self.remote_exporting) if self.remote_steerable is not None: result["remoteSteerable"] = from_union([from_bool, from_none], self.remote_steerable) + if self.response_budget is not None: + result["responseBudget"] = from_union([lambda x: to_class(ResponseBudgetConfig, x), from_none], self.response_budget) if self.running_in_interactive_mode is not None: result["runningInInteractiveMode"] = from_union([from_bool, from_none], self.running_in_interactive_mode) if self.sandbox_config is not None: @@ -19518,6 +19607,10 @@ class SessionUpdateOptionsParams: agent_context: str | None = None """Runtime context discriminator (e.g., `cli`, `actions`).""" + allow_all_mcp_server_instructions: bool | None = None + """Whether to include instructions from every MCP server in the system prompt instead of + only allowlisted servers. + """ ask_user_disabled: bool | None = None """Whether to disable the `ask_user` tool (encourages autonomous behavior).""" @@ -19641,6 +19734,9 @@ class SessionUpdateOptionsParams: reasoning_summary: ReasoningSummary | None = None """Reasoning summary mode for supported model clients.""" + response_budget: ResponseBudgetConfig | None = None + """Optional experimental response budget limits. Pass null to clear the response budget.""" + running_in_interactive_mode: bool | None = None """Whether the session is running in an interactive UI.""" @@ -19687,6 +19783,7 @@ def from_dict(obj: Any) -> 'SessionUpdateOptionsParams': assert isinstance(obj, dict) additional_content_exclusion_policies = from_union([lambda x: from_list(OptionsUpdateAdditionalContentExclusionPolicy.from_dict, x), from_none], obj.get("additionalContentExclusionPolicies")) agent_context = from_union([from_str, from_none], obj.get("agentContext")) + allow_all_mcp_server_instructions = from_union([from_bool, from_none], obj.get("allowAllMcpServerInstructions")) ask_user_disabled = from_union([from_bool, from_none], obj.get("askUserDisabled")) available_tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("availableTools")) capi = from_union([CapiSessionOptions.from_dict, from_none], obj.get("capi")) @@ -19723,6 +19820,7 @@ def from_dict(obj: Any) -> 'SessionUpdateOptionsParams': provider = from_union([ProviderConfig.from_dict, from_none], obj.get("provider")) reasoning_effort = from_union([from_str, from_none], obj.get("reasoningEffort")) reasoning_summary = from_union([ReasoningSummary, from_none], obj.get("reasoningSummary")) + response_budget = from_union([ResponseBudgetConfig.from_dict, from_none], obj.get("responseBudget")) running_in_interactive_mode = from_union([from_bool, from_none], obj.get("runningInInteractiveMode")) sandbox_config = from_union([SandboxConfig.from_dict, from_none], obj.get("sandboxConfig")) session_capabilities = from_union([lambda x: from_list(SessionCapability, x), from_none], obj.get("sessionCapabilities")) @@ -19735,7 +19833,7 @@ def from_dict(obj: Any) -> 'SessionUpdateOptionsParams': tool_filter_precedence = from_union([OptionsUpdateToolFilterPrecedence, from_none], obj.get("toolFilterPrecedence")) trajectory_file = from_union([from_str, from_none], obj.get("trajectoryFile")) working_directory = from_union([from_str, from_none], obj.get("workingDirectory")) - return SessionUpdateOptionsParams(additional_content_exclusion_policies, agent_context, ask_user_disabled, available_tools, capi, client_name, coauthor_enabled, context_tier, continue_on_auto_mode, copilot_url, custom_agents_local_only, disabled_instruction_sources, disabled_skills, enable_file_hooks, enable_host_git_operations, enable_on_demand_instruction_discovery, enable_reasoning_summaries, enable_script_safety, enable_session_store, enable_skills, enable_streaming, env_value_mode, events_log_directory, excluded_tools, feature_flags, installed_plugins, integration_id, is_experimental_mode, log_interactive_shells, lsp_client_name, manage_schedule_enabled, max_inline_binary_bytes, model, model_capabilities_overrides, organization_custom_instructions, provider, reasoning_effort, reasoning_summary, running_in_interactive_mode, sandbox_config, session_capabilities, shell_init_profile, shell_process_flags, skill_directories, skip_custom_instructions, skip_embedding_retrieval, suppress_custom_agent_prompt, tool_filter_precedence, trajectory_file, working_directory) + return SessionUpdateOptionsParams(additional_content_exclusion_policies, agent_context, allow_all_mcp_server_instructions, ask_user_disabled, available_tools, capi, client_name, coauthor_enabled, context_tier, continue_on_auto_mode, copilot_url, custom_agents_local_only, disabled_instruction_sources, disabled_skills, enable_file_hooks, enable_host_git_operations, enable_on_demand_instruction_discovery, enable_reasoning_summaries, enable_script_safety, enable_session_store, enable_skills, enable_streaming, env_value_mode, events_log_directory, excluded_tools, feature_flags, installed_plugins, integration_id, is_experimental_mode, log_interactive_shells, lsp_client_name, manage_schedule_enabled, max_inline_binary_bytes, model, model_capabilities_overrides, organization_custom_instructions, provider, reasoning_effort, reasoning_summary, response_budget, running_in_interactive_mode, sandbox_config, session_capabilities, shell_init_profile, shell_process_flags, skill_directories, skip_custom_instructions, skip_embedding_retrieval, suppress_custom_agent_prompt, tool_filter_precedence, trajectory_file, working_directory) def to_dict(self) -> dict: result: dict = {} @@ -19743,6 +19841,8 @@ def to_dict(self) -> dict: result["additionalContentExclusionPolicies"] = from_union([lambda x: from_list(lambda x: to_class(OptionsUpdateAdditionalContentExclusionPolicy, x), x), from_none], self.additional_content_exclusion_policies) if self.agent_context is not None: result["agentContext"] = from_union([from_str, from_none], self.agent_context) + if self.allow_all_mcp_server_instructions is not None: + result["allowAllMcpServerInstructions"] = from_union([from_bool, from_none], self.allow_all_mcp_server_instructions) if self.ask_user_disabled is not None: result["askUserDisabled"] = from_union([from_bool, from_none], self.ask_user_disabled) if self.available_tools is not None: @@ -19815,6 +19915,8 @@ def to_dict(self) -> dict: result["reasoningEffort"] = from_union([from_str, from_none], self.reasoning_effort) if self.reasoning_summary is not None: result["reasoningSummary"] = from_union([lambda x: to_enum(ReasoningSummary, x), from_none], self.reasoning_summary) + if self.response_budget is not None: + result["responseBudget"] = from_union([lambda x: to_class(ResponseBudgetConfig, x), from_none], self.response_budget) if self.running_in_interactive_mode is not None: result["runningInInteractiveMode"] = from_union([from_bool, from_none], self.running_in_interactive_mode) if self.sandbox_config is not None: @@ -20847,12 +20949,21 @@ class SubagentSettings: disabled_subagents: list[str] | None = None """Names of subagents the user has turned off; they cannot be dispatched""" + max_concurrency: int | None = None + """Maximum number of subagents that can run concurrently; applies to usage-based billing + users only + """ + max_depth: int | None = None + """Maximum subagent nesting depth; applies to usage-based billing users only""" + @staticmethod def from_dict(obj: Any) -> 'SubagentSettings': assert isinstance(obj, dict) agents = from_union([lambda x: from_dict(SubagentSettingsEntry.from_dict, x), from_none], obj.get("agents")) disabled_subagents = from_union([lambda x: from_list(from_str, x), from_none], obj.get("disabledSubagents")) - return SubagentSettings(agents, disabled_subagents) + max_concurrency = from_union([from_int, from_none], obj.get("maxConcurrency")) + max_depth = from_union([from_int, from_none], obj.get("maxDepth")) + return SubagentSettings(agents, disabled_subagents, max_concurrency, max_depth) def to_dict(self) -> dict: result: dict = {} @@ -20860,6 +20971,10 @@ def to_dict(self) -> dict: result["agents"] = from_union([lambda x: from_dict(lambda x: to_class(SubagentSettingsEntry, x), x), from_none], self.agents) if self.disabled_subagents is not None: result["disabledSubagents"] = from_union([lambda x: from_list(from_str, x), from_none], self.disabled_subagents) + if self.max_concurrency is not None: + result["maxConcurrency"] = from_union([from_int, from_none], self.max_concurrency) + if self.max_depth is not None: + result["maxDepth"] = from_union([from_int, from_none], self.max_depth) return result # Experimental: this type is part of an experimental API and may change or be removed. @@ -21162,6 +21277,9 @@ class RPC: mcp_execute_sampling_request: dict[str, Any] mcp_execute_sampling_result: dict[str, Any] mcp_filtered_server: MCPFilteredServer + mcp_headers_handle_pending_headers_refresh_request: MCPHeadersHandlePendingHeadersRefreshRequest + mcp_headers_handle_pending_headers_refresh_request_request: MCPHeadersHandlePendingHeadersRefreshRequestRequest + mcp_headers_handle_pending_headers_refresh_request_result: MCPHeadersHandlePendingHeadersRefreshRequestResult mcp_host_state: MCPHostState mcp_is_server_running_request: MCPIsServerRunningRequest mcp_is_server_running_result: MCPIsServerRunningResult @@ -21934,6 +22052,9 @@ def from_dict(obj: Any) -> 'RPC': mcp_execute_sampling_request = from_dict(lambda x: x, obj.get("McpExecuteSamplingRequest")) mcp_execute_sampling_result = from_dict(lambda x: x, obj.get("McpExecuteSamplingResult")) mcp_filtered_server = MCPFilteredServer.from_dict(obj.get("McpFilteredServer")) + mcp_headers_handle_pending_headers_refresh_request = MCPHeadersHandlePendingHeadersRefreshRequest.from_dict(obj.get("McpHeadersHandlePendingHeadersRefreshRequest")) + mcp_headers_handle_pending_headers_refresh_request_request = MCPHeadersHandlePendingHeadersRefreshRequestRequest.from_dict(obj.get("McpHeadersHandlePendingHeadersRefreshRequestRequest")) + mcp_headers_handle_pending_headers_refresh_request_result = MCPHeadersHandlePendingHeadersRefreshRequestResult.from_dict(obj.get("McpHeadersHandlePendingHeadersRefreshRequestResult")) mcp_host_state = MCPHostState.from_dict(obj.get("McpHostState")) mcp_is_server_running_request = MCPIsServerRunningRequest.from_dict(obj.get("McpIsServerRunningRequest")) mcp_is_server_running_result = MCPIsServerRunningResult.from_dict(obj.get("McpIsServerRunningResult")) @@ -22481,7 +22602,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, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, 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, agents_get_discovery_paths_request, allow_all_permission_set_result, allow_all_permission_state, api_key_auth_info, auth_info, auth_info_type, 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_session_context, capi_session_options, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, configure_session_extensions_params, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, 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, discovered_canvas, 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_result, execute_command_params, execute_command_result, extension, extension_context_push_input, 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_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, 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, 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, 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_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_filtered_server, mcp_host_state, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, 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_respond_request, mcp_oauth_respond_result, mcp_register_external_client_request, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_restart_server_request, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, 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_stdio, 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_tools, mcp_unregister_external_client_request, memory_configuration, 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_billing, 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_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, 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_management, permission_decision_approve_for_location_approval_extension_permission_access, 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_management, permission_decision_approve_for_session_approval_extension_permission_access, 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_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_reject, permission_decision_request, 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_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_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_allow_all_request, 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_management, permissions_locations_add_tool_approval_details_extension_permission_access, 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_allow_all_request, permissions_set_allow_all_source, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, 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_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, poll_spawned_sessions_result, 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_reference, push_attachment_git_hub_reference_type, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_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_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, sandbox_config, 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_seatbelt, schedule_entry, schedule_list, 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_mode, send_request, send_result, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_auth_status, session_bulk_delete_result, session_capability, 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_stat_request, session_fs_stat_result, session_fs_write_file_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_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, 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_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, 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_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, 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_poll_spawned_sessions_event, sessions_poll_spawned_sessions_request, 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_working_directory_context, session_working_directory_context_host_type, shell_cancel_user_requested_request, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_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_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, 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, tool, tool_list, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_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_user_input_request, ui_register_direct_auto_mode_switch_handler_result, 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_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, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_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, workspace_summary_host_type, workspaces_workspace_details_host_type, 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, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, 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, agents_get_discovery_paths_request, allow_all_permission_set_result, allow_all_permission_state, api_key_auth_info, auth_info, auth_info_type, 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_session_context, capi_session_options, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, configure_session_extensions_params, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, 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, discovered_canvas, 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_result, execute_command_params, execute_command_result, extension, extension_context_push_input, 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_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, 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, 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, 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_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, 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_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, 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_respond_request, mcp_oauth_respond_result, mcp_register_external_client_request, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_restart_server_request, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, 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_stdio, 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_tools, mcp_unregister_external_client_request, memory_configuration, 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_billing, 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_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, 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_management, permission_decision_approve_for_location_approval_extension_permission_access, 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_management, permission_decision_approve_for_session_approval_extension_permission_access, 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_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_reject, permission_decision_request, 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_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_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_allow_all_request, 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_management, permissions_locations_add_tool_approval_details_extension_permission_access, 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_allow_all_request, permissions_set_allow_all_source, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, 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_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, poll_spawned_sessions_result, 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_reference, push_attachment_git_hub_reference_type, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_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_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, sandbox_config, 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_seatbelt, schedule_entry, schedule_list, 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_mode, send_request, send_result, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_auth_status, session_bulk_delete_result, session_capability, 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_stat_request, session_fs_stat_result, session_fs_write_file_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_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, 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_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, 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_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, 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_poll_spawned_sessions_event, sessions_poll_spawned_sessions_request, 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_working_directory_context, session_working_directory_context_host_type, shell_cancel_user_requested_request, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_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_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, 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, tool, tool_list, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_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_user_input_request, ui_register_direct_auto_mode_switch_handler_result, 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_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, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_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, workspace_summary_host_type, workspaces_workspace_details_host_type, session_context_info, subagent_settings, task_progress, workspace_summary) def to_dict(self) -> dict: result: dict = {} @@ -22706,6 +22827,9 @@ def to_dict(self) -> dict: result["McpExecuteSamplingRequest"] = from_dict(lambda x: x, self.mcp_execute_sampling_request) result["McpExecuteSamplingResult"] = from_dict(lambda x: x, self.mcp_execute_sampling_result) result["McpFilteredServer"] = to_class(MCPFilteredServer, self.mcp_filtered_server) + result["McpHeadersHandlePendingHeadersRefreshRequest"] = to_class(MCPHeadersHandlePendingHeadersRefreshRequest, self.mcp_headers_handle_pending_headers_refresh_request) + result["McpHeadersHandlePendingHeadersRefreshRequestRequest"] = to_class(MCPHeadersHandlePendingHeadersRefreshRequestRequest, self.mcp_headers_handle_pending_headers_refresh_request_request) + result["McpHeadersHandlePendingHeadersRefreshRequestResult"] = to_class(MCPHeadersHandlePendingHeadersRefreshRequestResult, self.mcp_headers_handle_pending_headers_refresh_request_result) result["McpHostState"] = to_class(MCPHostState, self.mcp_host_state) result["McpIsServerRunningRequest"] = to_class(MCPIsServerRunningRequest, self.mcp_is_server_running_request) result["McpIsServerRunningResult"] = to_class(MCPIsServerRunningResult, self.mcp_is_server_running_result) @@ -23449,7 +23573,7 @@ def _load_SessionOpenParams(obj: Any) -> "SessionOpenParams": case "handoff": return SessionsOpenHandoff.from_dict(obj) case _: raise ValueError(f"Unknown SessionOpenParams kind: {kind!r}") -# Result of invoking the slash command (text output, prompt to send to the agent, or completion). +# Result of invoking the slash command (text output, prompt to send to the agent, completion, or subcommand selection). SlashCommandInvocationResult = SlashCommandTextResult | SlashCommandAgentPromptResult | SlashCommandCompletedResult | SlashCommandSelectSubcommandResult def _load_SlashCommandInvocationResult(obj: Any) -> "SlashCommandInvocationResult": @@ -24418,6 +24542,19 @@ async def login(self, params: MCPOauthLoginRequest, *, timeout: float | None = N return MCPOauthLoginResult.from_dict(await self._client.request("session.mcp.oauth.login", params_dict, **_timeout_kwargs(timeout))) +# Experimental: this API group is experimental and may change or be removed. +class McpHeadersApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def handle_pending_headers_refresh_request(self, params: MCPHeadersHandlePendingHeadersRefreshRequestRequest, *, timeout: float | None = None) -> MCPHeadersHandlePendingHeadersRefreshRequestResult: + "Responds to a pending MCP dynamic headers refresh request. Hosts that subscribe to `mcp.headers_refresh_required` use this to provide short-lived per-server headers or to indicate that no dynamic headers are available for this refresh.\n\nArgs:\n params: MCP headers refresh request id and the host response.\n\nReturns:\n Indicates whether the pending MCP headers refresh response was accepted." + params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return MCPHeadersHandlePendingHeadersRefreshRequestResult.from_dict(await self._client.request("session.mcp.headers.handlePendingHeadersRefreshRequest", params_dict, **_timeout_kwargs(timeout))) + + # Experimental: this API group is experimental and may change or be removed. class McpAppsApi: def __init__(self, client: "JsonRpcClient", session_id: str): @@ -24465,6 +24602,7 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id self.oauth = McpOauthApi(client, session_id) + self.headers = McpHeadersApi(client, session_id) self.apps = McpAppsApi(client, session_id) async def list(self, *, timeout: float | None = None) -> MCPServerList: @@ -24663,7 +24801,7 @@ async def list(self, params: CommandsListRequest | None = None, *, timeout: floa return CommandList.from_dict(await self._client.request("session.commands.list", params_dict, **_timeout_kwargs(timeout))) async def invoke(self, params: CommandsInvokeRequest, *, timeout: float | None = None) -> SlashCommandInvocationResult: - "Invokes a slash command in the session.\n\nArgs:\n params: Slash command name and optional raw input string to invoke.\n\nReturns:\n Result of invoking the slash command (text output, prompt to send to the agent, or completion)." + "Invokes a slash command in the session.\n\nArgs:\n params: Slash command name and optional raw input string to invoke.\n\nReturns:\n Result of invoking the slash command (text output, prompt to send to the agent, completion, or subcommand selection)." params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id return _load_SlashCommandInvocationResult(await self._client.request("session.commands.invoke", params_dict, **_timeout_kwargs(timeout))) @@ -25723,6 +25861,10 @@ async def handle_llm_inference_http_request_chunk(params: dict) -> dict | None: "MCPExecuteSamplingParams", "MCPFilteredServer", "MCPGrantType", + "MCPHeadersHandlePendingHeadersRefreshRequest", + "MCPHeadersHandlePendingHeadersRefreshRequestKind", + "MCPHeadersHandlePendingHeadersRefreshRequestRequest", + "MCPHeadersHandlePendingHeadersRefreshRequestResult", "MCPHostState", "MCPIsServerRunningRequest", "MCPIsServerRunningResult", @@ -25779,6 +25921,7 @@ async def handle_llm_inference_http_request_chunk(params: dict) -> dict | None: "McpAppsSetHostContextDetailsTheme", "McpExecuteSamplingRequest", "McpExecuteSamplingResult", + "McpHeadersApi", "McpOauthApi", "McpOauthLoginGrantType", "McpServerAuthConfig", diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 0a3666c094..0ef5dccbc7 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -160,6 +160,7 @@ class SessionEventType(Enum): ASSISTANT_MESSAGE_START = "assistant.message_start" ASSISTANT_MESSAGE_DELTA = "assistant.message_delta" ASSISTANT_TURN_END = "assistant.turn_end" + ASSISTANT_IDLE = "assistant.idle" ASSISTANT_USAGE = "assistant.usage" MODEL_CALL_FAILURE = "model.call_failure" ABORT = "abort" @@ -191,6 +192,8 @@ class SessionEventType(Enum): SAMPLING_COMPLETED = "sampling.completed" MCP_OAUTH_REQUIRED = "mcp.oauth_required" MCP_OAUTH_COMPLETED = "mcp.oauth_completed" + MCP_HEADERS_REFRESH_REQUIRED = "mcp.headers_refresh_required" + MCP_HEADERS_REFRESH_COMPLETED = "mcp.headers_refresh_completed" SESSION_CUSTOM_NOTIFICATION = "session.custom_notification" EXTERNAL_TOOL_REQUESTED = "external_tool.requested" EXTERNAL_TOOL_COMPLETED = "external_tool.completed" @@ -957,6 +960,26 @@ def to_dict(self) -> dict: return result +@dataclass +class AssistantIdleData: + "Payload emitted whenever the main agent's processing loop goes idle, including while related background work (running agents or in-flight attached shell commands) is still pending and the session-level idle event is therefore deferred" + aborted: bool | None = None + + @staticmethod + def from_dict(obj: Any) -> "AssistantIdleData": + assert isinstance(obj, dict) + aborted = from_union([from_none, from_bool], obj.get("aborted")) + return AssistantIdleData( + aborted=aborted, + ) + + def to_dict(self) -> dict: + result: dict = {} + if self.aborted is not None: + result["aborted"] = from_union([from_none, from_bool], self.aborted) + return result + + @dataclass class AssistantIntentData: "Agent intent description for current activity or plan" @@ -2849,6 +2872,60 @@ def to_dict(self) -> dict: return result +@dataclass +class McpHeadersRefreshCompletedData: + "MCP headers refresh request completion notification" + outcome: McpHeadersRefreshCompletedOutcome + request_id: str + + @staticmethod + def from_dict(obj: Any) -> "McpHeadersRefreshCompletedData": + assert isinstance(obj, dict) + outcome = parse_enum(McpHeadersRefreshCompletedOutcome, obj.get("outcome")) + request_id = from_str(obj.get("requestId")) + return McpHeadersRefreshCompletedData( + outcome=outcome, + request_id=request_id, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["outcome"] = to_enum(McpHeadersRefreshCompletedOutcome, self.outcome) + result["requestId"] = from_str(self.request_id) + return result + + +@dataclass +class McpHeadersRefreshRequiredData: + "Dynamic headers refresh request for a remote MCP server" + reason: McpHeadersRefreshRequiredReason + request_id: str + server_name: str + server_url: str + + @staticmethod + def from_dict(obj: Any) -> "McpHeadersRefreshRequiredData": + assert isinstance(obj, dict) + reason = parse_enum(McpHeadersRefreshRequiredReason, obj.get("reason")) + request_id = from_str(obj.get("requestId")) + server_name = from_str(obj.get("serverName")) + server_url = from_str(obj.get("serverUrl")) + return McpHeadersRefreshRequiredData( + reason=reason, + request_id=request_id, + server_name=server_name, + server_url=server_url, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["reason"] = to_enum(McpHeadersRefreshRequiredReason, self.reason) + result["requestId"] = from_str(self.request_id) + result["serverName"] = from_str(self.server_name) + result["serverUrl"] = from_str(self.server_url) + return result + + @dataclass class McpOauthCompletedData: "MCP OAuth request completion notification" @@ -2875,6 +2952,7 @@ def to_dict(self) -> dict: @dataclass class McpOauthRequiredData: "OAuth authentication request for an MCP server" + reason: McpOauthRequestReason request_id: str server_name: str server_url: str @@ -2885,6 +2963,7 @@ class McpOauthRequiredData: @staticmethod def from_dict(obj: Any) -> "McpOauthRequiredData": assert isinstance(obj, dict) + reason = parse_enum(McpOauthRequestReason, obj.get("reason")) request_id = from_str(obj.get("requestId")) server_name = from_str(obj.get("serverName")) server_url = from_str(obj.get("serverUrl")) @@ -2892,6 +2971,7 @@ def from_dict(obj: Any) -> "McpOauthRequiredData": static_client_config = from_union([from_none, McpOauthRequiredStaticClientConfig.from_dict], obj.get("staticClientConfig")) www_authenticate_params = from_union([from_none, McpOauthWWWAuthenticateParams.from_dict], obj.get("wwwAuthenticateParams")) return McpOauthRequiredData( + reason=reason, request_id=request_id, server_name=server_name, server_url=server_url, @@ -2902,6 +2982,7 @@ def from_dict(obj: Any) -> "McpOauthRequiredData": def to_dict(self) -> dict: result: dict = {} + result["reason"] = to_enum(McpOauthRequestReason, self.reason) result["requestId"] = from_str(self.request_id) result["serverName"] = from_str(self.server_name) result["serverUrl"] = from_str(self.server_url) @@ -2918,6 +2999,7 @@ def to_dict(self) -> dict: class McpOauthRequiredStaticClientConfig: "Static OAuth client configuration, if the server specifies one" client_id: str + client_secret: str | None = None grant_type: str | None = None public_client: bool | None = None @@ -2925,10 +3007,12 @@ class McpOauthRequiredStaticClientConfig: def from_dict(obj: Any) -> "McpOauthRequiredStaticClientConfig": assert isinstance(obj, dict) client_id = from_str(obj.get("clientId")) + client_secret = from_union([from_none, from_str], obj.get("clientSecret")) grant_type = from_union([from_none, from_str], obj.get("grantType")) public_client = from_union([from_none, from_bool], obj.get("publicClient")) return McpOauthRequiredStaticClientConfig( client_id=client_id, + client_secret=client_secret, grant_type=grant_type, public_client=public_client, ) @@ -2936,6 +3020,8 @@ def from_dict(obj: Any) -> "McpOauthRequiredStaticClientConfig": def to_dict(self) -> dict: result: dict = {} result["clientId"] = from_str(self.client_id) + if self.client_secret is not None: + result["clientSecret"] = from_union([from_none, from_str], self.client_secret) if self.grant_type is not None: result["grantType"] = from_union([from_none, from_str], self.grant_type) if self.public_client is not None: @@ -2946,27 +3032,28 @@ def to_dict(self) -> dict: @dataclass class McpOauthWWWAuthenticateParams: "OAuth WWW-Authenticate parameters parsed from an MCP auth challenge" - resource_metadata_url: str error: str | None = None + resource_metadata_url: str | None = None scope: str | None = None @staticmethod def from_dict(obj: Any) -> "McpOauthWWWAuthenticateParams": assert isinstance(obj, dict) - resource_metadata_url = from_str(obj.get("resourceMetadataUrl")) error = from_union([from_none, from_str], obj.get("error")) + resource_metadata_url = from_union([from_none, from_str], obj.get("resourceMetadataUrl")) scope = from_union([from_none, from_str], obj.get("scope")) return McpOauthWWWAuthenticateParams( - resource_metadata_url=resource_metadata_url, error=error, + resource_metadata_url=resource_metadata_url, scope=scope, ) def to_dict(self) -> dict: result: dict = {} - result["resourceMetadataUrl"] = from_str(self.resource_metadata_url) if self.error is not None: result["error"] = from_union([from_none, from_str], self.error) + if self.resource_metadata_url is not None: + result["resourceMetadataUrl"] = from_union([from_none, from_str], self.resource_metadata_url) if self.scope is not None: result["scope"] = from_union([from_none, from_str], self.scope) return result @@ -4318,6 +4405,31 @@ def to_dict(self) -> dict: return result +@dataclass +class ResponseBudgetConfig: + "Optional response budget limits." + max_ai_credits: float | None = None + max_model_iterations: int | None = None + + @staticmethod + def from_dict(obj: Any) -> "ResponseBudgetConfig": + assert isinstance(obj, dict) + max_ai_credits = from_union([from_none, from_float], obj.get("maxAiCredits")) + max_model_iterations = from_union([from_none, from_int], obj.get("maxModelIterations")) + return ResponseBudgetConfig( + max_ai_credits=max_ai_credits, + max_model_iterations=max_model_iterations, + ) + + def to_dict(self) -> dict: + result: dict = {} + if self.max_ai_credits is not None: + result["maxAiCredits"] = from_union([from_none, to_float], self.max_ai_credits) + if self.max_model_iterations is not None: + result["maxModelIterations"] = from_union([from_none, to_int], self.max_model_iterations) + return result + + @dataclass class SamplingCompletedData: "Sampling request completion notification signaling UI dismissal" @@ -5097,6 +5209,7 @@ class SessionResumeData: reasoning_effort: str | None = None reasoning_summary: ReasoningSummary | None = None remote_steerable: bool | None = None + response_budget: ResponseBudgetConfig | None = None selected_model: str | None = None session_was_active: bool | None = None @@ -5113,6 +5226,7 @@ def from_dict(obj: Any) -> "SessionResumeData": reasoning_effort = from_union([from_none, from_str], obj.get("reasoningEffort")) reasoning_summary = from_union([from_none, lambda x: parse_enum(ReasoningSummary, x)], obj.get("reasoningSummary")) remote_steerable = from_union([from_none, from_bool], obj.get("remoteSteerable")) + response_budget = from_union([from_none, ResponseBudgetConfig.from_dict], obj.get("responseBudget")) selected_model = from_union([from_none, from_str], obj.get("selectedModel")) session_was_active = from_union([from_none, from_bool], obj.get("sessionWasActive")) return SessionResumeData( @@ -5126,6 +5240,7 @@ def from_dict(obj: Any) -> "SessionResumeData": reasoning_effort=reasoning_effort, reasoning_summary=reasoning_summary, remote_steerable=remote_steerable, + response_budget=response_budget, selected_model=selected_model, session_was_active=session_was_active, ) @@ -5150,6 +5265,8 @@ def to_dict(self) -> dict: result["reasoningSummary"] = from_union([from_none, lambda x: to_enum(ReasoningSummary, x)], self.reasoning_summary) if self.remote_steerable is not None: result["remoteSteerable"] = from_union([from_none, from_bool], self.remote_steerable) + if self.response_budget is not None: + result["responseBudget"] = from_union([from_none, lambda x: to_class(ResponseBudgetConfig, x)], self.response_budget) if self.selected_model is not None: result["selectedModel"] = from_union([from_none, from_str], self.selected_model) if self.session_was_active is not None: @@ -5401,6 +5518,7 @@ class SessionStartData: reasoning_effort: str | None = None reasoning_summary: ReasoningSummary | None = None remote_steerable: bool | None = None + response_budget: ResponseBudgetConfig | None = None selected_model: str | None = None @staticmethod @@ -5418,6 +5536,7 @@ def from_dict(obj: Any) -> "SessionStartData": reasoning_effort = from_union([from_none, from_str], obj.get("reasoningEffort")) reasoning_summary = from_union([from_none, lambda x: parse_enum(ReasoningSummary, x)], obj.get("reasoningSummary")) remote_steerable = from_union([from_none, from_bool], obj.get("remoteSteerable")) + response_budget = from_union([from_none, ResponseBudgetConfig.from_dict], obj.get("responseBudget")) selected_model = from_union([from_none, from_str], obj.get("selectedModel")) return SessionStartData( copilot_version=copilot_version, @@ -5432,6 +5551,7 @@ def from_dict(obj: Any) -> "SessionStartData": reasoning_effort=reasoning_effort, reasoning_summary=reasoning_summary, remote_steerable=remote_steerable, + response_budget=response_budget, selected_model=selected_model, ) @@ -5456,6 +5576,8 @@ def to_dict(self) -> dict: result["reasoningSummary"] = from_union([from_none, lambda x: to_enum(ReasoningSummary, x)], self.reasoning_summary) if self.remote_steerable is not None: result["remoteSteerable"] = from_union([from_none, from_bool], self.remote_steerable) + if self.response_budget is not None: + result["responseBudget"] = from_union([from_none, lambda x: to_class(ResponseBudgetConfig, x)], self.response_budget) if self.selected_model is not None: result["selectedModel"] = from_union([from_none, from_str], self.selected_model) return result @@ -7091,6 +7213,7 @@ class ToolExecutionStartData: model: str | None = None # Deprecated: this field is deprecated. parent_tool_call_id: str | None = None + shell_tool_info: ToolExecutionStartShellToolInfo | None = None tool_description: ToolExecutionStartToolDescription | None = None turn_id: str | None = None @@ -7105,6 +7228,7 @@ def from_dict(obj: Any) -> "ToolExecutionStartData": mcp_tool_name = from_union([from_none, from_str], obj.get("mcpToolName")) model = from_union([from_none, from_str], obj.get("model")) parent_tool_call_id = from_union([from_none, from_str], obj.get("parentToolCallId")) + shell_tool_info = from_union([from_none, ToolExecutionStartShellToolInfo.from_dict], obj.get("shellToolInfo")) tool_description = from_union([from_none, ToolExecutionStartToolDescription.from_dict], obj.get("toolDescription")) turn_id = from_union([from_none, from_str], obj.get("turnId")) return ToolExecutionStartData( @@ -7116,6 +7240,7 @@ def from_dict(obj: Any) -> "ToolExecutionStartData": mcp_tool_name=mcp_tool_name, model=model, parent_tool_call_id=parent_tool_call_id, + shell_tool_info=shell_tool_info, tool_description=tool_description, turn_id=turn_id, ) @@ -7136,6 +7261,8 @@ def to_dict(self) -> dict: result["model"] = from_union([from_none, from_str], self.model) if self.parent_tool_call_id is not None: result["parentToolCallId"] = from_union([from_none, from_str], self.parent_tool_call_id) + if self.shell_tool_info is not None: + result["shellToolInfo"] = from_union([from_none, lambda x: to_class(ToolExecutionStartShellToolInfo, x)], self.shell_tool_info) if self.tool_description is not None: result["toolDescription"] = from_union([from_none, lambda x: to_class(ToolExecutionStartToolDescription, x)], self.tool_description) if self.turn_id is not None: @@ -7143,6 +7270,29 @@ def to_dict(self) -> dict: return result +@dataclass +class ToolExecutionStartShellToolInfo: + "Shell-aware path hints for a shell tool's command, captured at start time so consumers can snapshot a file's pre-image before the tool runs." + has_write_file_redirection: bool + possible_paths: list[str] + + @staticmethod + def from_dict(obj: Any) -> "ToolExecutionStartShellToolInfo": + assert isinstance(obj, dict) + has_write_file_redirection = from_bool(obj.get("hasWriteFileRedirection")) + possible_paths = from_list(from_str, obj.get("possiblePaths")) + return ToolExecutionStartShellToolInfo( + has_write_file_redirection=has_write_file_redirection, + possible_paths=possible_paths, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["hasWriteFileRedirection"] = from_bool(self.has_write_file_redirection) + result["possiblePaths"] = from_list(from_str, self.possible_paths) + return result + + @dataclass class ToolExecutionStartToolDescription: "Tool definition metadata, present for MCP tools with MCP Apps support" @@ -7318,6 +7468,7 @@ class UserMessageData: content: str agent_mode: UserMessageAgentMode | None = None attachments: list[Attachment] | None = None + delivery: UserMessageDelivery | None = None interaction_id: str | None = None is_autopilot_continuation: bool | None = None native_document_path_fallback_paths: list[str] | None = None @@ -7332,6 +7483,7 @@ def from_dict(obj: Any) -> "UserMessageData": content = from_str(obj.get("content")) agent_mode = from_union([from_none, lambda x: parse_enum(UserMessageAgentMode, x)], obj.get("agentMode")) attachments = from_union([from_none, lambda x: from_list(_load_Attachment, x)], obj.get("attachments")) + delivery = from_union([from_none, lambda x: parse_enum(UserMessageDelivery, x)], obj.get("delivery")) interaction_id = from_union([from_none, from_str], obj.get("interactionId")) is_autopilot_continuation = from_union([from_none, from_bool], obj.get("isAutopilotContinuation")) native_document_path_fallback_paths = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("nativeDocumentPathFallbackPaths")) @@ -7343,6 +7495,7 @@ def from_dict(obj: Any) -> "UserMessageData": content=content, agent_mode=agent_mode, attachments=attachments, + delivery=delivery, interaction_id=interaction_id, is_autopilot_continuation=is_autopilot_continuation, native_document_path_fallback_paths=native_document_path_fallback_paths, @@ -7359,6 +7512,8 @@ def to_dict(self) -> dict: result["agentMode"] = from_union([from_none, lambda x: to_enum(UserMessageAgentMode, x)], self.agent_mode) if self.attachments is not None: result["attachments"] = from_union([from_none, lambda x: from_list(lambda x: x.to_dict(), x)], self.attachments) + if self.delivery is not None: + result["delivery"] = from_union([from_none, lambda x: to_enum(UserMessageDelivery, x)], self.delivery) if self.interaction_id is not None: result["interactionId"] = from_union([from_none, from_str], self.interaction_id) if self.is_autopilot_continuation is not None: @@ -7915,6 +8070,26 @@ class HandoffSourceType(Enum): LOCAL = "local" +class McpHeadersRefreshCompletedOutcome(Enum): + "How the pending MCP headers refresh request resolved." + # The host supplied dynamic headers. + HEADERS = "headers" + # The host responded with no dynamic headers. + NONE = "none" + # No response arrived within the bounded window. + TIMEOUT = "timeout" + + +class McpHeadersRefreshRequiredReason(Enum): + "Why dynamic headers are being requested." + # The transport is making its first dynamic header request for this server. + STARTUP = "startup" + # The previously cached dynamic headers expired. + TTL_EXPIRED = "ttl-expired" + # The server returned 401 and stale dynamic headers were invalidated. + AUTH_FAILED = "auth-failed" + + class McpOauthCompletionOutcome(Enum): "How the pending MCP OAuth request was completed" # The request completed with a token-backed OAuth provider. @@ -7923,6 +8098,18 @@ class McpOauthCompletionOutcome(Enum): CANCELLED = "cancelled" +class McpOauthRequestReason(Enum): + "Reason the runtime is requesting host-provided MCP OAuth credentials" + # Initial credentials are required before connecting to the MCP server. + INITIAL = "initial" + # The current host-provided credential was rejected and a replacement is requested. + REFRESH = "refresh" + # The server requires a new host authorization flow before continuing. + REAUTH = "reauth" + # The server requires a credential with additional scope or audience. + UPSCOPE = "upscope" + + class McpServerSource(Enum): "Configuration source: user, workspace, plugin, or builtin" # Server configured in the user's global MCP configuration. @@ -8149,6 +8336,16 @@ class UserMessageAgentMode(Enum): SHELL = "shell" +class UserMessageDelivery(Enum): + "How this user message was delivered to the agentic loop, relative to whether the loop was already running. This is the timing axis only; the message's origin (human vs. system/command/schedule/skill/etc.) is carried separately by `source`. A system-injected message has a delivery too — e.g. a background-task notification waking an idle agent is `idle`, the same mechanism as a human starting a fresh turn." + # Delivered while the loop was idle; starts its own run immediately (a human's fresh turn, or a system notification waking an idle agent). + IDLE = "idle" + # Injected into the current in-flight run while the agent was busy (immediate mode). + STEERING = "steering" + # Enqueued while the agent was busy; processed as its own run afterward. + QUEUED = "queued" + + class WorkingDirectoryContextHostType(Enum): "Hosting platform type of the repository (github or ado)" # Repository is hosted on GitHub. @@ -8165,7 +8362,7 @@ class WorkspaceFileChangedOperation(Enum): UPDATE = "update" -SessionEventData = SessionStartData | SessionResumeData | SessionRemoteSteerableChangedData | SessionErrorData | SessionIdleData | SessionTitleChangedData | SessionScheduleCreatedData | SessionScheduleCancelledData | SessionScheduleRearmedData | SessionAutopilotObjectiveChangedData | SessionInfoData | SessionWarningData | SessionModelChangeData | SessionModeChangedData | SessionPermissionsChangedData | SessionPlanChangedData | SessionTodosChangedData | SessionWorkspaceFileChangedData | SessionHandoffData | SessionTruncationData | SessionSnapshotRewindData | SessionShutdownData | SessionContextChangedData | SessionUsageInfoData | SessionCompactionStartData | SessionCompactionCompleteData | SessionTaskCompleteData | UserMessageData | PendingMessagesModifiedData | AssistantTurnStartData | AssistantIntentData | AssistantReasoningData | AssistantReasoningDeltaData | AssistantStreamingDeltaData | AssistantMessageData | AssistantMessageStartData | AssistantMessageDeltaData | AssistantTurnEndData | AssistantUsageData | ModelCallFailureData | AbortData | ToolUserRequestedData | ToolExecutionStartData | ToolExecutionPartialResultData | ToolExecutionProgressData | ToolExecutionCompleteData | SkillInvokedData | SubagentStartedData | SubagentCompletedData | SubagentFailedData | SubagentSelectedData | SubagentDeselectedData | HookStartData | HookEndData | HookProgressData | SessionBinaryAssetData | SystemMessageData | SystemNotificationData | PermissionRequestedData | PermissionCompletedData | UserInputRequestedData | UserInputCompletedData | ElicitationRequestedData | ElicitationCompletedData | SamplingRequestedData | SamplingCompletedData | McpOauthRequiredData | McpOauthCompletedData | SessionCustomNotificationData | ExternalToolRequestedData | ExternalToolCompletedData | CommandQueuedData | CommandExecuteData | CommandCompletedData | AutoModeSwitchRequestedData | AutoModeSwitchCompletedData | CommandsChangedData | CapabilitiesChangedData | ExitPlanModeRequestedData | ExitPlanModeCompletedData | SessionToolsUpdatedData | SessionBackgroundTasksChangedData | SessionSkillsLoadedData | SessionCustomAgentsUpdatedData | SessionMcpServersLoadedData | SessionMcpServerStatusChangedData | SessionExtensionsLoadedData | SessionCanvasOpenedData | SessionCanvasRegistryChangedData | SessionCanvasClosedData | SessionCanvasUnavailableData | SessionCanvasRecordedData | SessionCanvasRemovedData | SessionExtensionsAttachmentsPushedData | McpAppToolCallCompleteData | RawSessionEventData | Data +SessionEventData = SessionStartData | SessionResumeData | SessionRemoteSteerableChangedData | SessionErrorData | SessionIdleData | SessionTitleChangedData | SessionScheduleCreatedData | SessionScheduleCancelledData | SessionScheduleRearmedData | SessionAutopilotObjectiveChangedData | SessionInfoData | SessionWarningData | SessionModelChangeData | SessionModeChangedData | SessionPermissionsChangedData | SessionPlanChangedData | SessionTodosChangedData | SessionWorkspaceFileChangedData | SessionHandoffData | SessionTruncationData | SessionSnapshotRewindData | SessionShutdownData | SessionContextChangedData | SessionUsageInfoData | SessionCompactionStartData | SessionCompactionCompleteData | SessionTaskCompleteData | UserMessageData | PendingMessagesModifiedData | AssistantTurnStartData | AssistantIntentData | AssistantReasoningData | AssistantReasoningDeltaData | AssistantStreamingDeltaData | AssistantMessageData | AssistantMessageStartData | AssistantMessageDeltaData | AssistantTurnEndData | AssistantIdleData | AssistantUsageData | ModelCallFailureData | AbortData | ToolUserRequestedData | ToolExecutionStartData | ToolExecutionPartialResultData | ToolExecutionProgressData | ToolExecutionCompleteData | SkillInvokedData | SubagentStartedData | SubagentCompletedData | SubagentFailedData | SubagentSelectedData | SubagentDeselectedData | HookStartData | HookEndData | HookProgressData | SessionBinaryAssetData | SystemMessageData | SystemNotificationData | PermissionRequestedData | PermissionCompletedData | UserInputRequestedData | UserInputCompletedData | ElicitationRequestedData | ElicitationCompletedData | SamplingRequestedData | SamplingCompletedData | McpOauthRequiredData | McpOauthCompletedData | McpHeadersRefreshRequiredData | McpHeadersRefreshCompletedData | SessionCustomNotificationData | ExternalToolRequestedData | ExternalToolCompletedData | CommandQueuedData | CommandExecuteData | CommandCompletedData | AutoModeSwitchRequestedData | AutoModeSwitchCompletedData | CommandsChangedData | CapabilitiesChangedData | ExitPlanModeRequestedData | ExitPlanModeCompletedData | SessionToolsUpdatedData | SessionBackgroundTasksChangedData | SessionSkillsLoadedData | SessionCustomAgentsUpdatedData | SessionMcpServersLoadedData | SessionMcpServerStatusChangedData | SessionExtensionsLoadedData | SessionCanvasOpenedData | SessionCanvasRegistryChangedData | SessionCanvasClosedData | SessionCanvasUnavailableData | SessionCanvasRecordedData | SessionCanvasRemovedData | SessionExtensionsAttachmentsPushedData | McpAppToolCallCompleteData | RawSessionEventData | Data @dataclass @@ -8229,6 +8426,7 @@ def from_dict(obj: Any) -> "SessionEvent": case SessionEventType.ASSISTANT_MESSAGE_START: data = AssistantMessageStartData.from_dict(data_obj) case SessionEventType.ASSISTANT_MESSAGE_DELTA: data = AssistantMessageDeltaData.from_dict(data_obj) case SessionEventType.ASSISTANT_TURN_END: data = AssistantTurnEndData.from_dict(data_obj) + case SessionEventType.ASSISTANT_IDLE: data = AssistantIdleData.from_dict(data_obj) case SessionEventType.ASSISTANT_USAGE: data = AssistantUsageData.from_dict(data_obj) case SessionEventType.MODEL_CALL_FAILURE: data = ModelCallFailureData.from_dict(data_obj) case SessionEventType.ABORT: data = AbortData.from_dict(data_obj) @@ -8259,6 +8457,8 @@ def from_dict(obj: Any) -> "SessionEvent": case SessionEventType.SAMPLING_COMPLETED: data = SamplingCompletedData.from_dict(data_obj) case SessionEventType.MCP_OAUTH_REQUIRED: data = McpOauthRequiredData.from_dict(data_obj) case SessionEventType.MCP_OAUTH_COMPLETED: data = McpOauthCompletedData.from_dict(data_obj) + case SessionEventType.MCP_HEADERS_REFRESH_REQUIRED: data = McpHeadersRefreshRequiredData.from_dict(data_obj) + case SessionEventType.MCP_HEADERS_REFRESH_COMPLETED: data = McpHeadersRefreshCompletedData.from_dict(data_obj) case SessionEventType.SESSION_CUSTOM_NOTIFICATION: data = SessionCustomNotificationData.from_dict(data_obj) case SessionEventType.EXTERNAL_TOOL_REQUESTED: data = ExternalToolRequestedData.from_dict(data_obj) case SessionEventType.EXTERNAL_TOOL_COMPLETED: data = ExternalToolCompletedData.from_dict(data_obj) @@ -8322,6 +8522,7 @@ def session_event_to_dict(x: SessionEvent) -> Any: __all__ = [ "AbortData", "AbortReason", + "AssistantIdleData", "AssistantIntentData", "AssistantMessageData", "AssistantMessageDeltaData", @@ -8407,8 +8608,13 @@ def session_event_to_dict(x: SessionEvent) -> Any: "McpAppToolCallCompleteError", "McpAppToolCallCompleteToolMeta", "McpAppToolCallCompleteToolMetaUI", + "McpHeadersRefreshCompletedData", + "McpHeadersRefreshCompletedOutcome", + "McpHeadersRefreshRequiredData", + "McpHeadersRefreshRequiredReason", "McpOauthCompletedData", "McpOauthCompletionOutcome", + "McpOauthRequestReason", "McpOauthRequiredData", "McpOauthRequiredStaticClientConfig", "McpOauthWWWAuthenticateParams", @@ -8471,6 +8677,7 @@ def session_event_to_dict(x: SessionEvent) -> Any: "PlanChangedOperation", "RawSessionEventData", "ReasoningSummary", + "ResponseBudgetConfig", "SamplingCompletedData", "SamplingRequestedData", "SessionAutopilotObjectiveChangedData", @@ -8577,6 +8784,7 @@ def session_event_to_dict(x: SessionEvent) -> Any: "ToolExecutionPartialResultData", "ToolExecutionProgressData", "ToolExecutionStartData", + "ToolExecutionStartShellToolInfo", "ToolExecutionStartToolDescription", "ToolExecutionStartToolDescriptionMeta", "ToolExecutionStartToolDescriptionMetaUI", @@ -8586,6 +8794,7 @@ def session_event_to_dict(x: SessionEvent) -> Any: "UserInputRequestedData", "UserMessageAgentMode", "UserMessageData", + "UserMessageDelivery", "UserToolSessionApproval", "UserToolSessionApprovalCommands", "UserToolSessionApprovalCustomTool", diff --git a/python/copilot/session.py b/python/copilot/session.py index 0dc569f258..4cedce9f5c 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -39,6 +39,9 @@ ExternalToolTextResultForLlm, HandlePendingToolCallRequest, LogRequest, + MCPOauthHandlePendingRequest, + MCPOauthPendingRequestResponse, + MCPOauthPendingRequestResponseKind, ModelSwitchToRequest, PermissionDecision, PermissionDecisionApproveOnce, @@ -67,6 +70,7 @@ CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, + McpOauthRequiredData, PermissionRequest, PermissionRequestedData, SessionCanvasClosedData, @@ -367,6 +371,63 @@ def approve_all( return PermissionDecisionApproveOnce() +# ============================================================================ +# MCP Auth Types +# ============================================================================ + + +class McpAuthWwwAuthenticateParams(TypedDict, total=False): + """Parsed parameters from an MCP server's WWW-Authenticate response.""" + + resourceMetadataUrl: str + scope: str + error: str + + +class McpAuthStaticClientConfig(TypedDict, total=False): + """Static OAuth client configuration supplied by the MCP server, if available.""" + + clientId: Required[str] + clientSecret: str + grantType: Literal["client_credentials"] + publicClient: bool + + +class McpAuthRequest(TypedDict, total=False): + """MCP OAuth request that the SDK host can satisfy with a host-acquired token.""" + + requestId: Required[str] + serverName: Required[str] + serverUrl: Required[str] + reason: Required[Literal["initial", "refresh", "reauth", "upscope"]] + wwwAuthenticateParams: McpAuthWwwAuthenticateParams + resourceMetadata: str + staticClientConfig: McpAuthStaticClientConfig + + +class McpAuthToken(TypedDict, total=False): + """Host-provided OAuth token data for a pending MCP OAuth request.""" + + accessToken: Required[str] + tokenType: str + expiresIn: int + + +class McpAuthResult(TypedDict, total=False): + """Result returned by an MCP auth request handler.""" + + kind: Required[Literal["token", "cancelled"]] + accessToken: str + tokenType: str + expiresIn: int + + +McpAuthHandler = Callable[ + [McpAuthRequest, dict[str, str]], + McpAuthResult | McpAuthToken | None | Awaitable[McpAuthResult | McpAuthToken | None], +] + + # ============================================================================ # User Input Request Types # ============================================================================ @@ -1340,6 +1401,8 @@ def __init__( self._tool_handlers_lock = threading.Lock() self._permission_handler: _PermissionHandlerFn | None = None self._permission_handler_lock = threading.Lock() + self._mcp_auth_handler: McpAuthHandler | None = None + self._mcp_auth_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 @@ -1729,6 +1792,50 @@ def _handle_broadcast_event(self, event: SessionEvent) -> None: ) ) + case McpOauthRequiredData() as data: + with self._mcp_auth_handler_lock: + handler = self._mcp_auth_handler + if not data.request_id: + return + if not handler: + logger.warning( + "Received MCP OAuth request without a registered MCP auth handler. " + "SessionId=%s, RequestId=%s", + self.session_id, + data.request_id, + ) + return + request: McpAuthRequest = { + "requestId": data.request_id, + "serverName": data.server_name, + "serverUrl": data.server_url, + "reason": data.reason.value, + } + if data.www_authenticate_params is not None: + request["wwwAuthenticateParams"] = {} + if data.www_authenticate_params.resource_metadata_url is not None: + request["wwwAuthenticateParams"]["resourceMetadataUrl"] = ( + data.www_authenticate_params.resource_metadata_url + ) + if data.www_authenticate_params.scope is not None: + request["wwwAuthenticateParams"]["scope"] = data.www_authenticate_params.scope + if data.www_authenticate_params.error is not None: + request["wwwAuthenticateParams"]["error"] = data.www_authenticate_params.error + if data.resource_metadata is not None: + request["resourceMetadata"] = data.resource_metadata + if data.static_client_config is not None: + static_client_config: McpAuthStaticClientConfig = { + "clientId": data.static_client_config.client_id, + } + if data.static_client_config.client_secret is not None: + static_client_config["clientSecret"] = data.static_client_config.client_secret + if data.static_client_config.grant_type is not None: + static_client_config["grantType"] = data.static_client_config.grant_type + if data.static_client_config.public_client is not None: + static_client_config["publicClient"] = data.static_client_config.public_client + request["staticClientConfig"] = static_client_config + asyncio.ensure_future(self._execute_mcp_auth_and_respond(request, handler)) + case CommandExecuteData() as data: request_id = data.request_id command_name = data.command_name @@ -1942,6 +2049,57 @@ async def _execute_permission_and_respond( except (JsonRpcError, ProcessExitedError, OSError): pass # Connection lost or RPC error — nothing we can do + async def _execute_mcp_auth_and_respond( + self, + request: McpAuthRequest, + handler: McpAuthHandler, + ) -> None: + """Execute an MCP auth handler and respond via RPC.""" + request_id = request["requestId"] + try: + handler_start = time.perf_counter() + result = handler(request, {"session_id": self.session_id}) + if inspect.isawaitable(result): + result = await result + log_timing( + logger, + logging.DEBUG, + "CopilotSession._execute_mcp_auth_and_respond dispatch", + handler_start, + session_id=self.session_id, + request_id=request_id, + ) + + if result and result.get("kind", "token") == "token": + rpc_result = MCPOauthPendingRequestResponse( + kind=MCPOauthPendingRequestResponseKind.TOKEN, + access_token=result["accessToken"], + expires_in=result.get("expiresIn"), + token_type=result.get("tokenType"), + ) + else: + rpc_result = MCPOauthPendingRequestResponse( + kind=MCPOauthPendingRequestResponseKind.CANCELLED + ) + await self.rpc.mcp.oauth.handle_pending_request( + MCPOauthHandlePendingRequest( + request_id=request_id, + result=rpc_result, + ) + ) + except Exception: + try: + await self.rpc.mcp.oauth.handle_pending_request( + MCPOauthHandlePendingRequest( + request_id=request_id, + result=MCPOauthPendingRequestResponse( + kind=MCPOauthPendingRequestResponseKind.CANCELLED + ), + ) + ) + except (JsonRpcError, ProcessExitedError, OSError): + pass # Connection lost or RPC error — nothing we can do + async def _execute_command_and_respond( self, request_id: str, @@ -2126,6 +2284,11 @@ def _register_elicitation_handler(self, handler: ElicitationHandler | None) -> N with self._elicitation_handler_lock: self._elicitation_handler = handler + def _register_mcp_auth_handler(self, handler: McpAuthHandler | None) -> None: + """Register the MCP auth handler for this session.""" + with self._mcp_auth_handler_lock: + self._mcp_auth_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/e2e/test_mcp_oauth_e2e.py b/python/e2e/test_mcp_oauth_e2e.py new file mode 100644 index 0000000000..520ee6cf11 --- /dev/null +++ b/python/e2e/test_mcp_oauth_e2e.py @@ -0,0 +1,266 @@ +import asyncio +import json +import os +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from copilot.generated.rpc import MCPAppsCallToolRequest, MCPListToolsRequest +from copilot.session import MCPServerConfig, PermissionHandler +from copilot.session_events import McpServerStatus + +from .testharness import E2ETestContext, wait_for_condition + +TEST_MCP_OAUTH_SERVER = str( + (Path(__file__).parents[2] / "test" / "harness" / "test-mcp-oauth-server.mjs").resolve() +) +EXPECTED_TOKEN = "sdk-host-token" +REFRESH_TOKEN = f"{EXPECTED_TOKEN}-refresh" +UPSCOPE_TOKEN = f"{EXPECTED_TOKEN}-upscope" +REAUTH_TOKEN = f"{EXPECTED_TOKEN}-reauth" + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +async def _start_oauth_mcp_server() -> tuple[str, asyncio.subprocess.Process]: + process = await asyncio.create_subprocess_exec( + "node", + TEST_MCP_OAUTH_SERVER, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, "EXPECTED_TOKEN": EXPECTED_TOKEN}, + ) + assert process.stdout is not None + + try: + line = await asyncio.wait_for(process.stdout.readline(), timeout=10) + except TimeoutError as exc: + await _stop_process(process) + assert process.stderr is not None + stderr = (await process.stderr.read()).decode(errors="replace") + raise TimeoutError(f"Timed out waiting for OAuth MCP server: {stderr}") from exc + if not line: + assert process.stderr is not None + stderr = (await process.stderr.read()).decode(errors="replace") + raise RuntimeError(f"OAuth MCP server exited before listening: {stderr}") + text = line.decode().strip() + if text.startswith("Listening: "): + return text.removeprefix("Listening: "), process + + await _stop_process(process) + raise RuntimeError(f"Unexpected OAuth MCP server startup line: {text}") + + +async def _stop_process(process: asyncio.subprocess.Process) -> None: + if process.returncode is not None: + return + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=5) + except TimeoutError: + process.kill() + await process.wait() + + +async def _requests(base_url: str) -> list[dict[str, Any]]: + async with httpx.AsyncClient() as client: + response = await client.get(f"{base_url}/__requests") + response.raise_for_status() + return response.json() + + +async def _wait_for_mcp_server_status( + session, server_name: str, expected_status: McpServerStatus = McpServerStatus.CONNECTED +) -> None: + last_status = "" + + async def matches() -> bool: + nonlocal last_status + result = await session.rpc.mcp.list() + server = next((s for s in result.servers if s.name == server_name), None) + last_status = server.status.value if server is not None else "" + return server is not None and server.status == expected_status + + await wait_for_condition( + matches, + timeout=60.0, + poll_interval=0.2, + timeout_message=f"{server_name} did not reach {expected_status.value}; last status was {last_status}", + ) + + +class TestMcpOAuth: + async def test_should_satisfy_mcp_oauth_using_host_provided_token( + self, ctx: E2ETestContext + ): + url, process = await _start_oauth_mcp_server() + server_name = "oauth-protected-mcp" + observed_request = None + + def on_mcp_auth_request(request, _invocation): + nonlocal observed_request + observed_request = request + return { + "kind": "token", + "accessToken": EXPECTED_TOKEN, + "tokenType": "Bearer", + "expiresIn": 3600, + } + + try: + mcp_servers: dict[str, MCPServerConfig] = { + server_name: { + "type": "http", + "url": f"{url}/mcp", + "tools": ["*"], + } + } + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + on_mcp_auth_request=on_mcp_auth_request, + mcp_servers=mcp_servers, + ) as session: + await _wait_for_mcp_server_status(session, server_name) + + tools = await session.rpc.mcp.list_tools( + MCPListToolsRequest(server_name=server_name) + ) + assert [tool.name for tool in tools.tools] == ["whoami"] + + assert observed_request is not None + assert observed_request["serverName"] == server_name + assert observed_request["serverUrl"] == f"{url}/mcp" + assert observed_request["reason"] == "initial" + assert observed_request["wwwAuthenticateParams"] == { + "resourceMetadataUrl": f"{url}/.well-known/oauth-protected-resource", + "scope": "mcp.read", + "error": "invalid_token", + } + assert json.loads(observed_request["resourceMetadata"]) == { + "resource": f"{url}/mcp", + "authorization_servers": [url], + "scopes_supported": ["mcp.read"], + "bearer_methods_supported": ["header"], + } + + requests = await _requests(url) + assert any(request["authorization"] is None for request in requests) + assert any( + request["authorization"] == f"Bearer {EXPECTED_TOKEN}" for request in requests + ) + finally: + await _stop_process(process) + + async def test_should_request_replacement_tokens_across_mcp_oauth_lifecycle( + self, ctx: E2ETestContext + ): + url, process = await _start_oauth_mcp_server() + server_name = "oauth-lifecycle-mcp" + observed_requests: list[dict[str, Any]] = [] + refresh_count = 0 + + def on_mcp_auth_request(request, _invocation): + nonlocal refresh_count + observed_requests.append(request) + if request["reason"] == "refresh": + refresh_count += 1 + assert request["wwwAuthenticateParams"] == {"error": "invalid_token"} + if refresh_count > 1: + return {"kind": "cancelled"} + return {"kind": "token", "accessToken": REFRESH_TOKEN} + if request["reason"] == "upscope": + assert request["wwwAuthenticateParams"] == { + "resourceMetadataUrl": f"{url}/.well-known/oauth-protected-resource", + "scope": "mcp.write", + "error": "insufficient_scope", + } + return {"kind": "token", "accessToken": UPSCOPE_TOKEN} + if request["reason"] == "reauth": + return {"kind": "token", "accessToken": REAUTH_TOKEN} + return {"kind": "token", "accessToken": EXPECTED_TOKEN} + + try: + mcp_servers: dict[str, MCPServerConfig] = { + server_name: { + "type": "http", + "url": f"{url}/mcp", + "tools": ["*"], + } + } + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + on_mcp_auth_request=on_mcp_auth_request, + mcp_servers=mcp_servers, + enable_mcp_apps=True, + ) as session: + await _wait_for_mcp_server_status(session, server_name) + + for scenario in ("refresh", "upscope", "reauth"): + result = await session.rpc.mcp.apps.call_tool( + MCPAppsCallToolRequest( + origin_server_name=server_name, + server_name=server_name, + tool_name="whoami", + arguments={"scenario": scenario}, + ) + ) + assert result["content"] == [ + {"type": "text", "text": "oauth-test-user"} + ] + + assert [request["reason"] for request in observed_requests] == [ + "initial", + "refresh", + "upscope", + "refresh", + "reauth", + ] + requests = await _requests(url) + assert any( + request["authorization"] == f"Bearer {REFRESH_TOKEN}" for request in requests + ) + assert any( + request["authorization"] == f"Bearer {UPSCOPE_TOKEN}" for request in requests + ) + assert any( + request["authorization"] == f"Bearer {REAUTH_TOKEN}" for request in requests + ) + finally: + await _stop_process(process) + + async def test_should_cancel_pending_mcp_oauth_request( + self, ctx: E2ETestContext + ): + url, process = await _start_oauth_mcp_server() + server_name = "oauth-cancelled-mcp" + observed_request = None + + def on_mcp_auth_request(request, _invocation): + nonlocal observed_request + observed_request = request + return {"kind": "cancelled"} + + try: + mcp_servers: dict[str, MCPServerConfig] = { + server_name: { + "type": "http", + "url": f"{url}/mcp", + "tools": ["*"], + } + } + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + on_mcp_auth_request=on_mcp_auth_request, + mcp_servers=mcp_servers, + ) as session: + await _wait_for_mcp_server_status( + session, server_name, McpServerStatus.FAILED + ) + + assert observed_request is not None + assert observed_request["serverName"] == server_name + assert observed_request["reason"] == "initial" + finally: + await _stop_process(process) diff --git a/python/e2e/testharness/context.py b/python/e2e/testharness/context.py index 735c365c5f..f83544e8e8 100644 --- a/python/e2e/testharness/context.py +++ b/python/e2e/testharness/context.py @@ -18,6 +18,10 @@ from .proxy import CapiProxy +LOCAL_RUNTIME_CLI_PATH = Path( + "/Users/roji/.copilot/repos/copilot-worktrees/copilot-agent-runtime/roji-symmetrical-dollop/dist-cli/index.js" +) + def get_cli_path_for_tests() -> str: """Get CLI path for E2E tests. @@ -28,6 +32,9 @@ def get_cli_path_for_tests() -> str: if env_path and Path(env_path).exists(): return str(Path(env_path).resolve()) + if LOCAL_RUNTIME_CLI_PATH.exists(): + return str(LOCAL_RUNTIME_CLI_PATH) + # Look for CLI in sibling nodejs directory's node_modules. As of CLI 1.0.64-1 # the @github/copilot package is a thin loader; the runnable index.js ships in # the installed platform package (e.g. @github/copilot-linux-x64). @@ -168,6 +175,8 @@ def get_env(self) -> dict: "XDG_CONFIG_HOME": self.home_dir, "XDG_STATE_HOME": self.home_dir, "GITHUB_TOKEN": DEFAULT_GITHUB_TOKEN, + "COPILOT_MCP_APPS": "true", + "MCP_APPS": "true", } ) return env diff --git a/python/test_client.py b/python/test_client.py index f3f46c4d8b..0b41a34de2 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -4,6 +4,7 @@ This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.py instead. """ +import asyncio from datetime import UTC, datetime from unittest.mock import AsyncMock, Mock, patch @@ -28,6 +29,14 @@ ModelSupports, ) from copilot.session import PermissionHandler +from copilot.session_events import ( + McpOauthRequestReason, + McpOauthRequiredData, + McpOauthRequiredStaticClientConfig, + McpOauthWWWAuthenticateParams, + SessionEvent, + SessionEventType, +) from e2e.testharness import CLI_PATH @@ -139,6 +148,306 @@ async def test_resume_session_allows_none_permission_handler(self): class TestCreateSessionConfig: + @pytest.mark.asyncio + async def test_mcp_auth_handler_registers_interest_in_create_session(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 == "session.eventLog.registerInterest": + return {"id": "interest-1"} + if method == "session.create": + result = {"sessionId": params["sessionId"], "workspacePath": None} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + on_mcp_auth_request=lambda request: {"kind": "cancelled"}, + ) + + create_method, create_payload = captured[0] + interest_method, interest_payload = captured[1] + assert create_method == "session.create" + assert interest_method == "session.eventLog.registerInterest" + assert interest_payload["eventType"] == "mcp.oauth_required" + assert interest_payload["sessionId"] == create_payload["sessionId"] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_mcp_auth_interest_is_not_registered_without_handler(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 == "session.create": + result = {"sessionId": params["sessionId"], "workspacePath": None} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + if method == "session.resume": + return {"sessionId": params["sessionId"], "workspacePath": None} + return {} + + client._client.request = mock_request + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + on_event=lambda event: None, + ) + await client.resume_session( + "session-without-auth", + on_permission_request=PermissionHandler.approve_all, + on_event=lambda event: None, + ) + + assert session.session_id + assert not any( + method == "session.eventLog.registerInterest" + and params["eventType"] == "mcp.oauth_required" + for method, params in captured + ) + assert any( + method == "session.create" and params["requestPermission"] is True + for method, params in captured + ) + assert any( + method == "session.resume" and params["requestPermission"] is True + for method, params in captured + ) + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_mcp_auth_handler_registers_interest_before_resume(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 == "session.eventLog.registerInterest": + return {"id": "interest-1"} + if method == "session.resume": + return {"sessionId": params["sessionId"], "workspacePath": None} + return {} + + client._client.request = mock_request + await client.resume_session( + "session-with-auth", + on_permission_request=PermissionHandler.approve_all, + on_mcp_auth_request=lambda request: {"kind": "cancelled"}, + ) + + interest_method, interest_payload = captured[0] + resume_method, resume_payload = captured[1] + assert interest_method == "session.eventLog.registerInterest" + assert interest_payload == { + "sessionId": "session-with-auth", + "eventType": "mcp.oauth_required", + } + assert resume_method == "session.resume" + assert resume_payload["requestPermission"] is True + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_mcp_auth_handler_registers_interest_after_cloud_create_only_with_handler(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured: list[tuple[str, dict]] = [] + create_count = 0 + + async def mock_request(method, params, **kwargs): + nonlocal create_count + captured.append((method, params)) + if method == "session.eventLog.registerInterest": + return {"id": "interest-1"} + if method == "session.create": + create_count += 1 + result = { + "sessionId": f"server-assigned-session-{create_count}", + "workspacePath": None, + } + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + cloud = CloudSessionOptions( + repository=CloudSessionRepository( + owner="github", + name="copilot-sdk", + branch="main", + ) + ) + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + cloud=cloud, + ) + + assert not any( + method == "session.eventLog.registerInterest" + and params["eventType"] == "mcp.oauth_required" + for method, params in captured + ) + + captured.clear() + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + on_mcp_auth_request=lambda request: {"kind": "cancelled"}, + cloud=cloud, + ) + + create_method, _create_payload = captured[0] + interest_method, interest_payload = captured[1] + assert create_method == "session.create" + assert interest_method == "session.eventLog.registerInterest" + assert interest_payload == { + "sessionId": "server-assigned-session-2", + "eventType": "mcp.oauth_required", + } + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_mcp_auth_required_event_sends_host_token(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): + if method == "session.mcp.oauth.handlePendingRequest": + captured.append((method, params)) + return {"success": True} + if method == "session.create": + 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 + observed_request = None + + def handle_mcp_auth_request(request, invocation): + nonlocal observed_request + observed_request = request + return { + "accessToken": "host-token", + "tokenType": "Bearer", + } + + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + on_mcp_auth_request=handle_mcp_auth_request, + ) + + session._dispatch_event( + SessionEvent( + data=McpOauthRequiredData( + request_id="oauth-request", + server_name="oauth-server", + server_url="https://example.com/mcp", + reason=McpOauthRequestReason.INITIAL, + www_authenticate_params=McpOauthWWWAuthenticateParams( + resource_metadata_url="https://example.com/.well-known/oauth-protected-resource" + ), + resource_metadata='{"resource":"https://example.com/mcp"}', + static_client_config=McpOauthRequiredStaticClientConfig( + client_id="static-client", + client_secret="static-secret", + grant_type="client_credentials", + public_client=False, + ), + ), + id="evt-1", + timestamp="2026-01-01T00:00:00Z", + type=SessionEventType.MCP_OAUTH_REQUIRED, + ephemeral=True, + parent_id=None, + ) + ) + + for _ in range(200): + if captured: + break + await asyncio.sleep(0.005) + + assert observed_request is not None + assert observed_request["resourceMetadata"] == '{"resource":"https://example.com/mcp"}' + assert observed_request["wwwAuthenticateParams"]["resourceMetadataUrl"] == ( + "https://example.com/.well-known/oauth-protected-resource" + ) + assert observed_request["staticClientConfig"] == { + "clientId": "static-client", + "clientSecret": "static-secret", + "grantType": "client_credentials", + "publicClient": False, + } + assert captured == [ + ( + "session.mcp.oauth.handlePendingRequest", + { + "sessionId": session.session_id, + "requestId": "oauth-request", + "result": { + "kind": "token", + "accessToken": "host-token", + "tokenType": "Bearer", + }, + }, + ) + ] + + observed_request = None + session._dispatch_event( + SessionEvent( + data=McpOauthRequiredData( + request_id="oauth-request-without-metadata", + server_name="oauth-server", + server_url="https://example.com/mcp", + reason=McpOauthRequestReason.INITIAL, + ), + id="evt-2", + timestamp="2026-01-01T00:00:00Z", + type=SessionEventType.MCP_OAUTH_REQUIRED, + ephemeral=True, + parent_id=None, + ) + ) + + for _ in range(200): + if observed_request is not None: + break + await asyncio.sleep(0.005) + + assert observed_request is not None + assert "resourceMetadata" not in observed_request + assert "wwwAuthenticateParams" not in observed_request + 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 b1d85c0a58..36dc2478cc 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use super::session_events::{ AbortReason, ContextTier, McpServerSource, McpServerStatus, PermissionPromptRequest, - PermissionRule, ReasoningSummary, SessionMode, ShutdownType, SkillSource, + PermissionRule, ReasoningSummary, ResponseBudgetConfig, SessionMode, ShutdownType, SkillSource, UserToolSessionApproval, }; use crate::types::{RequestId, SessionEvent, SessionId}; @@ -321,6 +321,9 @@ pub mod rpc_methods { "session.mcp.oauth.handlePendingRequest"; /// `session.mcp.oauth.login` pub const SESSION_MCP_OAUTH_LOGIN: &str = "session.mcp.oauth.login"; + /// `session.mcp.headers.handlePendingHeadersRefreshRequest` + pub const SESSION_MCP_HEADERS_HANDLEPENDINGHEADERSREFRESHREQUEST: &str = + "session.mcp.headers.handlePendingHeadersRefreshRequest"; /// `session.mcp.apps.readResource` pub const SESSION_MCP_APPS_READRESOURCE: &str = "session.mcp.apps.readResource"; /// `session.mcp.apps.listTools` @@ -4285,6 +4288,52 @@ pub struct McpFilteredServer { pub redacted_reason: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpHeadersHandlePendingHeadersRefreshRequestHeaders { + /// Headers to overlay onto the MCP request. Dynamic headers override static config headers but do not replace SDK-managed request headers. + pub headers: HashMap, + pub kind: McpHeadersHandlePendingHeadersRefreshRequestHeadersKind, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpHeadersHandlePendingHeadersRefreshRequestNone { + pub kind: McpHeadersHandlePendingHeadersRefreshRequestNoneKind, +} + +/// MCP headers refresh request id and the host response. +/// +///

+/// +/// **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, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpHeadersHandlePendingHeadersRefreshRequestRequest { + /// Headers refresh request identifier from mcp.headers_refresh_required + pub request_id: RequestId, + /// Host response: supply dynamic headers or decline this refresh. + pub result: McpHeadersHandlePendingHeadersRefreshRequest, +} + +/// Indicates whether the pending MCP headers refresh response was accepted. +/// +///
+/// +/// **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 McpHeadersHandlePendingHeadersRefreshRequestResult { + /// Whether the response was accepted. False if the request was unknown, timed out, or already resolved. + pub success: bool, +} + /// Recorded MCP server connection failure. /// ///
@@ -4431,9 +4480,6 @@ pub struct McpOauthPendingRequestResponseToken { #[serde(skip_serializing_if = "Option::is_none")] pub expires_in: Option, pub kind: McpOauthPendingRequestResponseTokenKind, - /// Refresh token supplied by the host, if available. - #[serde(skip_serializing_if = "Option::is_none")] - pub refresh_token: Option, /// OAuth token type. Defaults to Bearer when omitted. #[serde(skip_serializing_if = "Option::is_none")] pub token_type: Option, @@ -5221,6 +5267,9 @@ pub struct ModelBillingTokenPrices { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ModelBilling { + /// Whole-number percentage discount (0-100) applied to usage billed through this model. Populated for the synthetic `auto` model, where requests routed by auto-mode are billed at a reduced rate; absent for concrete models. + #[serde(skip_serializing_if = "Option::is_none")] + pub discount_percent: Option, /// Billing cost multiplier relative to the base rate #[serde(skip_serializing_if = "Option::is_none")] pub multiplier: Option, @@ -9793,6 +9842,9 @@ pub struct SessionOpenOptions { /// Runtime context discriminator for agent filtering. #[serde(skip_serializing_if = "Option::is_none")] pub agent_context: Option, + /// Whether to include instructions from every MCP server in the system prompt instead of only allowlisted servers. + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_all_mcp_server_instructions: Option, /// Whether ask_user is explicitly disabled. #[serde(skip_serializing_if = "Option::is_none")] pub ask_user_disabled: Option, @@ -9941,6 +9993,9 @@ pub struct SessionOpenOptions { /// Whether this session supports remote steering. #[serde(skip_serializing_if = "Option::is_none")] pub remote_steerable: Option, + /// Initial experimental response budget limits for the session. + #[serde(skip_serializing_if = "Option::is_none")] + pub response_budget: Option, /// Whether the host is an interactive UI. #[serde(skip_serializing_if = "Option::is_none")] pub running_in_interactive_mode: Option, @@ -10884,6 +10939,9 @@ pub struct SessionUpdateOptionsParams { /// Runtime context discriminator (e.g., `cli`, `actions`). #[serde(skip_serializing_if = "Option::is_none")] pub agent_context: Option, + /// Whether to include instructions from every MCP server in the system prompt instead of only allowlisted servers. + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_all_mcp_server_instructions: Option, /// Whether to disable the `ask_user` tool (encourages autonomous behavior). #[serde(skip_serializing_if = "Option::is_none")] pub ask_user_disabled: Option, @@ -10992,6 +11050,9 @@ pub struct SessionUpdateOptionsParams { /// Reasoning summary mode for supported model clients. #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_summary: Option, + /// Optional experimental response budget limits. Pass null to clear the response budget. + #[serde(skip_serializing_if = "Option::is_none")] + pub response_budget: Option, /// Whether the session is running in an interactive UI. #[serde(skip_serializing_if = "Option::is_none")] pub running_in_interactive_mode: Option, @@ -11532,6 +11593,12 @@ pub struct SubagentSettings { /// Names of subagents the user has turned off; they cannot be dispatched #[serde(skip_serializing_if = "Option::is_none")] pub disabled_subagents: Option>, + /// Maximum number of subagents that can run concurrently; applies to usage-based billing users only + #[serde(skip_serializing_if = "Option::is_none")] + pub max_concurrency: Option, + /// Maximum subagent nesting depth; applies to usage-based billing users only + #[serde(skip_serializing_if = "Option::is_none")] + pub max_depth: Option, } /// Schema for the `TaskAgentInfo` type. @@ -12658,6 +12725,12 @@ pub struct UpdateSubagentSettingsRequestSubagents { /// Names of subagents the user has turned off; they cannot be dispatched #[serde(skip_serializing_if = "Option::is_none")] pub disabled_subagents: Option>, + /// Maximum number of subagents that can run concurrently; applies to usage-based billing users only + #[serde(skip_serializing_if = "Option::is_none")] + pub max_concurrency: Option, + /// Maximum subagent nesting depth; applies to usage-based billing users only + #[serde(skip_serializing_if = "Option::is_none")] + pub max_depth: Option, } /// Subagent settings to apply to the current session @@ -15178,6 +15251,21 @@ pub struct SessionMcpOauthLoginResult { pub authorization_url: Option, } +/// Indicates whether the pending MCP headers refresh response was accepted. +/// +///
+/// +/// **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 SessionMcpHeadersHandlePendingHeadersRefreshRequestResult { + /// Whether the response was accepted. False if the request was unknown, timed out, or already resolved. + pub success: bool, +} + /// Resource contents returned by the MCP server. /// ///
@@ -18123,6 +18211,35 @@ pub enum McpAppsSetHostContextDetailsTheme { Unknown, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum McpHeadersHandlePendingHeadersRefreshRequestHeadersKind { + #[serde(rename = "headers")] + #[default] + Headers, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum McpHeadersHandlePendingHeadersRefreshRequestNoneKind { + #[serde(rename = "none")] + #[default] + None, +} + +/// Host response: supply dynamic headers or decline this refresh. +/// +///
+/// +/// **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, Serialize, Deserialize)] +#[serde(untagged)] +pub enum McpHeadersHandlePendingHeadersRefreshRequest { + Headers(McpHeadersHandlePendingHeadersRefreshRequestHeaders), + None(McpHeadersHandlePendingHeadersRefreshRequestNone), +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum McpOauthPendingRequestResponseTokenKind { #[serde(rename = "token")] @@ -19945,7 +20062,7 @@ pub enum SlashCommandSelectSubcommandResultKind { SelectSubcommand, } -/// Result of invoking the slash command (text output, prompt to send to the agent, or completion). +/// Result of invoking the slash command (text output, prompt to send to the agent, completion, or subcommand selection). /// ///
/// diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index 57a5192dca..aad601456e 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -3143,7 +3143,7 @@ impl<'a> SessionRpcCommands<'a> { /// /// # Returns /// - /// Result of invoking the slash command (text output, prompt to send to the agent, or completion). + /// Result of invoking the slash command (text output, prompt to send to the agent, completion, or subcommand selection). /// ///
/// @@ -3887,6 +3887,13 @@ impl<'a> SessionRpcMcp<'a> { } } + /// `session.mcp.headers.*` sub-namespace. + pub fn headers(&self) -> SessionRpcMcpHeaders<'a> { + SessionRpcMcpHeaders { + session: self.session, + } + } + /// `session.mcp.oauth.*` sub-namespace. pub fn oauth(&self) -> SessionRpcMcpOauth<'a> { SessionRpcMcpOauth { @@ -4600,6 +4607,50 @@ impl<'a> SessionRpcMcpApps<'a> { } } +/// `session.mcp.headers.*` RPCs. +#[derive(Clone, Copy)] +pub struct SessionRpcMcpHeaders<'a> { + pub(crate) session: &'a Session, +} + +impl<'a> SessionRpcMcpHeaders<'a> { + /// Responds to a pending MCP dynamic headers refresh request. Hosts that subscribe to `mcp.headers_refresh_required` use this to provide short-lived per-server headers or to indicate that no dynamic headers are available for this refresh. + /// + /// Wire method: `session.mcp.headers.handlePendingHeadersRefreshRequest`. + /// + /// # Parameters + /// + /// * `params` - MCP headers refresh request id and the host response. + /// + /// # Returns + /// + /// Indicates whether the pending MCP headers refresh response was accepted. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn handle_pending_headers_refresh_request( + &self, + params: McpHeadersHandlePendingHeadersRefreshRequestRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_MCP_HEADERS_HANDLEPENDINGHEADERSREFRESHREQUEST, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + /// `session.mcp.oauth.*` RPCs. #[derive(Clone, Copy)] pub struct SessionRpcMcpOauth<'a> { diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index e094ec3655..36fecc054b 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -85,6 +85,8 @@ pub enum SessionEventType { AssistantMessageDelta, #[serde(rename = "assistant.turn_end")] AssistantTurnEnd, + #[serde(rename = "assistant.idle")] + AssistantIdle, #[serde(rename = "assistant.usage")] AssistantUsage, #[serde(rename = "model.call_failure")] @@ -152,6 +154,10 @@ pub enum SessionEventType { McpOauthRequired, #[serde(rename = "mcp.oauth_completed")] McpOauthCompleted, + #[serde(rename = "mcp.headers_refresh_required")] + McpHeadersRefreshRequired, + #[serde(rename = "mcp.headers_refresh_completed")] + McpHeadersRefreshCompleted, #[serde(rename = "session.custom_notification")] SessionCustomNotification, #[serde(rename = "external_tool.requested")] @@ -336,6 +342,8 @@ pub enum SessionEventData { AssistantMessageDelta(AssistantMessageDeltaData), #[serde(rename = "assistant.turn_end")] AssistantTurnEnd(AssistantTurnEndData), + #[serde(rename = "assistant.idle")] + AssistantIdle(AssistantIdleData), #[serde(rename = "assistant.usage")] AssistantUsage(AssistantUsageData), #[serde(rename = "model.call_failure")] @@ -396,6 +404,10 @@ pub enum SessionEventData { McpOauthRequired(McpOauthRequiredData), #[serde(rename = "mcp.oauth_completed")] McpOauthCompleted(McpOauthCompletedData), + #[serde(rename = "mcp.headers_refresh_required")] + McpHeadersRefreshRequired(McpHeadersRefreshRequiredData), + #[serde(rename = "mcp.headers_refresh_completed")] + McpHeadersRefreshCompleted(McpHeadersRefreshCompletedData), #[serde(rename = "session.custom_notification")] SessionCustomNotification(SessionCustomNotificationData), #[serde(rename = "external_tool.requested")] @@ -550,6 +562,18 @@ pub struct WorkingDirectoryContext { pub repository_host: Option, } +/// Optional response budget limits. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResponseBudgetConfig { + /// Maximum AI Credits allowed while responding to one top-level user message. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_ai_credits: Option, + /// Maximum model-call iterations allowed while responding to one top-level user message. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_model_iterations: Option, +} + /// Session event "session.start". Session initialization metadata including context and configuration #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -579,6 +603,9 @@ pub struct SessionStartData { /// Whether this session supports remote steering via GitHub #[serde(skip_serializing_if = "Option::is_none")] pub remote_steerable: Option, + /// Response budget limits configured at session creation time, if any + #[serde(skip_serializing_if = "Option::is_none")] + pub response_budget: Option, /// Model selected at session creation time, if any #[serde(skip_serializing_if = "Option::is_none")] pub selected_model: Option, @@ -620,6 +647,9 @@ pub struct SessionResumeData { /// Whether this session supports remote steering via GitHub #[serde(skip_serializing_if = "Option::is_none")] pub remote_steerable: Option, + /// Response budget limits currently configured at resume time; null when no budget is active + #[serde(skip_serializing_if = "Option::is_none")] + pub response_budget: Option, /// ISO 8601 timestamp when the session was resumed pub resume_time: String, /// Model currently selected at resume time @@ -1274,6 +1304,9 @@ pub struct UserMessageData { pub attachments: Option>, /// The user's message text as displayed in the timeline pub content: String, + /// How this message was delivered to the agentic loop relative to loop state (idle-start vs. steering/queued while busy). The timing axis; combine with `source` (origin) for the full picture. Used for telemetry attribution. + #[serde(skip_serializing_if = "Option::is_none")] + pub delivery: Option, /// CAPI interaction ID for correlating this user message with its turn #[serde(skip_serializing_if = "Option::is_none")] pub interaction_id: Option, @@ -1583,6 +1616,15 @@ pub struct AssistantTurnEndData { pub turn_id: String, } +/// Session event "assistant.idle". Payload emitted whenever the main agent's processing loop goes idle, including while related background work (running agents or in-flight attached shell commands) is still pending and the session-level idle event is therefore deferred +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssistantIdleData { + /// True when the preceding agentic loop was cancelled via abort signal + #[serde(skip_serializing_if = "Option::is_none")] + pub aborted: Option, +} + /// Token usage detail for a single billing category #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1820,6 +1862,16 @@ pub struct ToolUserRequestedData { pub tool_name: String, } +/// Shell-aware path hints for a shell tool's command, captured at start time so consumers can snapshot a file's pre-image before the tool runs. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolExecutionStartShellToolInfo { + /// Whether the command includes a file write redirection (e.g., > or >>). + pub has_write_file_redirection: bool, + /// File paths the command may read or write, derived from the command at start time. Produced by the same shell-aware extractor as PermissionRequestShell.possiblePaths, so it is present even when the command is auto-approved and no permission request fires. + pub possible_paths: Vec, +} + /// Schema for the `ToolExecutionStartToolDescriptionMetaUI` type. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1879,6 +1931,9 @@ pub struct ToolExecutionStartData { #[deprecated] #[serde(skip_serializing_if = "Option::is_none")] pub parent_tool_call_id: Option, + /// Shell-tool path hints derived from the command at start time for shell tools (bash/powershell/local_shell). Produced by the same shell-aware extractor as PermissionRequestShell.possiblePaths, so it is present even when the command is auto-approved and no permission request fires. Absent for non-shell tools. + #[serde(skip_serializing_if = "Option::is_none")] + pub shell_tool_info: Option, /// Unique identifier for this tool call pub tool_call_id: String, /// Tool definition metadata, present for MCP tools with MCP Apps support @@ -3274,6 +3329,9 @@ pub struct SamplingCompletedData { pub struct McpOauthRequiredStaticClientConfig { /// OAuth client ID for the server pub client_id: String, + /// Optional OAuth client secret for confidential static clients, when the runtime can resolve one + #[serde(skip_serializing_if = "Option::is_none")] + pub client_secret: Option, /// Optional non-default OAuth grant type. When set to 'client_credentials', the OAuth flow runs headlessly using the client_id + keychain-stored secret (no browser, no callback server). #[serde(skip_serializing_if = "Option::is_none")] pub grant_type: Option, @@ -3289,8 +3347,9 @@ pub struct McpOauthWWWAuthenticateParams { /// OAuth error from the WWW-Authenticate error parameter, if present #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, - /// Protected resource metadata URL from the WWW-Authenticate resource_metadata parameter - pub resource_metadata_url: String, + /// Protected resource metadata URL from the WWW-Authenticate resource_metadata parameter, if present + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_metadata_url: Option, /// Requested OAuth scopes from the WWW-Authenticate scope parameter, if present #[serde(skip_serializing_if = "Option::is_none")] pub scope: Option, @@ -3300,6 +3359,8 @@ pub struct McpOauthWWWAuthenticateParams { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpOauthRequiredData { + /// Why the runtime is requesting host-provided OAuth credentials. + pub reason: McpOauthRequestReason, /// Unique identifier for this OAuth request; used to respond via session.mcp.oauth.handlePendingRequest pub request_id: RequestId, /// Raw OAuth protected-resource metadata document fetched for the MCP server, if available @@ -3327,6 +3388,30 @@ pub struct McpOauthCompletedData { pub request_id: RequestId, } +/// Session event "mcp.headers_refresh_required". Dynamic headers refresh request for a remote MCP server +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpHeadersRefreshRequiredData { + /// Why dynamic headers are being requested. + pub reason: McpHeadersRefreshRequiredReason, + /// Unique identifier for this headers refresh request; used to respond via session.mcp.headers.handlePendingHeadersRefreshRequest() + pub request_id: RequestId, + /// Display name of the remote MCP server requesting headers + pub server_name: String, + /// URL of the remote MCP server requesting headers + pub server_url: String, +} + +/// Session event "mcp.headers_refresh_completed". MCP headers refresh request completion notification +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpHeadersRefreshCompletedData { + /// How the pending MCP headers refresh request resolved. + pub outcome: McpHeadersRefreshCompletedOutcome, + /// Request ID of the resolved headers refresh request + pub request_id: RequestId, +} + /// Session event "session.custom_notification". Opaque custom notification data. Consumers may branch on source and name, but payload semantics are source-defined. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -4094,6 +4179,24 @@ pub enum UserMessageAgentMode { Unknown, } +/// How this user message was delivered to the agentic loop, relative to whether the loop was already running. This is the timing axis only; the message's origin (human vs. system/command/schedule/skill/etc.) is carried separately by `source`. A system-injected message has a delivery too — e.g. a background-task notification waking an idle agent is `idle`, the same mechanism as a human starting a fresh turn. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum UserMessageDelivery { + /// Delivered while the loop was idle; starts its own run immediately (a human's fresh turn, or a system notification waking an idle agent). + #[serde(rename = "idle")] + Idle, + /// Injected into the current in-flight run while the agent was busy (immediate mode). + #[serde(rename = "steering")] + Steering, + /// Enqueued while the agent was busy; processed as its own run afterward. + #[serde(rename = "queued")] + Queued, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// The system that produced a citation. /// ///
@@ -4824,6 +4927,27 @@ pub enum ElicitationCompletedAction { Unknown, } +/// Reason the runtime is requesting host-provided MCP OAuth credentials +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum McpOauthRequestReason { + /// Initial credentials are required before connecting to the MCP server. + #[serde(rename = "initial")] + Initial, + /// The current host-provided credential was rejected and a replacement is requested. + #[serde(rename = "refresh")] + Refresh, + /// The server requires a new host authorization flow before continuing. + #[serde(rename = "reauth")] + Reauth, + /// The server requires a credential with additional scope or audience. + #[serde(rename = "upscope")] + Upscope, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// Optional non-default OAuth grant type. When set to 'client_credentials', the OAuth flow runs headlessly using the client_id + keychain-stored secret (no browser, no callback server). #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum McpOauthRequiredStaticClientConfigGrantType { @@ -4847,6 +4971,42 @@ pub enum McpOauthCompletionOutcome { Unknown, } +/// Why dynamic headers are being requested. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum McpHeadersRefreshRequiredReason { + /// The transport is making its first dynamic header request for this server. + #[serde(rename = "startup")] + Startup, + /// The previously cached dynamic headers expired. + #[serde(rename = "ttl-expired")] + TtlExpired, + /// The server returned 401 and stale dynamic headers were invalidated. + #[serde(rename = "auth-failed")] + AuthFailed, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// How the pending MCP headers refresh request resolved. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum McpHeadersRefreshCompletedOutcome { + /// The host supplied dynamic headers. + #[serde(rename = "headers")] + Headers, + /// The host responded with no dynamic headers. + #[serde(rename = "none")] + None, + /// No response arrived within the bounded window. + #[serde(rename = "timeout")] + Timeout, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// The user's auto-mode-switch choice #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum AutoModeSwitchResponse { diff --git a/rust/src/handler.rs b/rust/src/handler.rs index dadd1706ff..22a16727ed 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -19,8 +19,13 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use crate::generated::api_types::{ - PermissionDecision, PermissionDecisionApproveOnce, PermissionDecisionReject, - PermissionDecisionUserNotAvailable, + McpOauthPendingRequestResponse, McpOauthPendingRequestResponseCancelled, + McpOauthPendingRequestResponseCancelledKind, McpOauthPendingRequestResponseToken, + McpOauthPendingRequestResponseTokenKind, PermissionDecision, PermissionDecisionApproveOnce, + PermissionDecisionReject, PermissionDecisionUserNotAvailable, +}; +use crate::session_events::{ + McpOauthRequestReason, McpOauthRequiredStaticClientConfig, McpOauthWWWAuthenticateParams, }; use crate::types::{ ElicitationRequest, ElicitationResult, ExitPlanModeData, PermissionRequestData, RequestId, @@ -159,6 +164,73 @@ pub trait ElicitationHandler: Send + Sync + 'static { ) -> ElicitationResult; } +/// MCP OAuth request that the SDK host can satisfy with a host-acquired token. +#[derive(Debug, Clone)] +pub struct McpAuthRequest { + /// Display name of the MCP server that requires OAuth. + pub server_name: String, + /// URL of the MCP server that requires OAuth. + pub server_url: String, + /// Why the runtime is requesting host-provided OAuth credentials. + pub reason: McpOauthRequestReason, + /// Parsed WWW-Authenticate parameters from the MCP server, if available. + pub www_authenticate_params: Option, + /// Raw RFC 9728 protected-resource metadata JSON fetched by the runtime, if available. + pub resource_metadata: Option, + /// Static OAuth client configuration, if the server specifies one. + pub static_client_config: Option, +} + +/// Result returned by an MCP auth request handler. +#[derive(Debug, Clone)] +pub enum McpAuthResult { + /// Supplies host-acquired OAuth token data. + Token { + /// Access token acquired by the SDK host. + access_token: String, + /// OAuth token type. Defaults to Bearer when omitted. + token_type: Option, + /// Token lifetime in seconds, if known. + expires_in: Option, + }, + /// Declines or cancels the pending OAuth request. + Cancelled, +} + +impl McpAuthResult { + pub(crate) fn into_wire(self) -> McpOauthPendingRequestResponse { + match self { + Self::Token { + access_token, + token_type, + expires_in, + } => McpOauthPendingRequestResponse::Token(McpOauthPendingRequestResponseToken { + access_token, + token_type, + expires_in, + kind: McpOauthPendingRequestResponseTokenKind::Token, + }), + Self::Cancelled => { + McpOauthPendingRequestResponse::Cancelled(McpOauthPendingRequestResponseCancelled { + kind: McpOauthPendingRequestResponseCancelledKind::Cancelled, + }) + } + } + } +} + +/// Handler for MCP server OAuth requests. +#[async_trait] +pub trait McpAuthHandler: Send + Sync + 'static { + /// Resolve an MCP OAuth request with host token data or cancellation. + async fn handle( + &self, + session_id: SessionId, + request_id: RequestId, + request: McpAuthRequest, + ) -> McpAuthResult; +} + /// Handler for `user_input.requested` events from the `ask_user` tool. /// /// When unset, `requestUserInput: false` goes on the wire and the @@ -266,4 +338,23 @@ mod tests { PermissionResult::Decision(PermissionDecision::Reject(_)) )); } + + #[test] + fn mcp_auth_result_token_converts_to_wire_response() { + let wire = McpAuthResult::Token { + access_token: "host-token".to_string(), + token_type: Some("Bearer".to_string()), + expires_in: Some(3600), + } + .into_wire(); + + match wire { + McpOauthPendingRequestResponse::Token(token) => { + assert_eq!(token.access_token, "host-token"); + assert_eq!(token.token_type.as_deref(), Some("Bearer")); + assert_eq!(token.expires_in, Some(3600)); + } + McpOauthPendingRequestResponse::Cancelled(_) => panic!("expected token response"), + } + } } diff --git a/rust/src/session.rs b/rust/src/session.rs index 18b91b4377..971e7b9e4b 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -11,14 +11,17 @@ use tokio_util::sync::CancellationToken; use tracing::{Instrument, warn}; use crate::canvas::CanvasHandler; -use crate::generated::api_types::{LogRequest, ModelSwitchToRequest, OpenCanvasInstance}; +use crate::generated::api_types::{ + LogRequest, ModelSwitchToRequest, OpenCanvasInstance, RegisterEventInterestParams, rpc_methods, +}; use crate::generated::session_events::{ - CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, + CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, McpOauthRequiredData, SessionCanvasClosedData, SessionErrorData, SessionEventType, }; use crate::handler::{ AutoModeSwitchHandler, AutoModeSwitchResponse, ElicitationHandler, ExitPlanModeHandler, - PermissionHandler, PermissionResult, UserInputHandler, UserInputResponse, + McpAuthHandler, McpAuthRequest, McpAuthResult, PermissionHandler, PermissionResult, + UserInputHandler, UserInputResponse, }; use crate::hooks::SessionHooks; use crate::provider_token::BearerTokenProvider; @@ -49,6 +52,7 @@ use crate::{ pub(crate) struct SessionHandlers { pub permission: Option>, pub elicitation: Option>, + pub mcp_auth: Option>, pub user_input: Option>, pub exit_plan_mode: Option>, pub auto_mode_switch: Option>, @@ -881,6 +885,7 @@ impl Client { let handlers = SessionHandlers { permission: permission_handler, elicitation: runtime.elicitation_handler.take(), + mcp_auth: runtime.mcp_auth_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(), @@ -895,6 +900,7 @@ impl Client { let canvas_handler = runtime.canvas_handler.take(); let session_fs_provider = runtime.session_fs_provider.take(); let bearer_token_providers = std::mem::take(&mut runtime.bearer_token_providers); + let has_mcp_auth_handler = handlers.mcp_auth.is_some(); if self.inner.session_fs_configured && session_fs_provider.is_none() { return Err(ErrorKind::Session(SessionErrorKind::SessionFsProviderRequired).into()); } @@ -1030,6 +1036,9 @@ 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?; + } tracing::debug!( elapsed_ms = total_start.elapsed().as_millis(), @@ -1139,6 +1148,7 @@ impl Client { let handlers = SessionHandlers { permission: permission_handler, elicitation: runtime.elicitation_handler.take(), + mcp_auth: runtime.mcp_auth_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(), @@ -1153,6 +1163,7 @@ impl Client { let canvas_handler = runtime.canvas_handler.take(); let session_fs_provider = runtime.session_fs_provider.take(); let bearer_token_providers = std::mem::take(&mut runtime.bearer_token_providers); + let has_mcp_auth_handler = handlers.mcp_auth.is_some(); if self.inner.session_fs_configured && session_fs_provider.is_none() { return Err(ErrorKind::Session(SessionErrorKind::SessionFsProviderRequired).into()); } @@ -1170,6 +1181,9 @@ impl Client { let mut params = serde_json::to_value(&wire)?; let trace_ctx = self.resolve_trace_context().await; inject_trace_context(&mut params, &trace_ctx); + if has_mcp_auth_handler { + register_mcp_auth_interest(self, &session_id).await?; + } let capabilities = Arc::new(parking_lot::RwLock::new(SessionCapabilities::default())); let setup_start = Instant::now(); @@ -1477,6 +1491,17 @@ fn notification_permission_payload(result: &PermissionResult) -> Option { } } +async fn register_mcp_auth_interest(client: &Client, session_id: &SessionId) -> Result<(), Error> { + let mut params = serde_json::to_value(RegisterEventInterestParams { + event_type: "mcp.oauth_required".to_string(), + })?; + params["sessionId"] = Value::String(session_id.to_string()); + client + .call(rpc_methods::SESSION_EVENTLOG_REGISTERINTEREST, Some(params)) + .await?; + Ok(()) +} + fn tool_failure_result(message: impl Into) -> ToolResult { let message = message.into(); ToolResult::Expanded(ToolResultExpanded { @@ -1944,6 +1969,90 @@ async fn handle_notification( .instrument(span), ); } + SessionEventType::McpOauthRequired => { + let Some(request_id) = extract_request_id(¬ification.event.data) else { + return; + }; + let Some(mcp_auth_handler) = handlers.mcp_auth.clone() else { + warn!( + session_id = %session_id, + request_id = %request_id, + "received MCP OAuth request without a registered MCP auth handler" + ); + return; + }; + let data: McpOauthRequiredData = + match serde_json::from_value(notification.event.data.clone()) { + Ok(d) => d, + Err(e) => { + warn!(error = %e, "failed to deserialize MCP OAuth request"); + return; + } + }; + let request = McpAuthRequest { + server_name: data.server_name, + server_url: data.server_url, + reason: data.reason, + www_authenticate_params: data.www_authenticate_params, + resource_metadata: data.resource_metadata, + static_client_config: data.static_client_config, + }; + let client = client.clone(); + let sid = session_id.clone(); + let span = tracing::error_span!( + "mcp_auth_request_handler", + session_id = %sid, + request_id = %request_id + ); + tokio::spawn( + async move { + let cancel = McpAuthResult::Cancelled; + let handler_task = tokio::spawn({ + let sid = sid.clone(); + let request_id = request_id.clone(); + let span = tracing::error_span!( + "mcp_auth_callback", + session_id = %sid, + request_id = %request_id + ); + async move { + let handler_start = Instant::now(); + let response = mcp_auth_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, + "McpAuthHandler::handle dispatch" + ); + response + } + .instrument(span) + }); + let result = match handler_task.await { + Ok(result) => result, + Err(_) => cancel, + }; + let rpc_start = Instant::now(); + let _ = client + .call( + "session.mcp.oauth.handlePendingRequest", + Some(serde_json::json!({ + "sessionId": sid, + "requestId": request_id, + "result": result.into_wire(), + })), + ) + .await; + tracing::debug!( + elapsed_ms = rpc_start.elapsed().as_millis(), + "Session::handle_notification MCP auth response sent" + ); + } + .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 75408db026..290937e392 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -24,8 +24,8 @@ use crate::generated::api_types::OpenCanvasInstance; pub use crate::generated::session_events::ContextTier; use crate::generated::session_events::ReasoningSummary; use crate::handler::{ - AutoModeSwitchHandler, ElicitationHandler, ExitPlanModeHandler, PermissionHandler, - UserInputHandler, + AutoModeSwitchHandler, ElicitationHandler, ExitPlanModeHandler, McpAuthHandler, + PermissionHandler, UserInputHandler, }; use crate::hooks::SessionHooks; use crate::provider_token::BearerTokenProvider; @@ -1772,6 +1772,9 @@ pub struct SessionConfig { /// Optional elicitation-request handler. When `None`, /// `requestElicitation: false` goes on the wire. pub elicitation_handler: Option>, + /// 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 user-input handler. When `None`, /// `requestUserInput: false` goes on the wire and the `ask_user` /// tool is disabled. @@ -1901,6 +1904,10 @@ impl std::fmt::Debug for SessionConfig { "elicitation_handler", &self.elicitation_handler.as_ref().map(|_| ""), ) + .field( + "mcp_auth_handler", + &self.mcp_auth_handler.as_ref().map(|_| ""), + ) .field( "user_input_handler", &self.user_input_handler.as_ref().map(|_| ""), @@ -1990,6 +1997,7 @@ impl Default for SessionConfig { session_fs_provider: None, permission_handler: None, elicitation_handler: None, + mcp_auth_handler: None, user_input_handler: None, exit_plan_mode_handler: None, auto_mode_switch_handler: None, @@ -2013,6 +2021,7 @@ pub(crate) struct SessionConfigRuntime { pub permission_handler: Option>, pub permission_policy: Option, pub elicitation_handler: Option>, + pub mcp_auth_handler: Option>, pub user_input_handler: Option>, pub exit_plan_mode_handler: Option>, pub auto_mode_switch_handler: Option>, @@ -2143,6 +2152,7 @@ impl SessionConfig { permission_handler: self.permission_handler, permission_policy: self.permission_policy, elicitation_handler: self.elicitation_handler, + mcp_auth_handler: self.mcp_auth_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, @@ -2173,6 +2183,12 @@ impl SessionConfig { self } + /// Install an [`McpAuthHandler`] for host-provided MCP OAuth tokens. + pub fn with_mcp_auth_handler(mut self, handler: Arc) -> Self { + self.mcp_auth_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 { @@ -2851,6 +2867,8 @@ pub struct ResumeSessionConfig { /// Optional elicitation handler. See /// [`SessionConfig::elicitation_handler`]. pub elicitation_handler: Option>, + /// Optional MCP OAuth handler. See [`SessionConfig::mcp_auth_handler`]. + pub mcp_auth_handler: Option>, /// Optional user-input handler. See /// [`SessionConfig::user_input_handler`]. pub user_input_handler: Option>, @@ -3103,6 +3121,7 @@ impl ResumeSessionConfig { permission_handler: self.permission_handler, permission_policy: self.permission_policy, elicitation_handler: self.elicitation_handler, + mcp_auth_handler: self.mcp_auth_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, @@ -3182,6 +3201,7 @@ impl ResumeSessionConfig { continue_pending_work: None, permission_handler: None, elicitation_handler: None, + mcp_auth_handler: None, user_input_handler: None, exit_plan_mode_handler: None, auto_mode_switch_handler: None, @@ -3207,6 +3227,12 @@ impl ResumeSessionConfig { self } + /// Install an [`McpAuthHandler`] for host-provided MCP OAuth tokens. + pub fn with_mcp_auth_handler(mut self, handler: Arc) -> Self { + self.mcp_auth_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); diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 59b83ab27c..79059c7f28 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -37,6 +37,8 @@ mod hooks; mod hooks_extended; #[path = "e2e/mcp_and_agents.rs"] mod mcp_and_agents; +#[path = "e2e/mcp_oauth.rs"] +mod mcp_oauth; #[path = "e2e/mode_empty.rs"] mod mode_empty; #[path = "e2e/mode_handlers.rs"] diff --git a/rust/tests/e2e/mcp_oauth.rs b/rust/tests/e2e/mcp_oauth.rs new file mode 100644 index 0000000000..f04ff0352a --- /dev/null +++ b/rust/tests/e2e/mcp_oauth.rs @@ -0,0 +1,430 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::handler::{McpAuthHandler, McpAuthRequest, McpAuthResult}; +use github_copilot_sdk::rpc::{McpAppsCallToolRequest, McpListToolsRequest}; +use github_copilot_sdk::session::Session; +use github_copilot_sdk::session_events::{McpOauthRequestReason, McpServerStatus}; +use github_copilot_sdk::{McpHttpServerConfig, McpServerConfig, RequestId, SessionId}; +use parking_lot::Mutex; +use serde::Deserialize; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child, Command}; + +use super::support::{wait_for_condition, with_e2e_context_no_snapshot}; + +const EXPECTED_TOKEN: &str = "sdk-host-token"; +const REFRESH_TOKEN: &str = "sdk-host-token-refresh"; +const UPSCOPE_TOKEN: &str = "sdk-host-token-upscope"; +const REAUTH_TOKEN: &str = "sdk-host-token-reauth"; + +#[tokio::test] +async fn should_satisfy_mcp_oauth_using_host_provided_token() { + with_e2e_context_no_snapshot(|ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let mut oauth_server = OAuthMcpServer::start( + ctx.repo_root() + .join("test/harness/test-mcp-oauth-server.mjs"), + ) + .await; + let server_name = "oauth-protected-mcp"; + let handler = Arc::new(TokenAuthHandler::default()); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_auth_handler(handler.clone()) + .with_mcp_servers(HashMap::from([( + server_name.to_string(), + McpServerConfig::Http(McpHttpServerConfig { + tools: Some(vec!["*".to_string()]), + timeout: None, + url: format!("{}/mcp", oauth_server.url), + headers: HashMap::new(), + }), + )])), + ) + .await + .expect("create session"); + + wait_for_mcp_server_status(&session, server_name, McpServerStatus::Connected).await; + let tools = session + .rpc() + .mcp() + .list_tools(McpListToolsRequest { + server_name: server_name.to_string(), + }) + .await + .expect("list MCP tools"); + assert!(tools.tools.iter().any(|tool| tool.name == "whoami")); + + let request = handler + .request + .lock() + .clone() + .expect("MCP auth handler should be invoked"); + assert_eq!(request.server_name, server_name); + assert_eq!(request.server_url, format!("{}/mcp", oauth_server.url)); + assert_eq!(request.reason, McpOauthRequestReason::Initial); + let www_authenticate = request + .www_authenticate_params + .expect("WWW-Authenticate params"); + assert_eq!( + www_authenticate.resource_metadata_url, + Some(format!( + "{}/.well-known/oauth-protected-resource", + oauth_server.url + )) + ); + assert_eq!(www_authenticate.scope.as_deref(), Some("mcp.read")); + assert_eq!(www_authenticate.error.as_deref(), Some("invalid_token")); + let metadata: Value = serde_json::from_str( + request + .resource_metadata + .as_deref() + .expect("resource metadata"), + ) + .expect("parse resource metadata"); + assert_eq!(metadata["resource"], format!("{}/mcp", oauth_server.url)); + + let requests = oauth_server.requests().await; + assert!( + requests + .iter() + .any(|request| request.authorization.is_none()) + ); + assert!( + requests.iter().any( + |request| request.authorization.as_deref() == Some("Bearer sdk-host-token") + ) + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + oauth_server.stop().await; + }) + }) + .await; +} + +#[tokio::test] +async fn should_request_replacement_tokens_across_mcp_oauth_lifecycle() { + with_e2e_context_no_snapshot(|ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let mut oauth_server = OAuthMcpServer::start( + ctx.repo_root() + .join("test/harness/test-mcp-oauth-server.mjs"), + ) + .await; + let server_name = "oauth-lifecycle-mcp"; + let handler = Arc::new(LifecycleAuthHandler::default()); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_enable_mcp_apps(true) + .with_mcp_auth_handler(handler.clone()) + .with_mcp_servers(HashMap::from([( + server_name.to_string(), + McpServerConfig::Http(McpHttpServerConfig { + tools: Some(vec!["*".to_string()]), + timeout: None, + url: format!("{}/mcp", oauth_server.url), + headers: HashMap::new(), + }), + )])), + ) + .await + .expect("create session"); + + wait_for_mcp_server_status(&session, server_name, McpServerStatus::Connected).await; + call_whoami(&session, server_name, "refresh").await; + call_whoami(&session, server_name, "upscope").await; + call_whoami(&session, server_name, "reauth").await; + + assert_eq!( + handler.reasons.lock().as_slice(), + [ + McpOauthRequestReason::Initial, + McpOauthRequestReason::Refresh, + McpOauthRequestReason::Upscope, + McpOauthRequestReason::Refresh, + McpOauthRequestReason::Reauth, + ] + ); + + let requests = oauth_server.requests().await; + assert!( + requests + .iter() + .any(|request| request.authorization.as_deref() + == Some("Bearer sdk-host-token-refresh")) + ); + assert!( + requests + .iter() + .any(|request| request.authorization.as_deref() + == Some("Bearer sdk-host-token-upscope")) + ); + assert!( + requests + .iter() + .any(|request| request.authorization.as_deref() + == Some("Bearer sdk-host-token-reauth")) + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + oauth_server.stop().await; + }) + }) + .await; +} + +#[tokio::test] +async fn should_cancel_pending_mcp_oauth_request() { + with_e2e_context_no_snapshot(|ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let mut oauth_server = OAuthMcpServer::start( + ctx.repo_root() + .join("test/harness/test-mcp-oauth-server.mjs"), + ) + .await; + let server_name = "oauth-cancelled-mcp"; + let handler = Arc::new(CancelAuthHandler::default()); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_auth_handler(handler.clone()) + .with_mcp_servers(HashMap::from([( + server_name.to_string(), + McpServerConfig::Http(McpHttpServerConfig { + tools: Some(vec!["*".to_string()]), + timeout: None, + url: format!("{}/mcp", oauth_server.url), + headers: HashMap::new(), + }), + )])), + ) + .await + .expect("create session"); + + wait_for_mcp_server_status(&session, server_name, McpServerStatus::Failed).await; + + let request = handler + .request + .lock() + .clone() + .expect("MCP auth handler should be invoked"); + assert_eq!(request.server_name, server_name); + assert_eq!(request.reason, McpOauthRequestReason::Initial); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + oauth_server.stop().await; + }) + }) + .await; +} + +#[derive(Default)] +struct TokenAuthHandler { + request: Mutex>, +} + +#[async_trait] +impl McpAuthHandler for TokenAuthHandler { + async fn handle( + &self, + _session_id: SessionId, + _request_id: RequestId, + request: McpAuthRequest, + ) -> McpAuthResult { + *self.request.lock() = Some(request); + McpAuthResult::Token { + access_token: EXPECTED_TOKEN.to_string(), + token_type: Some("Bearer".to_string()), + expires_in: Some(3600), + } + } +} + +#[derive(Default)] +struct LifecycleAuthHandler { + reasons: Mutex>, + refresh_count: Mutex, +} + +#[async_trait] +impl McpAuthHandler for LifecycleAuthHandler { + async fn handle( + &self, + _session_id: SessionId, + _request_id: RequestId, + request: McpAuthRequest, + ) -> McpAuthResult { + let reason = request.reason.clone(); + self.reasons.lock().push(reason.clone()); + let token = match reason { + McpOauthRequestReason::Refresh => { + let www_authenticate = request + .www_authenticate_params + .as_ref() + .expect("refresh WWW-Authenticate params"); + assert_eq!(www_authenticate.resource_metadata_url, None); + assert_eq!(www_authenticate.error.as_deref(), Some("invalid_token")); + let mut refresh_count = self.refresh_count.lock(); + *refresh_count += 1; + if *refresh_count > 1 { + return McpAuthResult::Cancelled; + } + REFRESH_TOKEN + } + McpOauthRequestReason::Upscope => { + let www_authenticate = request + .www_authenticate_params + .as_ref() + .expect("upscope WWW-Authenticate params"); + assert!( + www_authenticate + .resource_metadata_url + .as_deref() + .is_some_and(|url| url.ends_with("/.well-known/oauth-protected-resource")) + ); + assert_eq!(www_authenticate.scope.as_deref(), Some("mcp.write")); + assert_eq!( + www_authenticate.error.as_deref(), + Some("insufficient_scope") + ); + UPSCOPE_TOKEN + } + McpOauthRequestReason::Reauth => REAUTH_TOKEN, + _ => EXPECTED_TOKEN, + }; + McpAuthResult::Token { + access_token: token.to_string(), + token_type: None, + expires_in: None, + } + } +} + +#[derive(Default)] +struct CancelAuthHandler { + request: Mutex>, +} + +#[async_trait] +impl McpAuthHandler for CancelAuthHandler { + async fn handle( + &self, + _session_id: SessionId, + _request_id: RequestId, + request: McpAuthRequest, + ) -> McpAuthResult { + *self.request.lock() = Some(request); + McpAuthResult::Cancelled + } +} + +#[derive(Deserialize)] +struct OAuthMcpRequest { + authorization: Option, +} + +struct OAuthMcpServer { + child: Child, + url: String, +} + +impl OAuthMcpServer { + async fn start(script: PathBuf) -> Self { + let mut child = Command::new("node") + .arg(script) + .env("EXPECTED_TOKEN", EXPECTED_TOKEN) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .expect("start OAuth MCP server"); + let stdout = child.stdout.take().expect("OAuth MCP stdout"); + let mut lines = BufReader::new(stdout).lines(); + let line = tokio::time::timeout(std::time::Duration::from_secs(10), lines.next_line()) + .await + .expect("OAuth MCP server startup timeout") + .expect("read OAuth MCP startup line") + .expect("OAuth MCP server stdout closed"); + let url = line + .strip_prefix("Listening: ") + .unwrap_or_else(|| panic!("unexpected OAuth MCP startup line: {line}")) + .to_string(); + Self { child, url } + } + + async fn requests(&self) -> Vec { + let text = reqwest::get(format!("{}/__requests", self.url)) + .await + .expect("fetch OAuth MCP requests") + .error_for_status() + .expect("OAuth MCP request status") + .text() + .await + .expect("read OAuth MCP requests"); + serde_json::from_str(&text).expect("decode OAuth MCP requests") + } + + async fn stop(&mut self) { + let _ = self.child.kill().await; + let _ = self.child.wait().await; + } +} + +async fn wait_for_mcp_server_status( + session: &Session, + server_name: &str, + expected_status: McpServerStatus, +) { + wait_for_condition("MCP server status", || async { + session + .rpc() + .mcp() + .list() + .await + .expect("list MCP servers") + .servers + .iter() + .any(|server| server.name == server_name && server.status == expected_status) + }) + .await; +} + +async fn call_whoami(session: &Session, server_name: &str, scenario: &str) { + let result = session + .rpc() + .mcp() + .apps() + .call_tool(McpAppsCallToolRequest { + arguments: Some(HashMap::from([( + "scenario".to_string(), + serde_json::Value::String(scenario.to_string()), + )])), + origin_server_name: server_name.to_string(), + server_name: server_name.to_string(), + tool_name: "whoami".to_string(), + }) + .await + .expect("call whoami"); + let content = result.get("content").expect("whoami content"); + assert_eq!( + content, + &serde_json::json!([{ "type": "text", "text": "oauth-test-user" }]) + ); +} diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs index 1805eb145b..3c869a08b0 100644 --- a/rust/tests/e2e/support.rs +++ b/rust/tests/e2e/support.rs @@ -310,6 +310,8 @@ impl E2eContext { .as_os_str() .to_owned(), ), + ("COPILOT_MCP_APPS".into(), "true".into()), + ("MCP_APPS".into(), "true".into()), ]); if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true") { env.push(("GH_TOKEN".into(), "fake-token-for-e2e-tests".into())); @@ -598,6 +600,13 @@ fn cli_path(repo_root: &Path) -> std::io::Result { } } + let local_runtime_cli_path = PathBuf::from( + "/Users/roji/.copilot/repos/copilot-worktrees/copilot-agent-runtime/roji-symmetrical-dollop/dist-cli/index.js", + ); + if local_runtime_cli_path.exists() { + return Ok(local_runtime_cli_path); + } + // The `@github/copilot` package is a thin loader; the runnable `index.js` // ships in a platform-specific `@github/copilot--` package, // exactly one of which is installed. Resolve whichever one is present. diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 98c6248230..31b0cc2330 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -9,17 +9,19 @@ use async_trait::async_trait; use github_copilot_sdk::canvas::{CanvasDeclaration, CanvasHandler, CanvasResult}; use github_copilot_sdk::handler::{ ApproveAllHandler, AutoModeSwitchHandler, AutoModeSwitchResponse, ElicitationHandler, - ExitPlanModeHandler, ExitPlanModeResult, UserInputHandler, UserInputResponse, + ExitPlanModeHandler, ExitPlanModeResult, McpAuthHandler, McpAuthRequest, McpAuthResult, + UserInputHandler, UserInputResponse, }; use github_copilot_sdk::rpc::{ CanvasProviderInvokeActionRequest, CanvasProviderOpenRequest, CanvasProviderOpenResult, OpenCanvasInstance, }; -use github_copilot_sdk::session_events::ReasoningSummary; +use github_copilot_sdk::session_events::{McpOauthRequiredData, ReasoningSummary}; use github_copilot_sdk::types::{ - CommandContext, CommandDefinition, CommandHandler, DeliveryMode, ElicitationRequest, - ElicitationResult, ExitPlanModeData, ExtensionInfo, MessageOptions, RequestId, SessionConfig, - SessionId, SetModelOptions, Tool, ToolInvocation, ToolResult, + CloudSessionOptions, CloudSessionRepository, CommandContext, CommandDefinition, CommandHandler, + DeliveryMode, ElicitationRequest, ElicitationResult, ExitPlanModeData, ExtensionInfo, + MessageOptions, RequestId, SessionConfig, SessionId, SetModelOptions, Tool, ToolInvocation, + ToolResult, }; use github_copilot_sdk::{Client, ContextTier, tool}; use serde_json::Value; @@ -30,6 +32,20 @@ const TIMEOUT: Duration = Duration::from_secs(2); struct TestCanvasHandler; +struct CancelMcpAuthHandler; + +#[async_trait] +impl McpAuthHandler for CancelMcpAuthHandler { + async fn handle( + &self, + _session_id: SessionId, + _request_id: RequestId, + _request: McpAuthRequest, + ) -> McpAuthResult { + McpAuthResult::Cancelled + } +} + #[async_trait] impl CanvasHandler for TestCanvasHandler { async fn on_open( @@ -220,12 +236,294 @@ fn rand_id() -> u64 { COUNTER.fetch_add(1, Ordering::Relaxed) as u64 } +#[test] +fn mcp_oauth_required_data_allows_optional_metadata() { + let with_metadata: McpOauthRequiredData = serde_json::from_value(serde_json::json!({ + "requestId": "oauth-request", + "reason": "initial", + "serverName": "oauth-server", + "serverUrl": "https://example.com/mcp", + "wwwAuthenticateParams": { + "resourceMetadataUrl": "https://example.com/.well-known/oauth-protected-resource" + }, + "resourceMetadata": "{\"resource\":\"https://example.com/mcp\"}", + "staticClientConfig": { + "clientId": "static-client", + "clientSecret": "static-secret", + "publicClient": false + } + })) + .unwrap(); + assert_eq!( + with_metadata.resource_metadata.as_deref(), + Some("{\"resource\":\"https://example.com/mcp\"}") + ); + assert!(with_metadata.www_authenticate_params.is_some()); + assert_eq!( + with_metadata + .static_client_config + .as_ref() + .and_then(|config| config.client_secret.as_deref()), + Some("static-secret") + ); + + let without_metadata: McpOauthRequiredData = serde_json::from_value(serde_json::json!({ + "requestId": "oauth-request", + "reason": "initial", + "serverName": "oauth-server", + "serverUrl": "https://example.com/mcp" + })) + .unwrap(); + assert!(without_metadata.resource_metadata.is_none()); + assert!(without_metadata.www_authenticate_params.is_none()); +} + fn requested_session_id(request: &Value) -> &str { request["params"]["sessionId"] .as_str() .expect("session request should include sessionId") } +#[tokio::test] +async fn create_session_registers_mcp_auth_interest_only_with_handler() { + let (client, mut server_read, mut server_write) = make_client(); + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session( + SessionConfig::default().with_permission_handler(Arc::new(ApproveAllHandler)), + ) + .await + .unwrap() + } + }); + + let create_req = read_framed(&mut server_read).await; + assert_eq!(create_req["method"], "session.create"); + assert_eq!(create_req["params"]["requestPermission"], true); + let session_id = requested_session_id(&create_req).to_string(); + server_respond_create(&mut server_write, &create_req, &session_id).await; + let session = timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); + + let no_extra_request = timeout(Duration::from_millis(50), read_framed(&mut server_read)).await; + assert!(no_extra_request.is_err()); + drop(session); + + let (client, mut server_read, mut server_write) = make_client(); + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_mcp_auth_handler(Arc::new(CancelMcpAuthHandler)), + ) + .await + .unwrap() + } + }); + + let create_req = read_framed(&mut server_read).await; + assert_eq!(create_req["method"], "session.create"); + assert_eq!(create_req["params"]["requestPermission"], true); + let session_id = requested_session_id(&create_req).to_string(); + server_respond_create(&mut server_write, &create_req, &session_id).await; + + let interest_req = read_framed(&mut server_read).await; + assert_eq!(interest_req["method"], "session.eventLog.registerInterest"); + assert_eq!(interest_req["params"]["eventType"], "mcp.oauth_required"); + let id = interest_req["id"].as_u64().unwrap(); + write_framed( + &mut server_write, + &serde_json::to_vec(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "id": "interest-1" }, + })) + .unwrap(), + ) + .await; + + let _session = timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn cloud_create_session_registers_mcp_auth_interest_after_create_only_with_handler() { + let cloud = || { + CloudSessionOptions::with_repository( + CloudSessionRepository::new("github", "copilot-sdk").with_branch("main"), + ) + }; + + let (client, mut server_read, mut server_write) = make_client(); + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_cloud(cloud()), + ) + .await + .unwrap() + } + }); + + let create_req = read_framed(&mut server_read).await; + assert_eq!(create_req["method"], "session.create"); + assert!(create_req["params"].get("sessionId").is_none()); + assert_eq!(create_req["params"]["requestPermission"], true); + server_respond_create(&mut server_write, &create_req, "server-assigned-session-1").await; + let session = timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); + let no_extra_request = timeout(Duration::from_millis(50), read_framed(&mut server_read)).await; + assert!(no_extra_request.is_err()); + drop(session); + + let (client, mut server_read, mut server_write) = make_client(); + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_mcp_auth_handler(Arc::new(CancelMcpAuthHandler)) + .with_cloud(cloud()), + ) + .await + .unwrap() + } + }); + + let create_req = read_framed(&mut server_read).await; + assert_eq!(create_req["method"], "session.create"); + assert!(create_req["params"].get("sessionId").is_none()); + assert_eq!(create_req["params"]["requestPermission"], true); + server_respond_create(&mut server_write, &create_req, "server-assigned-session-2").await; + + let interest_req = read_framed(&mut server_read).await; + assert_eq!(interest_req["method"], "session.eventLog.registerInterest"); + assert_eq!( + interest_req["params"]["sessionId"], + "server-assigned-session-2" + ); + assert_eq!(interest_req["params"]["eventType"], "mcp.oauth_required"); + let id = interest_req["id"].as_u64().unwrap(); + write_framed( + &mut server_write, + &serde_json::to_vec(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "id": "interest-1" }, + })) + .unwrap(), + ) + .await; + let _session = timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn resume_session_registers_mcp_auth_interest_only_with_handler() { + use github_copilot_sdk::types::ResumeSessionConfig; + + let (client, mut server_read, mut server_write) = make_client(); + let resume_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .resume_session( + ResumeSessionConfig::new(SessionId::from("session-without-auth")) + .with_permission_handler(Arc::new(ApproveAllHandler)), + ) + .await + .unwrap() + } + }); + + let resume_req = read_framed(&mut server_read).await; + assert_eq!(resume_req["method"], "session.resume"); + assert_eq!(resume_req["params"]["requestPermission"], true); + server_respond_create(&mut server_write, &resume_req, "session-without-auth").await; + respond_to_reload(&mut server_read, &mut server_write).await; + let session = timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); + let no_extra_request = timeout(Duration::from_millis(50), read_framed(&mut server_read)).await; + assert!(no_extra_request.is_err()); + drop(session); + + let (client, mut server_read, mut server_write) = make_client(); + let resume_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .resume_session( + ResumeSessionConfig::new(SessionId::from("session-with-auth")) + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_mcp_auth_handler(Arc::new(CancelMcpAuthHandler)), + ) + .await + .unwrap() + } + }); + + let interest_req = read_framed(&mut server_read).await; + assert_eq!(interest_req["method"], "session.eventLog.registerInterest"); + assert_eq!(interest_req["params"]["eventType"], "mcp.oauth_required"); + let id = interest_req["id"].as_u64().unwrap(); + write_framed( + &mut server_write, + &serde_json::to_vec(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "id": "interest-1" }, + })) + .unwrap(), + ) + .await; + + let resume_req = read_framed(&mut server_read).await; + assert_eq!(resume_req["method"], "session.resume"); + assert_eq!(resume_req["params"]["requestPermission"], true); + server_respond_create(&mut server_write, &resume_req, "session-with-auth").await; + respond_to_reload(&mut server_read, &mut server_write).await; + let _session = timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); +} + +async fn server_respond_create( + writer: &mut (impl AsyncWrite + Unpin), + request: &Value, + session_id: &str, +) { + let id = request["id"].as_u64().unwrap(); + write_framed( + writer, + &serde_json::to_vec(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id, "workspacePath": "/tmp/workspace" }, + })) + .unwrap(), + ) + .await; +} + +async fn respond_to_reload( + reader: &mut (impl tokio::io::AsyncRead + Unpin), + writer: &mut (impl AsyncWrite + Unpin), +) { + let reload = read_framed(reader).await; + assert_eq!(reload["method"], "session.skills.reload"); + let id = reload["id"].as_u64().unwrap(); + write_framed( + writer, + &serde_json::to_vec(&serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": {} })) + .unwrap(), + ) + .await; +} + #[tokio::test] async fn session_subscribe_yields_events_observe_only() { let (session, mut server) = create_session_pair().await; diff --git a/test/harness/test-mcp-oauth-server.mjs b/test/harness/test-mcp-oauth-server.mjs new file mode 100644 index 0000000000..fdb2047ec6 --- /dev/null +++ b/test/harness/test-mcp-oauth-server.mjs @@ -0,0 +1,305 @@ +#!/usr/bin/env node +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Minimal OAuth-protected Streamable HTTP MCP server for SDK E2E tests. + * + * The `/mcp` endpoint returns a WWW-Authenticate challenge until requests include + * an accepted test token, then serves enough JSON-RPC MCP methods for the runtime + * to initialize and list/call one tool. Specific tool-call scenarios trigger + * replacement-token challenges so SDK E2E tests can cover refresh, upscope, and + * reauth flows without relying on a real OAuth server. + */ + +import http from "node:http"; + +const DEFAULT_EXPECTED_TOKEN = "sdk-host-token"; +const PROTOCOL_VERSION = "2025-03-26"; +const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"; + +export async function startOAuthMcpServer({ + expectedToken = DEFAULT_EXPECTED_TOKEN, + host = "127.0.0.1", + port = 0, +} = {}) { + const requests = []; + const tokens = { + initial: expectedToken, + refresh: `${expectedToken}-refresh`, + upscope: `${expectedToken}-upscope`, + reauth: `${expectedToken}-reauth`, + rejected: `${expectedToken}-rejected`, + }; + const acceptedTokens = new Set([ + tokens.initial, + tokens.refresh, + tokens.upscope, + tokens.reauth, + ]); + + const server = http.createServer(async (req, res) => { + const url = new URL( + req.url ?? "/", + `http://${req.headers.host ?? `${host}:${port}`}`, + ); + const baseUrl = `http://${req.headers.host}`; + + if (req.method === "GET" && url.pathname === "/__requests") { + respondJson(res, 200, requests); + return; + } + + if ( + req.method === "GET" && + url.pathname === PROTECTED_RESOURCE_PATH + ) { + respondJson(res, 200, { + resource: `${baseUrl}/mcp`, + authorization_servers: [baseUrl], + scopes_supported: ["mcp.read"], + bearer_methods_supported: ["header"], + }); + return; + } + + if ( + req.method === "GET" && + url.pathname === "/.well-known/oauth-authorization-server" + ) { + respondJson(res, 200, { + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/authorize`, + token_endpoint: `${baseUrl}/token`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + }); + return; + } + + if (url.pathname !== "/mcp") { + respondJson(res, 404, { error: "not_found" }); + return; + } + + const body = await readBody(req); + requests.push({ + method: req.method, + path: url.pathname, + authorization: req.headers.authorization ?? null, + body: body ? JSON.parse(body) : null, + }); + + const token = parseBearerToken(req.headers.authorization); + if (!token || !acceptedTokens.has(token)) { + challengeInitial(res, baseUrl); + return; + } + + if (req.method !== "POST") { + respondJson(res, 405, { error: "method_not_allowed" }); + return; + } + + const message = body ? JSON.parse(body) : undefined; + const replacementChallenge = getReplacementChallenge( + message, + token, + tokens, + baseUrl, + ); + if (replacementChallenge) { + res.writeHead(replacementChallenge.statusCode, { + "www-authenticate": replacementChallenge.wwwAuthenticate, + "content-type": "application/json", + }); + res.end(JSON.stringify({ error: replacementChallenge.error })); + return; + } + + const response = Array.isArray(message) + ? message + .map((item) => handleJsonRpcMessage(item)) + .filter((item) => item !== undefined) + : handleJsonRpcMessage(message); + + if ( + response === undefined || + (Array.isArray(response) && response.length === 0) + ) { + res.writeHead(202, { "mcp-session-id": "oauth-test-session" }); + res.end(); + return; + } + + res.writeHead(200, { + "content-type": "application/json", + "mcp-session-id": "oauth-test-session", + }); + res.end(JSON.stringify(response)); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.off("error", reject); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP server address"); + } + + return { + url: `http://${host}:${address.port}`, + requests, + close: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ), + }; +} + +function getReplacementChallenge(message, token, tokens, baseUrl) { + const messages = Array.isArray(message) ? message : [message]; + const toolCall = messages.find((item) => item?.method === "tools/call"); + const scenario = toolCall?.params?.arguments?.scenario; + + if (scenario === "refresh" && token !== tokens.refresh) { + return { + statusCode: 401, + wwwAuthenticate: 'Bearer error="invalid_token"', + error: "token_expired", + }; + } + + if (scenario === "upscope" && token !== tokens.upscope) { + return { + statusCode: 403, + wwwAuthenticate: `Bearer resource_metadata="${baseUrl}${PROTECTED_RESOURCE_PATH}", scope="mcp.write", error="insufficient_scope"`, + error: "insufficient_scope", + }; + } + + if (scenario === "reauth" && token !== tokens.reauth) { + return { + statusCode: 401, + wwwAuthenticate: 'Bearer error="invalid_token"', + error: "reauth_required", + }; + } + + if (scenario === "cancel" && token !== tokens.refresh) { + return { + statusCode: 401, + wwwAuthenticate: 'Bearer error="invalid_token"', + error: "token_expired", + }; + } + + return undefined; +} + +function handleJsonRpcMessage(message) { + if (!message || typeof message !== "object" || !("id" in message)) { + return undefined; + } + + switch (message.method) { + case "initialize": + return { + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion ?? PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: "oauth-test-server", version: "1.0.0" }, + }, + }; + case "tools/list": + return { + jsonrpc: "2.0", + id: message.id, + result: { + tools: [ + { + name: "whoami", + description: "Returns the authenticated test principal.", + inputSchema: { + type: "object", + properties: { + scenario: { + type: "string", + enum: ["initial", "refresh", "upscope", "reauth", "cancel"], + }, + }, + additionalProperties: false, + }, + _meta: { "ui.visibility": ["model", "app"] }, + }, + ], + }, + }; + case "tools/call": + return { + jsonrpc: "2.0", + id: message.id, + result: { + content: [{ type: "text", text: "oauth-test-user" }], + isError: false, + }, + }; + default: + return { + jsonrpc: "2.0", + id: message.id, + error: { code: -32601, message: `Method not found: ${message.method}` }, + }; + } +} + +function parseBearerToken(authorization) { + const match = /^Bearer (.+)$/.exec(authorization ?? ""); + return match?.[1]; +} + +function challengeInitial(res, baseUrl) { + const resourceMetadataUrl = `${baseUrl}${PROTECTED_RESOURCE_PATH}`; + res.writeHead(401, { + "www-authenticate": `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp.read", error="invalid_token"`, + "content-type": "application/json", + }); + res.end(JSON.stringify({ error: "missing_or_invalid_token" })); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("error", reject); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); +} + +function respondJson(res, statusCode, body) { + const data = JSON.stringify(body); + res.writeHead(statusCode, { + "content-type": "application/json", + "content-length": Buffer.byteLength(data), + }); + res.end(data); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const server = await startOAuthMcpServer({ + expectedToken: process.env.EXPECTED_TOKEN ?? DEFAULT_EXPECTED_TOKEN, + }); + console.log(`Listening: ${server.url}`); + process.on("SIGTERM", async () => { + await server.close(); + process.exit(0); + }); +}