feat: add MCP OAuth authentication frontend wiring and banner

- Add McpOauthRequiredEvent and McpOauthStaticClientConfigEvent to sidecar contracts
- Add PendingMcpAuthRecord domain type with status tracking (pending/authenticating/failed/done)
- Add pendingMcpAuth field to SessionRecord for session-level auth state
- Wire onMcpOAuthRequired callback through sidecarProcess, runTurnPending, and AryxAppService
- Handle mcp-oauth-required events: set session pendingMcpAuth, clear on turn finalize/cancel
- Add dismissSessionMcpAuth IPC channel with preload binding and handler registration
- Create McpAuthBanner component (amber-themed, shows server name/URL, dismiss button)
- Integrate McpAuthBanner into ChatPane with placeholder text and App.tsx callback
- Update test fixtures for new RunTurnPendingCommand.onMcpOAuthRequired field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-27 19:09:11 +01:00
co-authored by Copilot
parent 192c28f721
commit f9757d5ce2
14 changed files with 196 additions and 2 deletions
+42
View File
@@ -8,6 +8,7 @@ import type {
AgentActivityEvent,
ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
McpOauthRequiredEvent,
RunTurnToolingConfig,
SidecarCapabilities,
TurnDeltaEvent,
@@ -586,6 +587,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
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<AppServiceEvents> {
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<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async dismissSessionMcpAuth(sessionId: string): Promise<WorkspaceState> {
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<AppServiceEvents> {
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<AppServiceEvents> {
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<AppServiceEvents> {
await this.persistAndBroadcast(workspace);
}
private async handleMcpOAuthRequired(
workspace: WorkspaceState,
sessionId: string,
event: McpOauthRequiredEvent,
): Promise<void> {
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,
+4
View File
@@ -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) =>
+2
View File
@@ -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<void>;
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>;
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>;
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>;
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>;
errored: boolean;
}
+10 -1
View File
@@ -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<void>,
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>,
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
): Promise<ChatMessageRecord[]> {
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode);
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode);
}
async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> {
@@ -220,6 +222,7 @@ export class SidecarClient {
onActivity?: (event: AgentActivityEvent) => void | Promise<void>,
onApproval?: (event: ApprovalRequestedEvent) => void | Promise<void>,
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise<void>,
onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
): Promise<TResult> {
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));
+1
View File
@@ -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),
+3
View File
@@ -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,
+24 -1
View File
@@ -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<unknown>;
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({
</div>
)}
{/* MCP auth required banner */}
{pendingMcpAuth && (
<div className="mb-3">
<McpAuthBanner
mcpAuth={pendingMcpAuth}
onDismiss={handleDismissMcpAuth}
/>
</div>
)}
{/* Session config pills — tools/approval left, model/reasoning right */}
{isSingleAgent && (
<div className="mb-2 flex items-center gap-2">
@@ -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...'
@@ -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 (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3" role="alert">
<div className="flex items-start gap-2.5">
<KeyRound className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-amber-200">Authentication required</span>
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400">
MCP
</span>
</div>
<button
aria-label="Dismiss authentication prompt"
className="rounded p-0.5 text-zinc-500 transition hover:bg-zinc-700/50 hover:text-zinc-300"
onClick={handleDismiss}
type="button"
>
<X className="size-3.5" />
</button>
</div>
<p className="mt-2 text-[13px] leading-relaxed text-zinc-200">
The MCP server{' '}
<span className="font-medium text-amber-200">{mcpAuth.serverName}</span>{' '}
requires OAuth authentication to connect.
</p>
<p className="mt-1 text-[11px] text-zinc-500">{mcpAuth.serverUrl}</p>
{hasFailed && mcpAuth.errorMessage && (
<p className="mt-2 text-[12px] text-red-400">{mcpAuth.errorMessage}</p>
)}
<p className="mt-3 text-[12px] leading-relaxed text-zinc-400">
{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.'}
</p>
</div>
</div>
</div>
);
}
+1
View File
@@ -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',
+5
View File
@@ -116,6 +116,10 @@ export interface DismissSessionPlanReviewInput {
sessionId: string;
}
export interface DismissSessionMcpAuthInput {
sessionId: string;
}
export interface ElectronApi {
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
@@ -145,6 +149,7 @@ export interface ElectronApi {
resolveSessionUserInput(input: ResolveSessionUserInputInput): Promise<WorkspaceState>;
setSessionInteractionMode(input: SetSessionInteractionModeInput): Promise<WorkspaceState>;
dismissSessionPlanReview(input: DismissSessionPlanReviewInput): Promise<WorkspaceState>;
dismissSessionMcpAuth(input: DismissSessionMcpAuthInput): Promise<WorkspaceState>;
updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise<WorkspaceState>;
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
selectProject(projectId?: string): Promise<WorkspaceState>;
+18
View File
@@ -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;
+19
View File
@@ -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;
}
+2
View File
@@ -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[];
}
+2
View File
@@ -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,
};