Repository Public
1'use client';2import { useEffect, useState } from 'react';3import { useQueryClient } from '@tanstack/react-query';4import type { McpServerInfo } from '@cr/shared';5import { Plug, Plus, Trash2 } from 'lucide-react';6import { api, errorMessage, qs } from '@/lib/api';7import { qk, useMcp, useMcpLive, useProjects } from '@/lib/queries';8import { useCurrentProject } from '@/hooks/use-current';9import { fmtDateTime } from '@/lib/format';10import { toast } from '@/stores/toasts';11import { Badge, type Tone } from '@/components/ui/badge';12import { Button, IconButton } from '@/components/ui/button';13import { ConfirmDialog, Dialog } from '@/components/ui/dialog';14import { Field, Input } from '@/components/ui/input';15import { Select } from '@/components/ui/select';16import { EmptyState, ErrorState, PageHeader, Skeleton } from '@/components/ui/misc';17import { useBrand } from '@/stores/branding';1819const STATUS: Record<McpServerInfo['status'], { label: string; tone: Tone }> = {20 connected: { label: 'Connected', tone: 'success' }, failed: { label: 'Failed', tone: 'error' }, needs_auth: { label: 'Needs authentication', tone: 'warning' }, unknown: { label: 'Unknown', tone: 'muted' },21};2223export default function McpPage() {24 const brand = useBrand();25 const project = useCurrentProject();26 const q = useMcp(project?.id);27 const qc = useQueryClient();28 const [adding, setAdding] = useState(false);29 const [removing, setRemoving] = useState<McpServerInfo | null>(null);30 const [testing, setTesting] = useState<string | null>(null);31 // GET /mcp is configuration only; live statuses are fetched in the background (can take ~45 s).32 const [liveRun, setLiveRun] = useState(0);33 // An agent whose `mcp list` does not health-check (Codex) has no live status: it stays "unknown".34 const canLive = brand.caps.mcpLiveStatus;35 const live = useMcpLive(project?.id, canLive && q.isSuccess && q.data.length > 0, liveRun);36 const [tested, setTested] = useState<Record<string, McpServerInfo>>({});37 const keyOf = (m: McpServerInfo) => `${m.scope}:${m.name}`;38 const merged = (m: McpServerInfo): McpServerInfo => tested[keyOf(m)] ?? live.data?.find((x) => keyOf(x) === keyOf(m)) ?? m;39 const test = async (m: McpServerInfo) => {40 setTesting(m.name);41 try {42 const r = await api.post<McpServerInfo>(`/mcp/${encodeURIComponent(m.name)}/test${qs({ scope: m.scope, projectId: m.scope === 'user' ? undefined : project?.id })}`);43 if (r && typeof r === 'object' && 'name' in r) setTested((t) => ({ ...t, [keyOf(m)]: r }));44 else await qc.invalidateQueries({ queryKey: qk.mcp });45 } catch (e) {46 toast.error('Test failed', errorMessage(e));47 } finally {48 setTesting(null);49 }50 };51 return (52 <div className="flex h-full flex-col overflow-hidden">53 <PageHeader title="MCP servers" description={`Model Context Protocol servers configured for ${brand.agentName}. Secret values are write-only and never shown.`} meta={q.data?.length ? <span data-testid="mcp-live-state">{!canLive ? `${brand.agentName} does not report live server status` : live.isFetching ? 'Checking servers… (this can take up to 45 s)' : live.isError ? 'Live status check failed' : live.data ? 'Live statuses loaded' : 'Statuses not checked yet'}</span> : undefined}54 actions={<>{canLive && <Button onClick={() => { setTested({}); setLiveRun((n) => n + 1); }} loading={live.isFetching} disabled={!q.data?.length}>Check status</Button>}<Button variant="primary" onClick={() => setAdding(true)} data-testid="add-mcp"><Plus className="size-3.5" /> Add server</Button></>} />55 <div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">56 {q.isPending ? <div className="flex flex-col gap-2">{[0, 1].map((i) => <Skeleton key={i} className="h-20" />)}</div>57 : q.isError ? <ErrorState error={q.error} onRetry={() => q.refetch()} />58 : q.data.length === 0 ? <EmptyState icon={<Plug />} title="No MCP servers configured" action={<Button variant="primary" onClick={() => setAdding(true)}>Add server</Button>} />59 : (60 <ul className="flex flex-col gap-3" data-testid="mcp-list">61 {q.data.map(merged).map((m) => (62 <li key={`${m.scope}:${m.name}`} className="card px-4 py-3" data-testid="mcp-row">63 <div className="flex flex-wrap items-center gap-2">64 <h2 className="text-[13px] font-semibold">{m.name}</h2>65 <Badge tone="muted">{m.transport}</Badge>66 <Badge tone="muted">{m.scope}</Badge>67 <Badge tone={STATUS[m.status].tone} data-testid="mcp-status">{m.status === 'unknown' && live.isFetching ? 'Checking…' : STATUS[m.status].label}</Badge>68 <span className="flex-1" />69 <Button size="sm" loading={testing === m.name} onClick={() => test(m)}>Test</Button>70 <IconButton label={`Remove ${m.name}`} onClick={() => setRemoving(m)}><Trash2 className="size-3.5" /></IconButton>71 </div>72 <p className="mt-1.5 break-all font-mono text-xs text-text-2">{m.target}</p>73 <p className="mt-1.5 text-[11px] text-muted">74 {m.envKeys.length ? <>Secrets set: {m.envKeys.map((k) => <code key={k} className="mr-1.5 font-mono text-text-2">{k}=••••</code>)}</> : brand.caps.mcpHeaders ? 'No environment variables or headers' : 'No environment variables'}75 <span className="tnum"> · checked {m.checkedAt ? fmtDateTime(m.checkedAt) : 'never'}</span>76 </p>77 {m.lastError && <p className="mt-1.5 rounded-sm bg-error/8 px-2 py-1 text-xs text-error">{m.lastError}</p>}78 </li>79 ))}80 </ul>81 )}82 </div>83 <AddMcpDialog open={adding} onClose={() => setAdding(false)} />84 <ConfirmDialog85 open={!!removing}86 onOpenChange={(o) => !o && setRemoving(null)}87 title="Remove MCP server?"88 description={<>“{removing?.name}” will be removed from the {brand.agentName} configuration, including its stored secrets.</>}89 confirmLabel="Remove"90 danger91 onConfirm={async () => {92 if (!removing) return;93 try {94 await api.delete(`/mcp/${encodeURIComponent(removing.name)}${qs({ scope: removing.scope, projectId: removing.scope === 'user' ? undefined : project?.id })}`);95 await qc.invalidateQueries({ queryKey: qk.mcp });96 } catch (e) {97 toast.error('Could not remove', errorMessage(e));98 }99 }}100 />101 </div>102 );103}104105const TRANSPORT_OPTIONS = [{ value: 'stdio', label: 'stdio (local command)' }, { value: 'http', label: 'http' }, { value: 'sse', label: 'sse' }] as const;106107type KV = { k: string; v: string };108/** Write-only key/value rows: values are typed into password inputs and never displayed again. */109function SecretRows({ label, rows, onChange, keyPlaceholder }: { label: string; rows: KV[]; onChange: (r: KV[]) => void; keyPlaceholder: string }) {110 return (111 <fieldset className="flex flex-col gap-2">112 <legend className="mb-1.5 text-xs font-medium text-text-2">{label}</legend>113 {rows.map((r, i) => (114 <div key={i} className="flex items-center gap-2">115 <Input aria-label={`${label} name ${i + 1}`} placeholder={keyPlaceholder} value={r.k} onChange={(e) => onChange(rows.map((x, j) => (j === i ? { ...x, k: e.target.value } : x)))} spellCheck={false} autoCapitalize="off" autoCorrect="off" className="font-mono" />116 <Input aria-label={`${label} value ${i + 1}`} type="password" placeholder="value (write-only)" value={r.v} onChange={(e) => onChange(rows.map((x, j) => (j === i ? { ...x, v: e.target.value } : x)))} autoComplete="new-password" className="font-mono" />117 <IconButton label="Remove row" noTooltip onClick={() => onChange(rows.filter((_, j) => j !== i))}><Trash2 className="size-3.5" /></IconButton>118 </div>119 ))}120 <Button size="sm" variant="outline" className="self-start" onClick={() => onChange([...rows, { k: '', v: '' }])}><Plus className="size-3" /> Add</Button>121 </fieldset>122 );123}124125function AddMcpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {126 const brand = useBrand();127 const qc = useQueryClient();128 const projects = useProjects().data ?? [];129 const [name, setName] = useState('');130 const [transport, setTransport] = useState<'stdio' | 'http' | 'sse'>('stdio');131 const [target, setTarget] = useState('');132 const [args, setArgs] = useState('');133 const [env, setEnv] = useState<KV[]>([]);134 const [headers, setHeaders] = useState<KV[]>([]);135 const [scope, setScope] = useState<'user' | 'project'>('user');136 const [projectId, setProjectId] = useState('');137 const [busy, setBusy] = useState(false);138 const [error, setError] = useState<string | null>(null);139 useEffect(() => {140 if (!open) return;141 setName(''); setTransport('stdio'); setTarget(''); setArgs(''); setEnv([]); setHeaders([]); setScope('user'); setProjectId(''); setError(null);142 }, [open]);143 const nameOk = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,39}$/.test(name);144 const urlOk = transport === 'stdio' || /^https?:\/\/\S+$/.test(target.trim());145 const transports = brand.caps.mcpTransports;146 const canProjectScope = brand.caps.projectMcpScope;147 const canHeaders = brand.caps.mcpHeaders;148 const valid = nameOk && target.trim().length > 0 && urlOk && (scope === 'user' || !!projectId);149 const toRecord = (rows: KV[]) => Object.fromEntries(rows.filter((r) => r.k.trim()).map((r) => [r.k.trim(), r.v]));150 const submit = async () => {151 setBusy(true);152 setError(null);153 try {154 await api.post('/mcp', {155 name, transport, target: target.trim(),156 args: transport === 'stdio' ? args.split('\n').map((a) => a.trim()).filter(Boolean) : [],157 env: transport === 'stdio' ? toRecord(env) : {},158 headers: transport === 'stdio' || !canHeaders ? {} : toRecord(headers),159 scope, ...(scope === 'project' ? { projectId } : {}),160 });161 // Secrets leave component state as soon as they are sent.162 setEnv([]);163 setHeaders([]);164 await qc.invalidateQueries({ queryKey: qk.mcp });165 onClose();166 } catch (e) {167 setError(errorMessage(e));168 } finally {169 setBusy(false);170 }171 };172 return (173 <Dialog174 open={open}175 onOpenChange={(o) => !o && onClose()}176 title="Add MCP server"177 size="lg"178 footer={179 <>180 {error && <p role="alert" className="mr-auto text-xs text-error">{error}</p>}181 <Button variant="ghost" onClick={onClose}>Cancel</Button>182 <Button variant="primary" loading={busy} disabled={!valid} onClick={submit} data-testid="mcp-submit">Add server</Button>183 </>184 }185 >186 <div className="flex flex-col gap-4">187 <div className="grid gap-4 sm:grid-cols-2">188 <Field label="Name" error={name && !nameOk ? 'Letters, digits, “_” and “-” (not starting with “-”), at most 40.' : null}><Input value={name} onChange={(e) => setName(e.target.value)} spellCheck={false} autoCapitalize="off" data-testid="mcp-name" /></Field>189 <Field label="Transport"><Select value={transport} onValueChange={(v) => setTransport(v as typeof transport)} options={TRANSPORT_OPTIONS.filter((o) => transports.includes(o.value))} /></Field>190 </div>191 <Field label={transport === 'stdio' ? 'Command' : 'URL'} error={target && !urlOk ? 'Enter an http(s) URL.' : null}>192 <Input value={target} onChange={(e) => setTarget(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" className="font-mono" placeholder={transport === 'stdio' ? 'npx' : 'https://'} data-testid="mcp-target" />193 </Field>194 {transport === 'stdio' ? (195 <>196 <Field label="Arguments" hint="One per line. Passed as an argument array, never through a shell.">197 <textarea value={args} onChange={(e) => setArgs(e.target.value)} rows={3} spellCheck={false} autoCapitalize="off" className="w-full rounded-sm border border-line-2 bg-bg px-2.5 py-2 font-mono text-xs text-text hover:border-line-3 focus:border-accent focus:outline-none" />198 </Field>199 <SecretRows label="Environment variables" rows={env} onChange={setEnv} keyPlaceholder="API_KEY" />200 </>201 ) : canHeaders ? (202 <SecretRows label="Headers" rows={headers} onChange={setHeaders} keyPlaceholder="Authorization" />203 ) : null}204 {canProjectScope && <div className="grid gap-4 sm:grid-cols-2">205 <Field label="Scope"><Select value={scope} onValueChange={(v) => setScope(v as typeof scope)} options={[{ value: 'user', label: 'User (all projects)' }, { value: 'project', label: 'Project' }]} /></Field>206 {scope === 'project' && <Field label="Project"><Select value={projectId} onValueChange={setProjectId} placeholder="Select a project" options={projects.map((p) => ({ value: p.id, label: p.name }))} /></Field>}207 </div>}208 </div>209 </Dialog>210 );211}212