feat(desktop): render setup_mcp as an interactive consent card in the transcript
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.
This commit is contained in:
parent
adbc77eb50
commit
6cd4793081
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Partial<McpSetupOutcome>, '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 (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
{trailing}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const McpSetupTool = (props: ToolCallMessagePartProps) => {
|
||||
// Settled → static outcome line (the flow already ran or was declined).
|
||||
if (props.result !== undefined) {
|
||||
return <McpSetupSettled {...props} />
|
||||
}
|
||||
|
||||
return <McpSetupLive {...props} />
|
||||
}
|
||||
|
||||
const McpSetupLive = (props: ToolCallMessagePartProps) => {
|
||||
const messageRunning = useAuiState(selectMessageRunning)
|
||||
|
||||
// Stopped mid-prompt with no result — don't leave a dead interactive panel.
|
||||
if (!messageRunning) {
|
||||
return <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
return <McpSetupPending {...props} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn(SHELL_CLASS, 'my-1.5 grid gap-1.5')} data-slot="mcp-setup-inline">
|
||||
<SetupLine
|
||||
trailing={
|
||||
ok ? (
|
||||
<CheckCircle2 aria-hidden className={cn(ICON_CLASS, 'text-emerald-400')} />
|
||||
) : neutral && brand ? (
|
||||
<brand.Icon aria-hidden className="mt-px size-4 shrink-0 opacity-60" style={brandGlyphStyle(brand)} />
|
||||
) : neutral ? (
|
||||
<Codicon className={ICON_CLASS} name="plug" size="1rem" />
|
||||
) : (
|
||||
<AlertCircle aria-hidden className={cn(ICON_CLASS, 'text-destructive')} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className={cn('font-medium', neutral && 'italic text-(--ui-text-tertiary)')}>{line}</span>
|
||||
{ok && toolCount > 0 && (
|
||||
<span className="ml-2 text-(--ui-text-tertiary)">{copy.toolCount(toolCount)}</span>
|
||||
)}
|
||||
{!ok && !neutral && fromResult.detail ? (
|
||||
<p className="mt-0.5 text-(--ui-text-secondary)">{fromResult.detail}</p>
|
||||
) : null}
|
||||
</SetupLine>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<Record<string, string>>({})
|
||||
const [entry, setEntry] = useState<McpCatalogEntry | null | undefined>(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 = <T,>(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 ? (
|
||||
<brand.Icon aria-hidden className="mt-px size-4 shrink-0" style={brandGlyphStyle(brand)} />
|
||||
) : (
|
||||
<Codicon className={ICON_CLASS} name="plug" size="1rem" />
|
||||
)
|
||||
|
||||
// ⌘/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 (
|
||||
<div className={cn(SHELL_CLASS, 'my-1.5 flex items-center gap-2')} data-slot="mcp-setup-inline">
|
||||
<Loader2 aria-hidden className="size-4 animate-spin text-(--ui-text-tertiary)" />
|
||||
<span className="text-(--ui-text-tertiary)">{title}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(SHELL_CLASS, 'my-1.5 grid gap-1.5')} data-slot="mcp-setup-inline">
|
||||
<SetupLine trailing={trailingIcon}>
|
||||
<span className="font-medium leading-(--conversation-line-height)">{title}</span>
|
||||
{reason ? <p className="mt-0.5 text-(--ui-text-secondary)">{reason}</p> : null}
|
||||
{sourceLine && <p className="mt-0.5 truncate text-[0.6875rem] text-(--ui-text-tertiary)">{sourceLine}</p>}
|
||||
</SetupLine>
|
||||
{envOpen && entry && entry.required_env.length > 0 && (
|
||||
<div className="grid gap-2" data-slot="mcp-setup-env">
|
||||
<p className="text-[0.6875rem] text-(--ui-text-tertiary)">{copy.envRequired}</p>
|
||||
{entry.required_env.map(env => (
|
||||
<label className="grid gap-1" key={env.name}>
|
||||
<span className="text-[0.6875rem] text-(--ui-text-secondary)">
|
||||
{env.prompt || env.name}
|
||||
{env.required ? ' *' : ''}
|
||||
</span>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
onChange={event => setEnvDraft(prev => ({ ...prev, [env.name]: event.currentTarget.value }))}
|
||||
type="password"
|
||||
value={envDraft[env.name] ?? ''}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
|
||||
<Button
|
||||
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={working}
|
||||
onClick={() => void approve()}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{working ? <Loader2 className="size-3 animate-spin" /> : actionLabel}
|
||||
{!working && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<Button
|
||||
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
onClick={decline}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{working ? t.common.cancel : copy.decline}
|
||||
<span className="text-[0.625rem] opacity-55">Esc</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<ToolCallMessagePartProps> = props => {
|
|||
return <ClarifyTool {...props} />
|
||||
}
|
||||
|
||||
if (props.toolName === 'setup_mcp') {
|
||||
return <McpSetupTool {...props} />
|
||||
}
|
||||
|
||||
return <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
auth?: string
|
||||
}): Promise<McpServerSummary> {
|
||||
return window.hermesDesktop.api<McpServerSummary>({
|
||||
...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(),
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
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() : ''
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<Record<string, McpSetupRequest>>({})
|
||||
|
||||
/** 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<string, McpSetupRequest> = {}
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
Loading…
Reference in New Issue