import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '../components/shared/Button' import { Input } from '../components/shared/Input' import { Modal } from '../components/shared/Modal' import { useTranslation } from '../i18n' import { useUIStore } from '../stores/uiStore' import { useMcpStore } from '../stores/mcpStore' import { useSessionStore } from '../stores/sessionStore' import type { McpServerRecord, McpUpsertPayload } from '../types/mcp' type EditorMode = | { type: 'list' } | { type: 'create' } | { type: 'edit'; server: McpServerRecord } | { type: 'details'; server: McpServerRecord } type TransportKind = 'stdio' | 'http' | 'sse' type StringRow = { id: string value: string } type KeyValueRow = { id: string key: string value: string } type McpDraft = { name: string transport: TransportKind command: string args: StringRow[] env: KeyValueRow[] url: string headers: KeyValueRow[] headersHelper: string oauthClientId: string oauthCallbackPort: string } type McpGroupKey = | 'plugin' | 'user' | 'project' | 'local' | 'managed' | 'enterprise' | 'claudeai' | 'dynamic' const MCP_GROUP_ORDER: McpGroupKey[] = [ 'plugin', 'user', 'project', 'local', 'managed', 'enterprise', 'claudeai', 'dynamic', ] const STATUS_TONE: Record = { connected: 'bg-[var(--color-inspector-success-bg)] text-[var(--color-inspector-success)] border-[var(--color-border)]', checking: 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] border-[var(--color-border)]', 'needs-auth': 'bg-[var(--color-surface-container-low)] text-[var(--color-warning)] border-[var(--color-border)]', failed: 'bg-[var(--color-inspector-danger-bg)] text-[var(--color-inspector-danger)] border-[var(--color-border)]', disabled: 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] border-[var(--color-border)]', } function createId() { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID() return `${Date.now()}-${Math.random().toString(16).slice(2)}` } function createStringRow(value = ''): StringRow { return { id: createId(), value } } function createKeyValueRow(key = '', value = ''): KeyValueRow { return { id: createId(), key, value } } function createEmptyDraft(): McpDraft { return { name: '', transport: 'stdio', command: '', args: [createStringRow('')], env: [createKeyValueRow()], url: '', headers: [createKeyValueRow()], headersHelper: '', oauthClientId: '', oauthCallbackPort: '', } } function isStdioConfig(config: McpServerRecord['config']): config is Extract { return config.type === 'stdio' } function isRemoteConfig(config: McpServerRecord['config']): config is Extract { return config.type === 'http' || config.type === 'sse' } function draftFromServer(server: McpServerRecord): McpDraft { const base = createEmptyDraft() base.name = server.name if (isStdioConfig(server.config)) { return { ...base, transport: 'stdio', command: server.config.command, args: (server.config.args.length ? server.config.args : ['']).map((value) => createStringRow(value)), env: Object.entries(server.config.env ?? {}).map(([key, value]) => createKeyValueRow(key, value)).concat( Object.keys(server.config.env ?? {}).length === 0 ? [createKeyValueRow()] : [], ), } } if (isRemoteConfig(server.config)) { return { ...base, transport: server.config.type, url: server.config.url, headers: Object.entries(server.config.headers ?? {}).map(([key, value]) => createKeyValueRow(key, value)).concat( Object.keys(server.config.headers ?? {}).length === 0 ? [createKeyValueRow()] : [], ), headersHelper: server.config.headersHelper ?? '', oauthClientId: server.config.oauth?.clientId ?? '', oauthCallbackPort: server.config.oauth?.callbackPort ? String(server.config.oauth.callbackPort) : '', } } return base } function rowsToRecord(rows: KeyValueRow[]) { const entries: Array<[string, string]> = [] for (const row of rows) { const key = row.key.trim() if (!key) continue entries.push([key, row.value]) } return Object.fromEntries(entries) } function rowsToList(rows: StringRow[]) { return rows.map((row) => row.value.trim()).filter(Boolean) } function buildPayload(draft: McpDraft): McpUpsertPayload { if (draft.transport === 'stdio') { return { scope: 'user', config: { type: 'stdio', command: draft.command.trim(), args: rowsToList(draft.args), env: rowsToRecord(draft.env), }, } } const oauthCallbackPort = draft.oauthCallbackPort.trim() const callbackPortNumber = oauthCallbackPort ? Number(oauthCallbackPort) : undefined const oauthClientId = draft.oauthClientId.trim() return { scope: 'user', config: { type: draft.transport, url: draft.url.trim(), headers: rowsToRecord(draft.headers), ...(draft.headersHelper.trim() ? { headersHelper: draft.headersHelper.trim() } : {}), ...(oauthClientId || callbackPortNumber ? { oauth: { ...(oauthClientId ? { clientId: oauthClientId } : {}), ...(callbackPortNumber ? { callbackPort: callbackPortNumber } : {}), }, } : {}), }, } } function isDraftValid(draft: McpDraft) { if (!draft.name.trim()) return false if (draft.transport === 'stdio') return draft.command.trim().length > 0 return draft.url.trim().length > 0 } function transportLabel(transport: string, t: ReturnType) { switch (transport) { case 'stdio': return 'STDIO' case 'http': return t('settings.mcp.transport.http') case 'sse': return 'SSE' default: return transport } } function getServerGroupKey(server: McpServerRecord): McpGroupKey { if (server.name.startsWith('plugin:')) return 'plugin' switch (server.scope) { case 'user': case 'project': case 'local': case 'managed': case 'enterprise': case 'claudeai': case 'dynamic': return server.scope default: return 'dynamic' } } function scopeLabel(server: McpServerRecord, t: ReturnType) { const group = getServerGroupKey(server) if (group === 'plugin') return t('settings.mcp.scope.plugin') return t(`settings.mcp.scope.${group}`) } function StatusBadge({ server }: { server: McpServerRecord }) { return ( {server.statusLabel} ) } function getServerIdentityKey(server: Pick) { if (server.scope === 'local' || server.scope === 'project') { return `${server.scope}:${server.projectPath ?? ''}:${server.name}` } return `${server.scope}:${server.name}` } function ToggleSwitch({ checked, disabled, onChange, }: { checked: boolean disabled?: boolean onChange: () => void }) { return ( ) } function ArraySection({ title, rows, onChange, onAdd, onRemove, keyPlaceholder, valuePlaceholder, singleValue = false, addLabel, }: { title: string rows: KeyValueRow[] | StringRow[] onChange: (id: string, field: 'key' | 'value', value: string) => void onAdd: () => void onRemove: (id: string) => void keyPlaceholder?: string valuePlaceholder: string singleValue?: boolean addLabel: string }) { return (
{title}
{rows.map((row) => (
{!singleValue && 'key' in row && ( onChange(row.id, 'key', event.target.value)} placeholder={keyPlaceholder} /> )} onChange(row.id, 'value', event.target.value)} placeholder={valuePlaceholder} />
))}
) } function StatCard({ label, value, icon }: { label: string; value: number; icon: string }) { return (
{icon} {label}
{value}
) } function ServerRow({ server, isBusy, onOpen, onToggle, t, }: { server: McpServerRecord isBusy: boolean onOpen: () => void onToggle: () => void t: ReturnType }) { return (
{server.name}
{transportLabel(server.transport, t)} {scopeLabel(server, t)} {server.summary}
{server.statusDetail && (
{server.statusDetail}
)}
) } export function McpSettings() { const { servers, selectedServer, isLoading, error, fetchServers, createServer, updateServer, deleteServer, toggleServer, reconnectServer, refreshServerStatus, selectServer } = useMcpStore() const addToast = useUIStore((s) => s.addToast) const sessions = useSessionStore((s) => s.sessions) const activeSessionId = useSessionStore((s) => s.activeSessionId) const t = useTranslation() const [view, setView] = useState({ type: 'list' }) const [draft, setDraft] = useState(createEmptyDraft) const [isSaving, setIsSaving] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const [busyServerName, setBusyServerName] = useState(null) const [pendingDeleteServer, setPendingDeleteServer] = useState(null) const refreshInFlightRef = useRef(new Set()) const activeSession = sessions.find((session) => session.id === activeSessionId) const currentWorkDir = activeSession?.workDir || undefined const resolveOperationCwd = (server?: McpServerRecord) => server?.projectPath ?? currentWorkDir useEffect(() => { void fetchServers(undefined, currentWorkDir) }, [fetchServers, currentWorkDir]) const groupedServers = useMemo(() => { const groups: Partial> = {} for (const server of servers) { const key = getServerGroupKey(server) ;(groups[key] ??= []).push(server) } return groups }, [servers]) const stats = useMemo(() => ({ total: servers.length, connected: servers.filter((server) => server.status === 'connected').length, attention: servers.filter((server) => server.status === 'failed' || server.status === 'needs-auth').length, }), [servers]) const beginCreate = () => { setDraft(createEmptyDraft()) setView({ type: 'create' }) } const beginEdit = (server: McpServerRecord) => { selectServer(server) if (!server.canEdit) { setView({ type: 'details', server }) return } setDraft(draftFromServer(server)) setView({ type: 'edit', server }) } useEffect(() => { if (!selectedServer) return if (selectedServer.canEdit) { setDraft(draftFromServer(selectedServer)) setView({ type: 'edit', server: selectedServer }) } else { setView({ type: 'details', server: selectedServer }) } }, [selectedServer]) useEffect(() => { const pendingServers = servers.filter((server) => ( server.enabled && server.status === 'checking' && !refreshInFlightRef.current.has(getServerIdentityKey(server)) )) if (pendingServers.length === 0) return let cancelled = false const queue = [...pendingServers] const workerCount = Math.min(2, queue.length) const runWorker = async () => { while (!cancelled) { const server = queue.shift() if (!server) return const key = getServerIdentityKey(server) refreshInFlightRef.current.add(key) try { const updated = await refreshServerStatus(server, resolveOperationCwd(server)) if (cancelled) return setView((current) => { if (current.type !== 'details' && current.type !== 'edit') return current if (getServerIdentityKey(current.server) !== key) return current return { ...current, server: updated } }) } catch { // Keep passive checks silent. Explicit reconnect remains the action that // surfaces failures to the user. } finally { refreshInFlightRef.current.delete(key) } } } void Promise.all(Array.from({ length: workerCount }, () => runWorker())) return () => { cancelled = true } }, [servers, refreshServerStatus, currentWorkDir]) const handleToggle = async (server: McpServerRecord) => { setBusyServerName(server.name) try { const updated = await toggleServer(server, resolveOperationCwd(server)) addToast({ type: 'success', message: updated.enabled ? t('settings.mcp.toast.enabled', { name: server.name }) : t('settings.mcp.toast.disabled', { name: server.name }), }) } catch (error) { addToast({ type: 'error', message: error instanceof Error ? error.message : t('settings.mcp.toast.toggleFailed'), }) } finally { setBusyServerName(null) } } const handleReconnect = async (server: McpServerRecord) => { const optimistic = { ...server, status: 'checking' as const, statusLabel: t('status.reconnecting'), statusDetail: undefined, } setBusyServerName(server.name) setView((current) => { if (current.type !== 'details' && current.type !== 'edit') return current if (getServerIdentityKey(current.server) !== getServerIdentityKey(server)) return current return { ...current, server: optimistic } }) try { const updated = await reconnectServer(server, resolveOperationCwd(server)) addToast({ type: updated.status === 'connected' ? 'success' : 'warning', message: updated.status === 'connected' ? t('settings.mcp.toast.reconnected', { name: server.name }) : updated.statusDetail || updated.statusLabel, }) if (view.type === 'edit') setView({ type: 'edit', server: updated }) if (view.type === 'details') setView({ type: 'details', server: updated }) } catch (error) { setView((current) => { if (current.type !== 'details' && current.type !== 'edit') return current if (getServerIdentityKey(current.server) !== getServerIdentityKey(server)) return current return { ...current, server } }) addToast({ type: 'error', message: error instanceof Error ? error.message : t('settings.mcp.toast.reconnectFailed'), }) } finally { setBusyServerName(null) } } const handleDelete = (server: McpServerRecord) => { setPendingDeleteServer(server) } const confirmDelete = async () => { const server = pendingDeleteServer if (!server) return setIsDeleting(true) try { await deleteServer(server, resolveOperationCwd(server)) addToast({ type: 'success', message: t('settings.mcp.toast.deleted', { name: server.name }), }) setView({ type: 'list' }) selectServer(null) setPendingDeleteServer(null) } catch (error) { addToast({ type: 'error', message: error instanceof Error ? error.message : t('settings.mcp.toast.deleteFailed'), }) } finally { setIsDeleting(false) } } const deleteModal = ( { if (isDeleting) return setPendingDeleteServer(null) }} title={t('settings.mcp.form.deleteTitle')} footer={( <> )} >

{pendingDeleteServer ? t('settings.mcp.form.deleteConfirmBody', { name: pendingDeleteServer.name }) : ''}

) const handleSave = async () => { if (!isDraftValid(draft)) return setIsSaving(true) try { const payload = buildPayload(draft) const saved = view.type === 'edit' ? await updateServer(view.server, payload, resolveOperationCwd(view.server)) : await createServer(draft.name.trim(), payload, currentWorkDir) addToast({ type: 'success', message: view.type === 'edit' ? t('settings.mcp.toast.saved', { name: saved.name }) : t('settings.mcp.toast.created', { name: saved.name }), }) setView({ type: 'list' }) selectServer(null) } catch (error) { addToast({ type: 'error', message: error instanceof Error ? error.message : t('settings.mcp.toast.saveFailed'), }) } finally { setIsSaving(false) } } const setDraftField = (key: K, value: McpDraft[K]) => { setDraft((current) => ({ ...current, [key]: value })) } const updateStringRows = (key: 'args', id: string, value: string) => { setDraft((current) => ({ ...current, [key]: current[key].map((row) => (row.id === id ? { ...row, value } : row)), })) } const updateKeyValueRows = (key: 'env' | 'headers', id: string, field: 'key' | 'value', value: string) => { setDraft((current) => ({ ...current, [key]: current[key].map((row) => (row.id === id ? { ...row, [field]: value } : row)), })) } const addRow = (key: 'args' | 'env' | 'headers') => { setDraft((current) => ({ ...current, [key]: [...current[key], key === 'args' ? createStringRow() : createKeyValueRow()], })) } const removeRow = (key: 'args' | 'env' | 'headers', id: string) => { setDraft((current) => { const next = current[key].filter((row) => row.id !== id) return { ...current, [key]: next.length > 0 ? next : [key === 'args' ? createStringRow() : createKeyValueRow()], } }) } if (view.type === 'details') { const server = view.server return ( <>

{server.name}

{server.summary}

{server.statusDetail && ( {server.statusDetail} )}
{server.canReconnect && ( )}
{t('settings.mcp.form.rawConfig')}
                {JSON.stringify(server.config, null, 2)}
              
{deleteModal} ) } if (view.type === 'create' || view.type === 'edit') { const editing = view.type === 'edit' const targetServer = editing ? view.server : null const transportLocked = editing const isBusy = isSaving || isDeleting return ( <>

{editing ? t('settings.mcp.form.editTitle', { name: targetServer!.name }) : t('settings.mcp.form.createTitle')}

{editing ? t('settings.mcp.form.editHint') : t('settings.mcp.form.createHint')}

{editing && targetServer && (
{targetServer.statusDetail && ( {targetServer.statusDetail} )}
)}
{editing && targetServer?.canReconnect && ( )} {editing && targetServer?.canRemove && ( )}
setDraftField('name', event.target.value)} placeholder={t('settings.mcp.form.namePlaceholder')} disabled={editing} required />
{t('settings.mcp.form.scope')}

{t('settings.mcp.globalOnlyHint')}

{(['stdio', 'http', 'sse'] as TransportKind[]).map((transport) => { const active = draft.transport === transport return ( ) })}
{editing && (
{t('settings.mcp.form.transportLocked')}
)} {draft.transport === 'stdio' ? ( <>
setDraftField('command', event.target.value)} placeholder={t('settings.mcp.form.commandPlaceholder')} required />

{t('settings.mcp.form.commandHostHint')}

updateStringRows('args', id, value)} onAdd={() => addRow('args')} onRemove={(id) => removeRow('args', id)} singleValue valuePlaceholder={t('settings.mcp.form.argumentPlaceholder')} addLabel={t('settings.mcp.form.addArgument')} /> updateKeyValueRows('env', id, field, value)} onAdd={() => addRow('env')} onRemove={(id) => removeRow('env', id)} keyPlaceholder={t('settings.mcp.form.keyPlaceholder')} valuePlaceholder={t('settings.mcp.form.valuePlaceholder')} addLabel={t('settings.mcp.form.addEnv')} /> ) : ( <>
setDraftField('url', event.target.value)} placeholder={t('settings.mcp.form.urlPlaceholder')} required />
updateKeyValueRows('headers', id, field, value)} onAdd={() => addRow('headers')} onRemove={(id) => removeRow('headers', id)} keyPlaceholder={t('settings.mcp.form.keyPlaceholder')} valuePlaceholder={t('settings.mcp.form.valuePlaceholder')} addLabel={t('settings.mcp.form.addHeader')} />
setDraftField('oauthClientId', event.target.value)} placeholder={t('settings.mcp.form.oauthClientIdPlaceholder')} /> setDraftField('oauthCallbackPort', event.target.value)} placeholder={t('settings.mcp.form.oauthCallbackPortPlaceholder')} />
setDraftField('headersHelper', event.target.value)} placeholder={t('settings.mcp.form.headersHelperPlaceholder')} />
)}
{deleteModal} ) } return (

{t('settings.mcp.title')}

{t('settings.mcp.description')}

{isLoading && servers.length === 0 ? (
) : error ? (
error

{error}

) : servers.length === 0 ? (
dns

{t('settings.mcp.empty')}

{t('settings.mcp.emptyHint')}

) : (
{MCP_GROUP_ORDER.map((group) => { const groupServers = groupedServers[group] if (!groupServers?.length) return null return (
{group === 'plugin' ? t('settings.mcp.scope.plugin') : t(`settings.mcp.scope.${group}`)}
{groupServers.length}
{groupServers.map((server) => ( beginEdit(server)} onToggle={() => void handleToggle(server)} t={t} /> ))}
) })}
)} {deleteModal}
) } function InfoPair({ label, value }: { label: string; value: string }) { return (
{label}
{value}
) }