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