From 6cd4793081976efacff878febd59d1660ae331e8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 12 Aug 2026 23:19:43 -0500 Subject: [PATCH] feat(desktop): render setup_mcp as an interactive consent card in the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card follows the approval bar's consent vocabulary (primary-tinted action + ghost decline, ⌘⏎/Esc with clarify's focus-stand-down rule) on clarify's widget shell. Install prefers the reviewed catalog entry (env prompts inline, background installs polled to completion) and falls back to the desktop suggestion directory via the validated add-server POST + OAuth; success reloads live MCP tools before unblocking the agent so it resumes with the tools it was just promised. Esc stays live mid-flight as cancel — the abandoned flow aborts at its next poll and a post-write cancel rolls the config entry back. Typing while the card is pending declines it and sends normally (skipClarifyRequest's pattern), and the request/tool.start rows merge on the server arg so reconnects can't double-render the card. --- .../composer/hooks/use-composer-submit.ts | 7 + .../hooks/use-message-stream/gateway-event.ts | 28 + .../assistant-ui/mcp-setup-tool.tsx | 519 ++++++++++++++++++ .../assistant-ui/thread/message-parts.tsx | 5 + apps/desktop/src/hermes.ts | 28 + apps/desktop/src/lib/chat-messages.ts | 9 +- apps/desktop/src/lib/gateway-events.ts | 1 + apps/desktop/src/lib/tool-render-class.ts | 4 +- apps/desktop/src/store/mcp-setup.ts | 118 ++++ 9 files changed, 716 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx create mode 100644 apps/desktop/src/store/mcp-setup.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index 47f9616a08cd3..a6015fc930399 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -6,6 +6,7 @@ import { hasClarifyRequest, skipClarifyRequest } from '@/store/clarify' import { clearSessionDraft, type ComposerAttachment } from '@/store/composer' import { resetBrowseState } from '@/store/composer-input-history' import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue' +import { hasMcpSetupRequest, skipMcpSetupRequest } from '@/store/mcp-setup' import { hasBlockingPromptRequest } from '@/store/prompts' import { cloneAttachments, type QueueEditState } from '../composer-utils' @@ -162,6 +163,12 @@ export function useComposerSubmit({ void skipClarifyRequest(sessionId) } + // Same deal for a pending MCP setup card: the agent is blocked on + // mcp.setup.respond, so a typed message declines the card and rides on. + if (payloadPresent && !queueEdit && hasMcpSetupRequest(sessionId)) { + void skipMcpSetupRequest(sessionId) + } + // Approval / sudo / secret prompts also park the turn inside a tool batch, // but typing CANNOT answer them (no message text approves a command or // supplies a password), so there is no skip-and-steer path: a steer would diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index a5875b81c455d..c3d6bb9c86279 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -34,6 +34,7 @@ import { type PetChangeMeta, setChangeEventsAvailable } from '@/store/live-sync' +import { setMcpSetupRequest } from '@/store/mcp-setup' import { dispatchNativeNotification } from '@/store/native-notifications' import { isDiskFullErrorMessage, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding, requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding' @@ -966,6 +967,33 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { title: translateNow('notifications.native.inputTitle') }) } + } else if (event.type === 'mcp.setup.request') { + // setup_mcp tool (desktop GUI): the agent proposed an MCP server and + // the Python side is blocked on mcp.setup.respond. Park the request + // per-session (like clarify) and upsert a stable pending tool row so + // the inline consent card has somewhere to render even when the + // tool.start event was missed (stream reconnect / hydration race). + const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' + const server = typeof payload?.server === 'string' ? payload.server : '' + const rawAction = typeof payload?.action === 'string' ? payload.action : 'install' + const action = rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install' + const reason = typeof payload?.reason === 'string' ? payload.reason : '' + + if (requestId && server) { + setMcpSetupRequest({ action, reason, requestId, server, sessionId: sessionId ?? null }) + + if (sessionId) { + upsertToolCall(sessionId, { args: { action, reason, server }, name: 'setup_mcp', tool_id: requestId }, 'running') + updateSessionState(sessionId, state => ({ ...state, needsInput: true })) + } + + dispatchNativeNotification({ + body: reason || server, + kind: 'input', + sessionId, + title: translateNow('notifications.native.inputTitle') + }) + } } else if (event.type === 'approval.request') { // Dangerous-command / execute_code approval. The Python side is blocked // in _await_gateway_decision() until approval.respond lands; without diff --git a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx new file mode 100644 index 0000000000000..3af50d025892f --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx @@ -0,0 +1,519 @@ +'use client' + +import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' +import { useStore } from '@nanostores/react' +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { useSessionView } from '@/app/chat/session-view' +import { ToolFallback } from '@/components/assistant-ui/tool/fallback' +import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Input } from '@/components/ui/input' +import { + addMcpServer, + authMcpServer, + getActionStatus, + getMcpCatalog, + getMcpOAuthFlow, + installMcpCatalogEntry, + type McpCatalogEntry, + removeMcpServer, + setMcpServerEnabled +} from '@/hermes' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { AlertCircle, CheckCircle2, Loader2 } from '@/lib/icons' +import { brandFor, brandGlyphStyle } from '@/lib/mcp-brands' +import { completeMcpDesktopOAuth } from '@/lib/mcp-dashboard-oauth' +import { directoryEntry } from '@/lib/mcp-directory' +import { prettyName } from '@/lib/text' +import { cn } from '@/lib/utils' +import { $gateway } from '@/store/gateway' +import { clearMcpSetupRequest, type McpSetupOutcome, sessionMcpSetupRequest } from '@/store/mcp-setup' +import { invalidateMcpSuggestionIndex } from '@/store/mcp-suggestions' +import { notifyError } from '@/store/notifications' + +import { selectMessageRunning } from './tool/fallback-model' +import { parseMaybeObject } from './tool/fallback-model/format' + +type SetupAction = 'authorize' | 'enable' | 'install' + +interface SetupArgs { + server: string + action: SetupAction + reason: string +} + +const CATALOG_INSTALL_POLL_MS = 1500 + +// Thrown by the in-flight flow when the user cancels — the declined respond +// has already been sent, so the catch path must swallow this, not report it. +const CANCELLED = Symbol('mcp-setup-cancelled') + +function readSetupArgs(args: unknown): SetupArgs { + const row = parseMaybeObject(args) + const rawAction = typeof row.action === 'string' ? row.action : 'install' + + return { + action: rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install', + reason: typeof row.reason === 'string' ? row.reason : '', + server: typeof row.server === 'string' ? row.server : '' + } +} + +/** The tool's settled JSON — the card's outcome plus the tool-only + * `unanswered` status (timeout, no user action). */ +type SettledResult = Omit, 'status'> & { + status?: McpSetupOutcome['status'] | 'unanswered' + note?: string +} + +function readSetupResult(result: unknown): SettledResult { + return parseMaybeObject(result) as SettledResult +} + +const SHELL_CLASS = `${WIDGET_SHELL_CLASS} text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)` + +// Same platform sniff the approval bar uses for its accelerator hint. +const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform) + +const ICON_CLASS = 'mt-px size-4 shrink-0 text-(--ui-text-tertiary)' + +function SetupLine({ children, trailing }: { children: ReactNode; trailing?: ReactNode }) { + return ( +
+
{children}
+ {trailing} +
+ ) +} + +export const McpSetupTool = (props: ToolCallMessagePartProps) => { + // Settled → static outcome line (the flow already ran or was declined). + if (props.result !== undefined) { + return + } + + return +} + +const McpSetupLive = (props: ToolCallMessagePartProps) => { + const messageRunning = useAuiState(selectMessageRunning) + + // Stopped mid-prompt with no result — don't leave a dead interactive panel. + if (!messageRunning) { + return + } + + return +} + +function McpSetupSettled({ args, result }: ToolCallMessagePartProps) { + const { t } = useI18n() + const copy = t.assistant.mcpSetup + const fromArgs = useMemo(() => readSetupArgs(args), [args]) + const fromResult = useMemo(() => readSetupResult(result), [result]) + + const server = fromResult.server || fromArgs.server + const status = fromResult.status ?? 'error' + const displayName = prettyName(server) + + const line = + status === 'installed' + ? copy.installed(displayName) + : status === 'enabled' + ? copy.enabled(displayName) + : status === 'authorized' + ? copy.authorized(displayName) + : status === 'declined' + ? copy.declined + : status === 'unanswered' + ? copy.unanswered + : copy.failed(displayName) + + const ok = status === 'installed' || status === 'enabled' || status === 'authorized' + const neutral = status === 'declined' || status === 'unanswered' + const toolCount = Array.isArray(fromResult.tools) ? fromResult.tools.length : 0 + const brand = brandFor(server) + + return ( +
+ + ) : neutral && brand ? ( + + ) : neutral ? ( + + ) : ( + + ) + } + > + {line} + {ok && toolCount > 0 && ( + {copy.toolCount(toolCount)} + )} + {!ok && !neutral && fromResult.detail ? ( +

{fromResult.detail}

+ ) : null} +
+
+ ) +} + +function McpSetupPending({ args }: ToolCallMessagePartProps) { + const { t } = useI18n() + const copy = t.assistant.mcpSetup + // The tool row is in whichever session's transcript rendered it — read THAT + // session's request (primary or tile), not the globally-active one. + const sessionId = useStore(useSessionView().$runtimeId) + const $request = useMemo(() => sessionMcpSetupRequest(sessionId), [sessionId]) + const request = useStore($request) + const gateway = useStore($gateway) + const fromArgs = useMemo(() => readSetupArgs(args), [args]) + + const server = fromArgs.server || request?.server || '' + const action: SetupAction = fromArgs.action ?? request?.action ?? 'install' + const reason = fromArgs.reason || request?.reason || '' + + const [working, setWorking] = useState(false) + const [envDraft, setEnvDraft] = useState>({}) + const [entry, setEntry] = useState(undefined) + const [envOpen, setEnvOpen] = useState(false) + // Set when the user cancels mid-flight (a stuck OAuth tab, a hung install). + // The in-flight flow checks it at every poll boundary and aborts via the + // CANCELLED sentinel; the declined respond has already been sent by then. + const cancelRef = useRef(false) + + // Race: tool.start fires a tick before mcp.setup.request — hold the buttons + // until the gateway request is wired (same spinner rule as clarify). + const ready = Boolean(request?.requestId) + + const respond = useCallback( + async (outcome: McpSetupOutcome) => { + // Another path (cancel racing completion) may have already resolved this + // request; the store is the single source of truth, so bail if this + // session's entry is gone — same guard as the approval bar. + if (!request || sessionMcpSetupRequest(request.sessionId).get()?.requestId !== request.requestId) { + return + } + + if (!gateway) { + notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed) + + return + } + + // Clear first: the answer is decided, and an in-flight RPC must not + // leave a live card that can be answered a second time. + clearMcpSetupRequest(request.requestId, request.sessionId) + + // A successful outcome changed mcp_servers — reload the live session + // BEFORE unblocking the tool, or the agent resumes being told the + // server is ready while its tool snapshot still lacks it (the same + // write-through mcp-tab's silentReload does; consent was the card + // click, so no confirm prompt). Reload failure isn't outcome failure: + // the config landed, tools arrive next session — report it and move on. + if (outcome.status === 'installed' || outcome.status === 'enabled' || outcome.status === 'authorized') { + try { + await gateway.request('reload.mcp', { confirm: true, session_id: request.sessionId ?? undefined }) + } catch (error) { + notifyError(error, copy.reloadFailed) + } + + // The just-set-up server must stop being suggested immediately. + invalidateMcpSuggestionIndex() + } + + try { + await gateway.request<{ status?: string }>('mcp.setup.respond', { + request_id: request.requestId, + result: JSON.stringify(outcome) + }) + // tool.complete lands next → McpSetupSettled. + } catch (error) { + notifyError(error, copy.sendFailed) + } + }, + [copy.gatewayDisconnected, copy.reloadFailed, copy.sendFailed, gateway, request] + ) + + const decline = useCallback(() => { + // While a flow is in flight this is a CANCEL: answer declined right away + // and let the abandoned work notice via cancelRef at its next poll. + cancelRef.current = true + triggerHaptic('cancel') + void respond({ server, status: 'declined' }) + }, [respond, server]) + + const approve = useCallback(async () => { + cancelRef.current = false + setWorking(true) + + // Poll-boundary abort for the two long flows (OAuth browser round-trip, + // background install). Wrapping the status fns keeps the loops themselves + // untouched — they throw the sentinel instead of returning stale progress. + const throwIfCancelled = (value: T): T => { + if (cancelRef.current) { + throw CANCELLED + } + + return value + } + + try { + if (action === 'enable') { + await setMcpServerEnabled(server, true) + triggerHaptic('submit') + await respond({ server, status: 'enabled' }) + + return + } + + if (action === 'authorize') { + const flow = await completeMcpDesktopOAuth({ + serverName: server, + start: authMcpServer, + status: flowId => getMcpOAuthFlow(flowId).then(throwIfCancelled), + openExternal: url => window.hermesDesktop.openExternal(url) + }) + + triggerHaptic('submit') + await respond({ server, status: 'authorized', tools: (flow.tools ?? []).map(tool => tool.name) }) + + return + } + + // Install: prefer the reviewed catalog entry when one exists; otherwise + // fall back to the desktop suggestion directory (official URL-only + // remotes), written through the same validated POST the dashboard's add + // form uses. Required catalog credentials get an inline prompt first + // (never pre-filled, never echoed back). + let resolved = entry + + if (resolved === undefined) { + const catalog = await getMcpCatalog() + resolved = catalog.entries.find(candidate => candidate.name === server) ?? null + setEntry(resolved) + } + + if (!resolved) { + const known = directoryEntry(server) + + if (!known) { + await respond({ detail: copy.notInCatalog(server), server, status: 'error' }) + + return + } + + // URL-only remote: add to config, then run the OAuth/probe flow so + // "Install" lands the user on a working server, not a 401. If the + // flow dies after the config write (cancel, closed OAuth tab), roll + // the write back — decline means "no server", not an unauthorized + // entry squatting in mcp_servers (authoritative-write rule). + await addMcpServer({ name: known.name, url: known.url }) + + let flow + try { + flow = await completeMcpDesktopOAuth({ + serverName: known.name, + start: authMcpServer, + status: flowId => getMcpOAuthFlow(flowId).then(throwIfCancelled), + openExternal: url => window.hermesDesktop.openExternal(url) + }) + } catch (error) { + await removeMcpServer(known.name).catch(() => { + // Rollback is best-effort; the primary error/cancel wins. + }) + throw error + } + + triggerHaptic('submit') + await respond({ server, status: 'installed', tools: (flow.tools ?? []).map(tool => tool.name) }) + + return + } + + const required = resolved.required_env.filter(env => env.required) + + if (required.some(env => !envDraft[env.name]?.trim())) { + // Reveal the credential fields; the user approves again once filled. + setEnvOpen(true) + + return + } + + const res = await installMcpCatalogEntry(server, envDraft) + + // Git-backed entries clone in the background — poll to completion so a + // non-zero exit surfaces as a real failure instead of a false success. + if (res.background && res.action) { + for (;;) { + const status = throwIfCancelled(await getActionStatus(res.action, 1)) + + if (!status.running) { + if (status.exit_code !== 0) { + throw new Error(copy.failed(server)) + } + + break + } + + await new Promise(resolve => setTimeout(resolve, CATALOG_INSTALL_POLL_MS)) + } + } + + triggerHaptic('submit') + await respond({ server, status: 'installed' }) + } catch (error) { + // User cancel: the declined respond is already on the wire — the + // abandoned flow just stops, nothing to report. + if (error === CANCELLED) { + return + } + + notifyError(error, copy.failed(server)) + await respond({ + detail: error instanceof Error ? error.message : String(error), + server, + status: 'error' + }) + } finally { + setWorking(false) + } + }, [action, copy, entry, envDraft, respond, server]) + + const title = + action === 'enable' + ? copy.enableTitle(prettyName(server)) + : action === 'authorize' + ? copy.authorizeTitle(prettyName(server)) + : copy.installTitle(prettyName(server)) + + const actionLabel = + action === 'enable' ? copy.enableAction : action === 'authorize' ? copy.authorizeAction : copy.installAction + + // What connecting actually means — the endpoint that will be contacted. + // VS Code's trust dialog links the config it's about to trust; same idea. + // Directory servers know their URL statically; catalog entries state their + // provenance (the reviewed manifest carries the transport). + const known = directoryEntry(server) + const sourceLine = action === 'install' ? (known?.url ?? copy.catalogSource) : null + const brand = brandFor(server) + + const trailingIcon = brand ? ( + + ) : ( + + ) + + // ⌘/Ctrl+Enter → approve, Esc → decline/cancel. Same accelerators, same + // guard shape as the approval bar (tool/approval.tsx). Unlike approve, Esc + // stays live while a flow is in flight — that's the cancel path. Stands + // down whenever a focusable control has focus (clarify's rule): a keystroke + // meant for the composer, a popover, or the card's own credential fields + // must never silently approve an install or throw away typed input. + useEffect(() => { + if (!ready) { + return + } + + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented) { + return + } + + const active = document.activeElement as HTMLElement | null + + if ( + active && + (active.isContentEditable || active.matches('a[href], button, input, select, textarea, [role="button"]')) + ) { + return + } + + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + if (!working) { + event.preventDefault() + void approve() + } + } else if (event.key === 'Escape') { + event.preventDefault() + decline() + } + } + + window.addEventListener('keydown', onKeyDown, true) + + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [approve, decline, ready, working]) + + if (!ready) { + return ( +
+ + {title} +
+ ) + } + + return ( +
+ + {title} + {reason ?

{reason}

: null} + {sourceLine &&

{sourceLine}

} +
+ {envOpen && entry && entry.required_env.length > 0 && ( +
+

{copy.envRequired}

+ {entry.required_env.map(env => ( + + ))} +
+ )} + {/* Same strip as the tool approval bar (tool/approval.tsx): a bordered + primary-tinted action plus a quiet ghost decline, with the matching + keyboard hints. One consent vocabulary across the transcript. */} +
+
+ +
+ {/* Never disabled: while a flow is in flight this is the cancel — + a stuck OAuth tab or hung install must always have a way out. */} + +
+
+ ) +} diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index df42bc175423c..266082df1f896 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -8,6 +8,7 @@ import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useSta import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text' +import { McpSetupTool } from '@/components/assistant-ui/mcp-setup-tool' import { DelegateTool } from '@/components/assistant-ui/tool/delegate' import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback' import { formatElapsed, useElapsedSeconds, useMeasuredDuration } from '@/components/chat/activity-timer' @@ -73,6 +74,10 @@ const ChainToolFallback: FC = props => { return } + if (props.toolName === 'setup_mcp') { + return + } + return } diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index a80dd621dee50..80a02d303edf4 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -1826,6 +1826,34 @@ export function listMcpServers(): Promise<{ servers: McpServerSummary[] }> { }) } +/** Add one server to `mcp_servers` (validated + name-collision-checked + * server-side — the same endpoint the dashboard's add form uses). */ +export function addMcpServer(body: { + name: string + url?: string + command?: string + args?: string[] + env?: Record + auth?: string +}): Promise { + return window.hermesDesktop.api({ + ...profileScoped(), + path: '/api/mcp/servers', + method: 'POST', + body + }) +} + +/** Remove one server from `mcp_servers` (the inline setup card's rollback + * when a directory install is cancelled after the config write). */ +export function removeMcpServer(name: string): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/mcp/servers/${encodeURIComponent(name)}`, + method: 'DELETE' + }) +} + export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index c55dbf6c3eef7..f2d1f03c6bc13 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -73,6 +73,10 @@ export type GatewayEventPayload = { request_id?: string question?: string choices?: string[] | null + // mcp.setup.request (setup_mcp tool — inline MCP consent card) + server?: string + action?: string + reason?: string // approval.request (dangerous command / execute_code) — session-keyed command?: string description?: string @@ -512,7 +516,8 @@ function toolPayloadMatchValues(payload: GatewayEventPayload | undefined): strin // `clarify.request` (a fresh request id) must correlate with the `tool.start` // row (the model's tool_call_id) so the two ids don't produce a duplicate // clarify card — same correlation ClarifyToolPending uses for request↔args. - const query = firstStringField(payloadArgs, ['search_term', 'query', 'question', 'command', 'code', 'path']) + // `server` is setup_mcp's identifying arg, for the identical reason. + const query = firstStringField(payloadArgs, ['search_term', 'query', 'question', 'server', 'command', 'code', 'path']) const context = typeof payload?.context === 'string' ? payload.context.trim() : '' const preview = typeof payload?.preview === 'string' ? payload.preview.trim() : '' @@ -525,7 +530,7 @@ function toolPartMatchValues(part: ChatMessagePart): string[] { } const args = part.args as Record - const query = firstStringField(args, ['search_term', 'query', 'question', 'command', 'code', 'path']) + const query = firstStringField(args, ['search_term', 'query', 'question', 'server', 'command', 'code', 'path']) const context = typeof args.context === 'string' ? args.context.trim() : '' const preview = typeof args.preview === 'string' ? args.preview.trim() : '' diff --git a/apps/desktop/src/lib/gateway-events.ts b/apps/desktop/src/lib/gateway-events.ts index 005e79705c15b..4b09ba30d7ed4 100644 --- a/apps/desktop/src/lib/gateway-events.ts +++ b/apps/desktop/src/lib/gateway-events.ts @@ -22,6 +22,7 @@ const UNSCOPED_STREAM_EVENT_TYPES = new Set([ 'browser.progress', 'clarify.request', 'error', + 'mcp.setup.request', 'message.complete', 'message.delta', 'message.interim', diff --git a/apps/desktop/src/lib/tool-render-class.ts b/apps/desktop/src/lib/tool-render-class.ts index 810b35fe57613..a972d5f38b757 100644 --- a/apps/desktop/src/lib/tool-render-class.ts +++ b/apps/desktop/src/lib/tool-render-class.ts @@ -24,10 +24,12 @@ export function isFileEditTool(toolName: string): boolean { // - `clarify`, `image_generate` and `delegate_task` bypass ToolEntry to // render their own markup: a question the user has to answer, an image // they asked for, the several agents a fan-out is running. +// - `setup_mcp` is the same kind: an inline consent card the user has to +// act on. Folding it into a "Using 2 tools" summary hides the buttons. // // Everything else is ephemeral activity — reads, searches, commands — which is // what a run summarizes and what the live ticker cycles through. -const CARD_TOOL_NAMES = new Set(['clarify', 'delegate_task', 'image_generate']) +const CARD_TOOL_NAMES = new Set(['clarify', 'delegate_task', 'image_generate', 'setup_mcp']) export function isCardTool(toolName: string): boolean { return CARD_TOOL_NAMES.has(toolName) || isFileEditTool(toolName) diff --git a/apps/desktop/src/store/mcp-setup.ts b/apps/desktop/src/store/mcp-setup.ts new file mode 100644 index 0000000000000..09a78b4277103 --- /dev/null +++ b/apps/desktop/src/store/mcp-setup.ts @@ -0,0 +1,118 @@ +import { atom, computed } from 'nanostores' + +import { $gateway } from './gateway' + +/** + * Pending `mcp.setup.request`s — the desktop half of the `setup_mcp` tool's + * blocking bridge (tools/setup_mcp_tool.py). Mirrors the clarify store: + * keyed by the runtime session id that raised the request so a background + * session can park its card while the user looks at another chat, and the + * inline McpSetupTool reads its own session's entry. + */ +export interface McpSetupRequest { + requestId: string + /** Catalog name (install) or mcp_servers config name (enable/authorize). */ + server: string + action: 'authorize' | 'enable' | 'install' + /** Agent-supplied one-liner: why this server helps right now. */ + reason: string + sessionId: string | null +} + +/** The card's answer, serialized back through `mcp.setup.respond`. */ +export interface McpSetupOutcome { + status: 'authorized' | 'declined' | 'enabled' | 'error' | 'installed' + server: string + detail?: string + /** Tool names now available (OAuth flows report them). */ + tools?: string[] +} + +const keyFor = (sessionId: string | null | undefined): string => sessionId ?? '' + +export const $mcpSetupRequests = atom>({}) + +/** The setup request for one specific session — the transcript card reads + * this fixed-key view, same shape as `sessionClarifyRequest`. */ +export const sessionMcpSetupRequest = (sessionId: string | null) => + computed($mcpSetupRequests, requests => requests[keyFor(sessionId)] ?? null) + +export function setMcpSetupRequest(request: McpSetupRequest): void { + $mcpSetupRequests.set({ ...$mcpSetupRequests.get(), [keyFor(request.sessionId)]: request }) +} + +export function clearMcpSetupRequest(requestId?: string, sessionId?: string | null): void { + const requests = $mcpSetupRequests.get() + + if (sessionId !== undefined) { + const key = keyFor(sessionId) + const current = requests[key] + + if (!current || (requestId && current.requestId !== requestId)) { + return + } + + const next = { ...requests } + delete next[key] + $mcpSetupRequests.set(next) + + return + } + + const next: Record = {} + let changed = false + + for (const [key, value] of Object.entries(requests)) { + if (requestId && value.requestId !== requestId) { + next[key] = value + } else { + changed = true + } + } + + if (changed) { + $mcpSetupRequests.set(next) + } +} + +/** Whether `sessionId` has a setup card pending right now (imperative read — + * the composer checks this on Enter, not on every render). */ +export const hasMcpSetupRequest = (sessionId: string | null | undefined): boolean => + Boolean($mcpSetupRequests.get()[keyFor(sessionId)]) + +/** + * Answer `sessionId`'s pending setup card as declined and drop it locally, + * resolving to whether there was one to skip. + * + * The composer uses this when the user types a real message instead of acting + * on the card: setup_mcp blocks the agent inside its tool batch, so leaving + * the card unanswered would park the follow-up until the 10-minute timeout — + * the message looks sent and nothing happens. Typing IS the answer "not now": + * decline so the tool returns, then route the words normally. + * + * Mirrors skipClarifyRequest; mcp.setup.respond is allow_expired, so racing + * the timeout is harmless. + */ +export async function skipMcpSetupRequest(sessionId: string | null | undefined): Promise { + const request = $mcpSetupRequests.get()[keyFor(sessionId)] + + if (!request) { + return false + } + + // Clear first: the answer is already decided, and an in-flight RPC must not + // leave a live card the user can answer a second time. + clearMcpSetupRequest(request.requestId, request.sessionId) + + try { + await $gateway.get()?.request('mcp.setup.respond', { + request_id: request.requestId, + result: JSON.stringify({ server: request.server, status: 'declined' }) + }) + } catch { + // The tool times out on its own; a failed skip must never swallow the + // message the user is actually sending. + } + + return true +}