SPB Git

Repository Public

page.tsx 12.7 KB 212 lines tsx

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
Files main
Menu

Theme