diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index a09903d..8aa67b6 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -8,6 +8,7 @@ import type { AgentActivityEvent, ApprovalRequestedEvent, ExitPlanModeRequestedEvent, + McpOauthRequiredEvent, RunTurnToolingConfig, SidecarCapabilities, TurnDeltaEvent, @@ -586,6 +587,7 @@ export class AryxAppService extends EventEmitter { session.status = 'running'; session.lastError = undefined; session.pendingPlanReview = undefined; + session.pendingMcpAuth = undefined; session.updatedAt = occurredAt; session.runs = [ createSessionRunRecord({ @@ -634,6 +636,9 @@ export class AryxAppService extends EventEmitter { await this.handleUserInputRequested(workspace, session.id, requestId, event, (answer, wasFreeform) => this.sidecar.resolveUserInput(event.userInputId, answer, wasFreeform)); }, + async (event) => { + await this.handleMcpOAuthRequired(workspace, session.id, event); + }, async (event) => { await this.handleExitPlanModeRequested(workspace, session.id, event); }, @@ -874,6 +879,16 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async dismissSessionMcpAuth(sessionId: string): Promise { + const workspace = await this.loadWorkspace(); + const session = this.requireSession(workspace, sessionId); + + session.pendingMcpAuth = undefined; + session.updatedAt = nowIso(); + + return this.persistAndBroadcast(workspace); + } + async updateSessionTooling( sessionId: string, enabledMcpServerIds: string[], @@ -1210,6 +1225,7 @@ export class AryxAppService extends EventEmitter { session.lastError = undefined; session.pendingUserInput = undefined; session.pendingPlanReview = undefined; + session.pendingMcpAuth = undefined; session.updatedAt = completedAt; const completedRun = this.updateSessionRun(session, requestId, (run) => completeSessionRunRecord(run, completedAt)); @@ -1242,6 +1258,7 @@ export class AryxAppService extends EventEmitter { session.lastError = undefined; session.pendingUserInput = undefined; session.pendingPlanReview = undefined; + session.pendingMcpAuth = undefined; session.updatedAt = cancelledAt; const cancelledRun = this.updateSessionRun(session, requestId, (run) => cancelSessionRunRecord(run, cancelledAt)); @@ -1340,6 +1357,31 @@ export class AryxAppService extends EventEmitter { await this.persistAndBroadcast(workspace); } + private async handleMcpOAuthRequired( + workspace: WorkspaceState, + sessionId: string, + event: McpOauthRequiredEvent, + ): Promise { + const session = this.requireSession(workspace, sessionId); + const requestedAt = nowIso(); + + session.pendingMcpAuth = { + id: event.oauthRequestId, + status: 'pending', + agentId: event.agentId, + agentName: event.agentName, + serverName: event.serverName, + serverUrl: event.serverUrl, + staticClientConfig: event.staticClientConfig + ? { clientId: event.staticClientConfig.clientId, publicClient: event.staticClientConfig.publicClient } + : undefined, + requestedAt, + }; + session.updatedAt = requestedAt; + + await this.persistAndBroadcast(workspace); + } + private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord { return { id: event.approvalId, diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index a386925..7b33d40 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -8,6 +8,7 @@ import type { ResolveProjectDiscoveredToolingInput, ResolveWorkspaceDiscoveredToolingInput, DismissSessionPlanReviewInput, + DismissSessionMcpAuthInput, DuplicateSessionInput, RenameSessionInput, RescanProjectConfigsInput, @@ -122,6 +123,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi ipcMain.handle(ipcChannels.dismissSessionPlanReview, (_event, input: DismissSessionPlanReviewInput) => service.dismissSessionPlanReview(input.sessionId), ); + ipcMain.handle(ipcChannels.dismissSessionMcpAuth, (_event, input: DismissSessionMcpAuthInput) => + service.dismissSessionMcpAuth(input.sessionId), + ); ipcMain.handle( ipcChannels.updateSessionModelConfig, (_event, input: UpdateSessionModelConfigInput) => diff --git a/src/main/sidecar/runTurnPending.ts b/src/main/sidecar/runTurnPending.ts index 8c9d68f..5caae73 100644 --- a/src/main/sidecar/runTurnPending.ts +++ b/src/main/sidecar/runTurnPending.ts @@ -2,6 +2,7 @@ import type { AgentActivityEvent, ApprovalRequestedEvent, ExitPlanModeRequestedEvent, + McpOauthRequiredEvent, TurnDeltaEvent, UserInputRequestedEvent, } from '@shared/contracts/sidecar'; @@ -15,6 +16,7 @@ export interface RunTurnPendingCommand { onActivity: (event: AgentActivityEvent) => void | Promise; onApproval: (event: ApprovalRequestedEvent) => void | Promise; onUserInput: (event: UserInputRequestedEvent) => void | Promise; + onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise; onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise; errored: boolean; } diff --git a/src/main/sidecar/sidecarProcess.ts b/src/main/sidecar/sidecarProcess.ts index 52dd10d..9257c9a 100644 --- a/src/main/sidecar/sidecarProcess.ts +++ b/src/main/sidecar/sidecarProcess.ts @@ -10,6 +10,7 @@ import type { SidecarEvent, TurnDeltaEvent, UserInputRequestedEvent, + McpOauthRequiredEvent, ExitPlanModeRequestedEvent, ValidatePatternCommand, RunTurnCommand, @@ -103,9 +104,10 @@ export class SidecarClient { onActivity: (event: AgentActivityEvent) => void | Promise, onApproval: (event: ApprovalRequestedEvent) => void | Promise, onUserInput: (event: UserInputRequestedEvent) => void | Promise, + onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise, onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise, ): Promise { - return this.dispatch(command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode); + return this.dispatch(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode); } async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise { @@ -220,6 +222,7 @@ export class SidecarClient { onActivity?: (event: AgentActivityEvent) => void | Promise, onApproval?: (event: ApprovalRequestedEvent) => void | Promise, onUserInput?: (event: UserInputRequestedEvent) => void | Promise, + onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise, onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise, ): Promise { const state = await this.ensureProcess(); @@ -235,6 +238,7 @@ export class SidecarClient { onActivity: onActivity ?? (() => undefined), onApproval: onApproval ?? (() => undefined), onUserInput: onUserInput ?? (() => undefined), + onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined), onExitPlanMode: onExitPlanMode ?? (() => undefined), errored: false, }); @@ -333,6 +337,11 @@ export class SidecarClient { this.invokeRunTurnHandler(event.requestId, pending, () => pending.onUserInput(event)); } return; + case 'mcp-oauth-required': + if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) { + this.invokeRunTurnHandler(event.requestId, pending, () => pending.onMcpOAuthRequired(event)); + } + return; case 'exit-plan-mode-requested': if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) { this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event)); diff --git a/src/preload/index.ts b/src/preload/index.ts index 219f77c..9b872b9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -39,6 +39,7 @@ const api: ElectronApi = { resolveSessionUserInput: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionUserInput, input), setSessionInteractionMode: (input) => ipcRenderer.invoke(ipcChannels.setSessionInteractionMode, input), dismissSessionPlanReview: (input) => ipcRenderer.invoke(ipcChannels.dismissSessionPlanReview, input), + dismissSessionMcpAuth: (input) => ipcRenderer.invoke(ipcChannels.dismissSessionMcpAuth, input), updateSessionModelConfig: (input) => ipcRenderer.invoke(ipcChannels.updateSessionModelConfig, input), querySessions: (input) => ipcRenderer.invoke(ipcChannels.querySessions, input), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fda2253..41048b6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -259,6 +259,9 @@ export default function App() { onDismissPlanReview={() => { void api.dismissSessionPlanReview({ sessionId: selectedSession.id }); }} + onDismissMcpAuth={() => { + void api.dismissSessionMcpAuth({ sessionId: selectedSession.id }); + }} onUpdateSessionModelConfig={(config) => api.updateSessionModelConfig({ sessionId: selectedSession.id, diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index e5fd14d..1977425 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -5,6 +5,7 @@ import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer'; import { ApprovalBanner, QueuedApprovalsList } from '@renderer/components/chat/ApprovalBanner'; import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner'; +import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner'; import { UserInputBanner } from '@renderer/components/chat/UserInputBanner'; import { InlineApprovalPill, InlineModelPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills'; import { ThinkingDots } from '@renderer/components/chat/ThinkingDots'; @@ -42,6 +43,7 @@ interface ChatPaneProps { onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise; onSetInteractionMode?: (mode: InteractionMode) => void; onDismissPlanReview?: () => void; + onDismissMcpAuth?: () => void; onUpdateSessionModelConfig?: (config: { model: string; reasoningEffort?: ReasoningEffort; @@ -63,6 +65,7 @@ export function ChatPane({ onResolveUserInput, onSetInteractionMode, onDismissPlanReview, + onDismissMcpAuth, onUpdateSessionModelConfig, onUpdateSessionTooling, onUpdateSessionApprovalSettings, @@ -82,6 +85,10 @@ export function ChatPane({ const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length; const pendingUserInput = session.pendingUserInput?.status === 'pending' ? session.pendingUserInput : undefined; const pendingPlanReview = session.pendingPlanReview?.status === 'pending' ? session.pendingPlanReview : undefined; + const pendingMcpAuth = session.pendingMcpAuth?.status === 'pending' || session.pendingMcpAuth?.status === 'authenticating' + || session.pendingMcpAuth?.status === 'failed' + ? session.pendingMcpAuth + : undefined; const interactionMode: InteractionMode = session.interactionMode ?? 'interactive'; const isPlanMode = interactionMode === 'plan'; const isScratchpad = isScratchpadProject(project); @@ -134,6 +141,10 @@ export function ChatPane({ onDismissPlanReview?.(); } + function handleDismissMcpAuth() { + onDismissMcpAuth?.(); + } + async function handleSessionModelConfigChange(config: { model: string; reasoningEffort?: ReasoningEffort; @@ -398,6 +409,16 @@ export function ChatPane({ )} + {/* MCP auth required banner */} + {pendingMcpAuth && ( +
+ +
+ )} + {/* Session config pills — tools/approval left, model/reasoning right */} {isSingleAgent && (
@@ -489,7 +510,9 @@ export function ChatPane({ ? 'Awaiting your input above...' : pendingPlanReview ? 'Review the plan above...' - : isSessionBusy + : pendingMcpAuth + ? 'MCP server requires authentication...' + : isSessionBusy ? 'Waiting for response...' : isUpdatingSessionModelConfig ? 'Saving model settings...' diff --git a/src/renderer/components/chat/McpAuthBanner.tsx b/src/renderer/components/chat/McpAuthBanner.tsx new file mode 100644 index 0000000..a4b0694 --- /dev/null +++ b/src/renderer/components/chat/McpAuthBanner.tsx @@ -0,0 +1,63 @@ +import { useCallback } from 'react'; +import { KeyRound, X } from 'lucide-react'; + +import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth'; + +export function McpAuthBanner({ + mcpAuth, + onDismiss, +}: { + mcpAuth: PendingMcpAuthRecord; + onDismiss: () => void; +}) { + const handleDismiss = useCallback(() => { + onDismiss(); + }, [onDismiss]); + + const isAuthenticating = mcpAuth.status === 'authenticating'; + const hasFailed = mcpAuth.status === 'failed'; + + return ( +
+
+ +
+
+
+ Authentication required + + MCP + +
+ +
+ +

+ The MCP server{' '} + {mcpAuth.serverName}{' '} + requires OAuth authentication to connect. +

+ +

{mcpAuth.serverUrl}

+ + {hasFailed && mcpAuth.errorMessage && ( +

{mcpAuth.errorMessage}

+ )} + +

+ {isAuthenticating + ? 'Waiting for authentication to complete in the browser…' + : 'Authentication support for HTTP MCP servers is not yet available. Configure a static access token in the MCP server headers instead.'} +

+
+
+
+ ); +} diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 02b76ae..3e0d076 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -29,6 +29,7 @@ export const ipcChannels = { resolveSessionUserInput: 'sessions:resolve-user-input', setSessionInteractionMode: 'sessions:set-interaction-mode', dismissSessionPlanReview: 'sessions:dismiss-plan-review', + dismissSessionMcpAuth: 'sessions:dismiss-mcp-auth', querySessions: 'sessions:query', updateSessionModelConfig: 'sessions:update-model-config', selectProject: 'selection:project', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 467114f..028b086 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -116,6 +116,10 @@ export interface DismissSessionPlanReviewInput { sessionId: string; } +export interface DismissSessionMcpAuthInput { + sessionId: string; +} + export interface ElectronApi { describeSidecarCapabilities(): Promise; refreshSidecarCapabilities(): Promise; @@ -145,6 +149,7 @@ export interface ElectronApi { resolveSessionUserInput(input: ResolveSessionUserInputInput): Promise; setSessionInteractionMode(input: SetSessionInteractionModeInput): Promise; dismissSessionPlanReview(input: DismissSessionPlanReviewInput): Promise; + dismissSessionMcpAuth(input: DismissSessionMcpAuthInput): Promise; updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise; querySessions(input: QuerySessionsInput): Promise; selectProject(projectId?: string): Promise; diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index ce9c645..b64cde7 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -244,6 +244,23 @@ export interface UserInputRequestedEvent { allowFreeform?: boolean; } +export interface McpOauthStaticClientConfigEvent { + clientId: string; + publicClient?: boolean; +} + +export interface McpOauthRequiredEvent { + type: 'mcp-oauth-required'; + requestId: string; + sessionId: string; + oauthRequestId: string; + agentId?: string; + agentName?: string; + serverName: string; + serverUrl: string; + staticClientConfig?: McpOauthStaticClientConfigEvent; +} + export interface ExitPlanModeRequestedEvent { type: 'exit-plan-mode-requested'; requestId: string; @@ -276,6 +293,7 @@ export type SidecarEvent = | AgentActivityEvent | ApprovalRequestedEvent | UserInputRequestedEvent + | McpOauthRequiredEvent | ExitPlanModeRequestedEvent | CommandErrorEvent | CommandCompleteEvent; diff --git a/src/shared/domain/mcpAuth.ts b/src/shared/domain/mcpAuth.ts new file mode 100644 index 0000000..cf0d3af --- /dev/null +++ b/src/shared/domain/mcpAuth.ts @@ -0,0 +1,19 @@ +export type McpAuthStatus = 'pending' | 'authenticating' | 'authenticated' | 'failed'; + +export interface McpOauthStaticClientConfig { + clientId: string; + publicClient?: boolean; +} + +export interface PendingMcpAuthRecord { + id: string; + status: McpAuthStatus; + agentId?: string; + agentName?: string; + serverName: string; + serverUrl: string; + staticClientConfig?: McpOauthStaticClientConfig; + requestedAt: string; + completedAt?: string; + errorMessage?: string; +} diff --git a/src/shared/domain/session.ts b/src/shared/domain/session.ts index 7741f40..75a7edf 100644 --- a/src/shared/domain/session.ts +++ b/src/shared/domain/session.ts @@ -13,6 +13,7 @@ import { import type { SessionRunRecord } from '@shared/domain/runTimeline'; import type { PendingUserInputRecord } from '@shared/domain/userInput'; import type { PendingPlanReviewRecord } from '@shared/domain/planReview'; +import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth'; import type { InteractionMode } from '@shared/contracts/sidecar'; export type ChatRole = 'system' | 'user' | 'assistant'; @@ -54,6 +55,7 @@ export interface SessionRecord { pendingApprovalQueue?: PendingApprovalRecord[]; pendingUserInput?: PendingUserInputRecord; pendingPlanReview?: PendingPlanReviewRecord; + pendingMcpAuth?: PendingMcpAuthRecord; runs: SessionRunRecord[]; } diff --git a/tests/main/runTurnPending.test.ts b/tests/main/runTurnPending.test.ts index 93a4f3a..07c7a30 100644 --- a/tests/main/runTurnPending.test.ts +++ b/tests/main/runTurnPending.test.ts @@ -18,6 +18,7 @@ describe('run turn pending helpers', () => { onApproval: () => undefined, onUserInput: () => undefined, onExitPlanMode: () => undefined, + onMcpOAuthRequired: () => undefined, errored: false, }; @@ -42,6 +43,7 @@ describe('run turn pending helpers', () => { onApproval: () => undefined, onUserInput: () => undefined, onExitPlanMode: () => undefined, + onMcpOAuthRequired: () => undefined, errored: false, };