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
+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>
);
}