@bibobonking/opensol 3.1.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,687 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import {
3
+ ChevronLeft,
4
+ ExternalLink,
5
+ FileJson,
6
+ Globe,
7
+ KeyRound,
8
+ LogOut,
9
+ Pencil,
10
+ Plug,
11
+ Plus,
12
+ Radio,
13
+ RefreshCw,
14
+ Search,
15
+ ShieldCheck,
16
+ Terminal,
17
+ Trash2,
18
+ Unplug,
19
+ Wrench,
20
+ X,
21
+ } from 'lucide-react'
22
+ import type { McpServer, McpTransport } from '../types'
23
+ import { useMcp } from '../store/mcp'
24
+ import { useUi } from '../store/ui'
25
+ import { cn } from '../lib/utils'
26
+ import {
27
+ EMPTY_DRAFT,
28
+ MCP_PRESETS,
29
+ draftFromServer,
30
+ draftIsValid,
31
+ inputFromDraft,
32
+ inputFromServer,
33
+ parseMcpJson,
34
+ type McpDraft,
35
+ type McpServerInput,
36
+ } from '../lib/mcp-config'
37
+ import { Modal, Segmented, Spinner, Switch } from './ui'
38
+
39
+ const field =
40
+ 'h-9 w-full rounded-xl border border-line bg-panel px-3 text-[12px] text-ink outline-none transition-all placeholder:text-faint focus:border-accent/50 focus:shadow-[0_0_0_3px_rgb(var(--accent)/0.10)] disabled:opacity-50'
41
+
42
+ const area = field.replace('h-9 ', '')
43
+
44
+ const TRANSPORTS: Array<{ value: McpTransport; label: string; hint: string }> = [
45
+ { value: 'stdio', label: 'Local · stdio', hint: 'Runs a command on this computer' },
46
+ { value: 'http', label: 'Remote · HTTP', hint: 'Streamable HTTP endpoint' },
47
+ { value: 'sse', label: 'Remote · SSE', hint: 'Legacy HTTP+SSE endpoint' },
48
+ ]
49
+
50
+ const TRANSPORT_LABEL: Record<McpTransport, string> = { stdio: 'stdio', http: 'HTTP', sse: 'SSE' }
51
+
52
+ type View =
53
+ | { kind: 'server'; id: string }
54
+ | { kind: 'new'; draft: McpDraft }
55
+ | { kind: 'edit'; draft: McpDraft }
56
+ | { kind: 'import' }
57
+
58
+ function TransportIcon({ transport, size = 13 }: { transport: McpTransport; size?: number }) {
59
+ const Icon = transport === 'stdio' ? Terminal : transport === 'sse' ? Radio : Globe
60
+ return <Icon size={size} strokeWidth={1.7} />
61
+ }
62
+
63
+ function StatusDot({ status }: { status: McpServer['status'] }) {
64
+ return (
65
+ <span
66
+ className={cn(
67
+ 'h-2 w-2 shrink-0 rounded-full',
68
+ status === 'connected' && 'bg-success shadow-[0_0_0_3px_rgb(var(--success)/0.18)]',
69
+ status === 'connecting' && 'animate-breathe bg-warning',
70
+ status === 'needs_auth' && 'bg-warning shadow-[0_0_0_3px_rgb(var(--warning)/0.18)]',
71
+ status === 'error' && 'bg-danger',
72
+ (status === 'disconnected' || status === 'disabled') && 'bg-faint/60',
73
+ )}
74
+ />
75
+ )
76
+ }
77
+
78
+ function statusText(server: McpServer) {
79
+ switch (server.status) {
80
+ case 'connected':
81
+ return `${server.tools.length} tool${server.tools.length === 1 ? '' : 's'}${server.serverInfo?.name ? ` · ${server.serverInfo.name} ${server.serverInfo.version ?? ''}` : ''}`
82
+ case 'connecting':
83
+ return 'Connecting…'
84
+ case 'needs_auth':
85
+ return 'Sign-in required'
86
+ case 'error':
87
+ return server.error || 'Connection failed'
88
+ case 'disabled':
89
+ return 'Turned off'
90
+ default:
91
+ return 'Not connected'
92
+ }
93
+ }
94
+
95
+ export function McpPanel() {
96
+ const open = useUi((state) => state.mcpOpen)
97
+ const setOpen = useUi((state) => state.setMcpOpen)
98
+ const notify = useUi((state) => state.notify)
99
+ const askConfirm = useUi((state) => state.askConfirm)
100
+ const servers = useMcp((state) => state.servers)
101
+ const loaded = useMcp((state) => state.loaded)
102
+ const error = useMcp((state) => state.error)
103
+ const load = useMcp((state) => state.load)
104
+ const save = useMcp((state) => state.save)
105
+ const act = useMcp((state) => state.act)
106
+ const authorize = useMcp((state) => state.authorize)
107
+ const logout = useMcp((state) => state.logout)
108
+ const [view, setView] = useState<View | null>(null)
109
+ const [mobileDetail, setMobileDetail] = useState(false)
110
+ const [busy, setBusy] = useState(false)
111
+ const [signingIn, setSigningIn] = useState<string | null>(null)
112
+
113
+ useEffect(() => {
114
+ if (open) void load()
115
+ else {
116
+ setView(null)
117
+ setMobileDetail(false)
118
+ }
119
+ }, [open, load])
120
+
121
+ const current: View = useMemo(() => {
122
+ if (view && !(view.kind === 'server' && !servers.some((server) => server.id === view.id))) return view
123
+ return servers[0] ? { kind: 'server', id: servers[0].id } : { kind: 'new', draft: { ...EMPTY_DRAFT } }
124
+ }, [view, servers])
125
+ const selected = current.kind === 'server' ? servers.find((server) => server.id === current.id) : undefined
126
+
127
+ const close = () => setOpen(false)
128
+ const show = (next: View) => {
129
+ setView(next)
130
+ setMobileDetail(true)
131
+ }
132
+
133
+ const persist = async (next: McpServerInput[], message?: string): Promise<boolean> => {
134
+ setBusy(true)
135
+ try {
136
+ await save(next)
137
+ if (message) notify(message, 'success')
138
+ return true
139
+ } catch (failure) {
140
+ notify(failure instanceof Error ? failure.message : 'Could not save MCP servers.', 'error')
141
+ return false
142
+ } finally {
143
+ setBusy(false)
144
+ }
145
+ }
146
+
147
+ const patch = (id: string, change: Partial<McpServerInput>) =>
148
+ persist(servers.map((server) => (server.id === id ? { ...inputFromServer(server), ...change } : inputFromServer(server))))
149
+
150
+ const saveDraft = async (draft: McpDraft, isNew: boolean) => {
151
+ const known = new Set(servers.map((server) => server.id))
152
+ const entry = inputFromDraft(draft, servers.find((server) => server.id === draft.id))
153
+ const next = isNew
154
+ ? [...servers.map(inputFromServer), entry]
155
+ : servers.map((server) => (server.id === draft.id ? entry : inputFromServer(server)))
156
+ if (!(await persist(next, isNew ? `Added ${entry.name}. Connecting…` : `Saved ${entry.name}.`))) return
157
+ const created = useMcp.getState().servers.find((server) => !known.has(server.id))
158
+ const id = isNew ? created?.id : draft.id
159
+ if (id) setView({ kind: 'server', id })
160
+ }
161
+
162
+ const importServers = async (incoming: McpServerInput[]) => {
163
+ const known = new Set(servers.map((server) => server.id))
164
+ const ok = await persist([...servers.map(inputFromServer), ...incoming], `Imported ${incoming.length} server${incoming.length === 1 ? '' : 's'}.`)
165
+ if (!ok) return
166
+ const created = useMcp.getState().servers.find((server) => !known.has(server.id))
167
+ setView(created ? { kind: 'server', id: created.id } : null)
168
+ }
169
+
170
+ const remove = async (server: McpServer) => {
171
+ const ok = await askConfirm({
172
+ title: `Remove ${server.name}?`,
173
+ body: 'The server is disconnected, its saved sign-in is deleted and its tools disappear from the agent.',
174
+ confirmLabel: 'Remove server',
175
+ tone: 'danger',
176
+ })
177
+ if (!ok) return
178
+ if (await persist(servers.filter((item) => item.id !== server.id).map(inputFromServer), `Removed ${server.name}.`)) {
179
+ setView(null)
180
+ setMobileDetail(false)
181
+ }
182
+ }
183
+
184
+ const startSignIn = async (server: McpServer) => {
185
+ setSigningIn(server.id)
186
+ try {
187
+ await authorize(server.id)
188
+ notify(`Finish signing in to ${server.name} in the new window.`, 'info')
189
+ } catch (failure) {
190
+ notify(failure instanceof Error ? failure.message : 'Could not start sign-in.', 'error')
191
+ } finally {
192
+ setSigningIn(null)
193
+ }
194
+ }
195
+
196
+ const connectedCount = servers.filter((server) => server.status === 'connected').length
197
+ const toolCount = servers.reduce((sum, server) => sum + (server.status === 'connected' ? server.tools.length - server.disabledTools.length : 0), 0)
198
+
199
+ return (
200
+ <Modal
201
+ open={open}
202
+ onClose={close}
203
+ labelledBy="mcp-title"
204
+ className="settings-shell h-[94dvh] p-0 sm:h-[min(88dvh,780px)] sm:w-[min(1080px,96vw)] sm:!rounded-[28px]"
205
+ >
206
+ <header className="relative flex shrink-0 items-end gap-3 border-b border-line bg-canvas px-5 pb-4 pt-5 sm:px-7">
207
+ <div className="settings-glow pointer-events-none absolute inset-x-0 top-0 h-32" />
208
+ <div className="relative min-w-0 flex-1">
209
+ <p className="font-mono text-[10px] uppercase tracking-[0.22em] text-faint">Model Context Protocol</p>
210
+ <h2 id="mcp-title" className="mt-1 font-display text-[26px] font-semibold leading-none tracking-[-0.035em] text-ink">
211
+ MCP servers
212
+ </h2>
213
+ <p className="mt-1.5 text-[11.5px] text-muted">
214
+ {servers.length === 0
215
+ ? 'Give the agent new tools from local programs or remote services.'
216
+ : `${connectedCount} of ${servers.length} connected · ${toolCount} tool${toolCount === 1 ? '' : 's'} available to the agent`}
217
+ </p>
218
+ </div>
219
+ <div className="relative flex shrink-0 items-center gap-1.5">
220
+ <button type="button" className="chip hidden h-8 rounded-full px-3.5 text-[11.5px] sm:inline-flex" onClick={() => show({ kind: 'import' })}>
221
+ <FileJson size={13} /> Import JSON
222
+ </button>
223
+ <button
224
+ type="button"
225
+ className="inline-flex h-8 items-center gap-1.5 rounded-full bg-ink px-3.5 text-[11.5px] font-medium text-panel transition-transform active:scale-95"
226
+ onClick={() => show({ kind: 'new', draft: { ...EMPTY_DRAFT } })}
227
+ >
228
+ <Plus size={13} /> Add server
229
+ </button>
230
+ <button type="button" className="icon-button h-8 w-8 rounded-full" onClick={close} aria-label="Close MCP panel">
231
+ <X size={16} />
232
+ </button>
233
+ </div>
234
+ </header>
235
+
236
+ <div className="flex min-h-0 flex-1">
237
+ {/* Server list */}
238
+ <aside className={cn('thin-scroll min-h-0 w-full shrink-0 overflow-y-auto bg-canvas/60 p-3 md:block md:w-[300px] md:border-r md:border-line', mobileDetail ? 'hidden' : 'block')}>
239
+ {error && <p className="mb-2 rounded-xl border border-danger/25 bg-danger/[0.05] px-3 py-2 text-[11.5px] text-danger">{error}</p>}
240
+ {!loaded && (
241
+ <p className="flex items-center gap-2 px-2 py-3 text-[12px] text-faint">
242
+ <Spinner size={12} /> Loading servers…
243
+ </p>
244
+ )}
245
+ {loaded && servers.length === 0 && (
246
+ <p className="rounded-2xl border border-dashed border-line px-4 py-8 text-center text-[12px] text-muted">No servers yet.</p>
247
+ )}
248
+ <ul className="space-y-1">
249
+ {servers.map((server) => {
250
+ const active = current.kind === 'server' && current.id === server.id
251
+ return (
252
+ <li key={server.id}>
253
+ <button
254
+ type="button"
255
+ onClick={() => show({ kind: 'server', id: server.id })}
256
+ className={cn(
257
+ 'flex w-full items-center gap-3 rounded-2xl border px-3 py-2.5 text-left transition-all',
258
+ active ? 'border-line bg-panel shadow-pill' : 'border-transparent hover:bg-raised/70',
259
+ )}
260
+ >
261
+ <StatusDot status={server.status} />
262
+ <span className="min-w-0 flex-1">
263
+ <span className="flex items-center gap-1.5 text-[13px] font-medium text-ink">
264
+ <span className="truncate">{server.name}</span>
265
+ {server.oauth?.enabled && (
266
+ <KeyRound size={11} className={cn('shrink-0', server.oauth.authorized ? 'text-success' : 'text-warning')} aria-label="OAuth" />
267
+ )}
268
+ </span>
269
+ <span className={cn('mt-0.5 block truncate text-[11px]', server.status === 'needs_auth' ? 'text-warning' : server.status === 'error' ? 'text-danger' : 'text-muted')}>
270
+ {statusText(server)}
271
+ </span>
272
+ </span>
273
+ <span className="flex shrink-0 items-center gap-1 rounded-full bg-raised px-2 py-0.5 font-mono text-[9.5px] text-faint">
274
+ <TransportIcon transport={server.transport} size={10} />
275
+ {TRANSPORT_LABEL[server.transport]}
276
+ </span>
277
+ </button>
278
+ </li>
279
+ )
280
+ })}
281
+ </ul>
282
+ <button type="button" className="chip mt-3 h-8 w-full justify-center rounded-xl text-[11.5px] sm:hidden" onClick={() => show({ kind: 'import' })}>
283
+ <FileJson size={13} /> Import JSON
284
+ </button>
285
+ </aside>
286
+
287
+ {/* Detail */}
288
+ <section className={cn('min-w-0 flex-1 flex-col bg-panel md:flex', mobileDetail ? 'flex' : 'hidden')}>
289
+ <button type="button" className="flex h-10 shrink-0 items-center gap-1 border-b border-line px-3 text-[12px] text-muted md:hidden" onClick={() => setMobileDetail(false)}>
290
+ <ChevronLeft size={15} /> All servers
291
+ </button>
292
+ <div className="thin-scroll min-h-0 flex-1 overflow-y-auto">
293
+ {!loaded ? null : current.kind === 'new' ? (
294
+ <ServerForm
295
+ key={`new-${JSON.stringify(current.draft)}`}
296
+ title="New MCP server"
297
+ initial={current.draft}
298
+ busy={busy}
299
+ presets
300
+ submitLabel="Save and connect"
301
+ onCancel={servers.length > 0 ? () => setView(null) : undefined}
302
+ onSubmit={(draft) => void saveDraft(draft, true)}
303
+ />
304
+ ) : current.kind === 'edit' ? (
305
+ <ServerForm
306
+ key={`edit-${current.draft.id}`}
307
+ title={`Edit ${current.draft.name}`}
308
+ initial={current.draft}
309
+ busy={busy}
310
+ submitLabel="Save changes"
311
+ onCancel={() => setView({ kind: 'server', id: current.draft.id ?? '' })}
312
+ onSubmit={(draft) => void saveDraft(draft, false)}
313
+ />
314
+ ) : current.kind === 'import' ? (
315
+ <ImportView busy={busy} onCancel={() => setView(null)} onImport={(items) => void importServers(items)} />
316
+ ) : selected ? (
317
+ <ServerDetail
318
+ server={selected}
319
+ signingIn={signingIn === selected.id}
320
+ onEdit={() => show({ kind: 'edit', draft: draftFromServer(selected) })}
321
+ onPatch={(change) => void patch(selected.id, change)}
322
+ onAct={(action) => void act(selected.id, action)}
323
+ onAuthorize={() => void startSignIn(selected)}
324
+ onSignOut={() => void logout(selected.id).catch((failure) => notify(failure instanceof Error ? failure.message : 'Could not sign out.', 'error'))}
325
+ onRemove={() => void remove(selected)}
326
+ />
327
+ ) : null}
328
+ </div>
329
+ </section>
330
+ </div>
331
+ </Modal>
332
+ )
333
+ }
334
+
335
+ function ServerDetail({
336
+ server,
337
+ signingIn,
338
+ onEdit,
339
+ onPatch,
340
+ onAct,
341
+ onAuthorize,
342
+ onSignOut,
343
+ onRemove,
344
+ }: {
345
+ server: McpServer
346
+ signingIn: boolean
347
+ onEdit: () => void
348
+ onPatch: (change: Partial<McpServerInput>) => void
349
+ onAct: (action: 'connect' | 'disconnect' | 'refresh') => void
350
+ onAuthorize: () => void
351
+ onSignOut: () => void
352
+ onRemove: () => void
353
+ }) {
354
+ const [query, setQuery] = useState('')
355
+ const live = server.status === 'connected' || server.status === 'connecting'
356
+ const tools = server.tools.filter((tool) => !query.trim() || `${tool.name} ${tool.description}`.toLowerCase().includes(query.trim().toLowerCase()))
357
+ const allOff = server.tools.length > 0 && server.disabledTools.length >= server.tools.length
358
+
359
+ return (
360
+ <div className="space-y-5 px-5 py-5 sm:px-7 sm:py-6">
361
+ <div className="flex flex-wrap items-start gap-3">
362
+ <div className="min-w-0 flex-1">
363
+ <h3 className="flex items-center gap-2.5 font-display text-[22px] font-semibold leading-tight tracking-[-0.03em] text-ink">
364
+ <StatusDot status={server.status} />
365
+ <span className="truncate">{server.name}</span>
366
+ </h3>
367
+ <p className={cn('mt-1 text-[12px]', server.status === 'error' ? 'text-danger' : server.status === 'needs_auth' ? 'text-warning' : 'text-muted')}>
368
+ {statusText(server)}
369
+ </p>
370
+ </div>
371
+ <div className="flex shrink-0 items-center gap-1.5">
372
+ {live ? (
373
+ <>
374
+ <button type="button" className="icon-button h-8 w-8" title="Reload tools" aria-label="Reload tools" onClick={() => onAct('refresh')}>
375
+ <RefreshCw size={14} className={cn(server.status === 'connecting' && 'animate-spin')} />
376
+ </button>
377
+ <button type="button" className="icon-button h-8 w-8" title="Disconnect" aria-label="Disconnect" onClick={() => onAct('disconnect')}>
378
+ <Unplug size={14} />
379
+ </button>
380
+ </>
381
+ ) : (
382
+ server.status !== 'needs_auth' && (
383
+ <button type="button" className="chip h-8 rounded-full px-3.5 text-[11.5px]" disabled={!server.enabled} onClick={() => onAct('connect')}>
384
+ <Plug size={13} /> Connect
385
+ </button>
386
+ )
387
+ )}
388
+ <button type="button" className="icon-button h-8 w-8" title="Edit connection" aria-label="Edit connection" onClick={onEdit}>
389
+ <Pencil size={14} />
390
+ </button>
391
+ <Switch checked={server.enabled} onChange={(enabled) => onPatch({ enabled })} label={`Enable ${server.name}`} />
392
+ </div>
393
+ </div>
394
+
395
+ {server.oauth?.enabled && (
396
+ <div
397
+ className={cn(
398
+ 'flex flex-wrap items-center gap-3 rounded-2xl border px-4 py-3.5',
399
+ server.oauth.authorized ? 'border-success/25 bg-success/[0.05]' : 'border-warning/30 bg-warning/[0.06]',
400
+ )}
401
+ >
402
+ <span className={cn('flex h-9 w-9 shrink-0 items-center justify-center rounded-xl', server.oauth.authorized ? 'bg-success/10 text-success' : 'bg-warning/10 text-warning')}>
403
+ {server.oauth.authorized ? <ShieldCheck size={17} /> : <KeyRound size={17} />}
404
+ </span>
405
+ <div className="min-w-0 flex-1">
406
+ <p className="text-[12.5px] font-medium text-ink">{server.oauth.authorized ? 'Signed in with OAuth' : 'Sign-in required'}</p>
407
+ <p className="mt-0.5 text-[11.5px] leading-relaxed text-muted">
408
+ {server.oauth.authorized
409
+ ? 'Tokens are stored encrypted by the local daemon and refreshed automatically.'
410
+ : 'Approve access in your browser. OpenSol registers itself with the server and keeps the tokens encrypted on this machine.'}
411
+ </p>
412
+ </div>
413
+ {server.oauth.authorized ? (
414
+ <button type="button" className="chip h-8 rounded-full px-3.5 text-[11.5px]" onClick={onSignOut}>
415
+ <LogOut size={13} /> Sign out
416
+ </button>
417
+ ) : (
418
+ <button
419
+ type="button"
420
+ disabled={signingIn || !server.enabled}
421
+ onClick={onAuthorize}
422
+ className="inline-flex h-8 items-center gap-1.5 rounded-full bg-accent px-4 text-[11.5px] font-medium text-on-accent transition-transform active:scale-95 disabled:opacity-40"
423
+ >
424
+ {signingIn ? <Spinner size={12} /> : <ExternalLink size={12} />} Authorize
425
+ </button>
426
+ )}
427
+ </div>
428
+ )}
429
+
430
+ {server.status === 'error' && server.error && (
431
+ <p className="rounded-xl border border-danger/25 bg-danger/[0.05] px-3 py-2 text-[11.5px] leading-relaxed text-danger">{server.error}</p>
432
+ )}
433
+
434
+ <div className="grid gap-3 rounded-2xl border border-line p-4 sm:grid-cols-[1fr_auto] sm:items-center">
435
+ <div className="min-w-0">
436
+ <p className="flex items-center gap-1.5 text-[11px] font-medium text-muted">
437
+ <TransportIcon transport={server.transport} /> {TRANSPORTS.find((item) => item.value === server.transport)?.label}
438
+ </p>
439
+ <p className="mt-1 break-all font-mono text-[11px] text-ink">
440
+ {server.transport === 'stdio' ? `${server.command} ${(server.args ?? []).join(' ')}` : server.url}
441
+ </p>
442
+ </div>
443
+ <div className="flex items-center gap-2">
444
+ <span className="text-[11px] text-muted">Tool calls</span>
445
+ <Segmented
446
+ options={[
447
+ { value: 'ask', label: 'Ask first' },
448
+ { value: 'auto', label: 'Auto-run' },
449
+ ]}
450
+ value={server.approval}
451
+ onChange={(approval) => onPatch({ approval })}
452
+ />
453
+ </div>
454
+ </div>
455
+
456
+ {server.tools.length > 0 && (
457
+ <div>
458
+ <div className="mb-2 flex items-center gap-2">
459
+ <p className="mr-auto text-[12px] font-medium text-ink">
460
+ Tools <span className="font-normal text-faint">{server.tools.length - server.disabledTools.length}/{server.tools.length} on</span>
461
+ </p>
462
+ <button type="button" className="text-[11px] font-medium text-muted transition-colors hover:text-ink" onClick={() => onPatch({ disabledTools: allOff ? [] : server.tools.map((tool) => tool.name) })}>
463
+ {allOff ? 'Enable all' : 'Disable all'}
464
+ </button>
465
+ </div>
466
+ {server.tools.length > 6 && (
467
+ <div className="relative mb-2">
468
+ <Search size={13} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-faint" />
469
+ <input className={cn(field, 'pl-8')} placeholder="Filter tools" value={query} onChange={(event) => setQuery(event.target.value)} />
470
+ </div>
471
+ )}
472
+ <ul className="divide-y divide-line rounded-xl border border-line">
473
+ {tools.map((tool) => {
474
+ const off = server.disabledTools.includes(tool.name)
475
+ return (
476
+ <li key={tool.name} className="flex items-center gap-3 px-3 py-2">
477
+ <Wrench size={12} className={cn('shrink-0', off ? 'text-faint' : 'text-accent')} />
478
+ <div className="min-w-0 flex-1">
479
+ <p className={cn('truncate font-mono text-[11.5px]', off ? 'text-faint line-through' : 'text-ink')}>{tool.name}</p>
480
+ {tool.description && <p className="truncate text-[10.5px] text-muted">{tool.description}</p>}
481
+ </div>
482
+ {tool.readOnly && <span className="rounded-full bg-success/10 px-1.5 py-px text-[9px] text-success">read-only</span>}
483
+ <Switch
484
+ checked={!off}
485
+ label={`Enable ${tool.name}`}
486
+ onChange={(on) => onPatch({ disabledTools: on ? server.disabledTools.filter((name) => name !== tool.name) : [...server.disabledTools, tool.name] })}
487
+ />
488
+ </li>
489
+ )
490
+ })}
491
+ {tools.length === 0 && <li className="px-3 py-4 text-center text-[11.5px] text-faint">No tools match.</li>}
492
+ </ul>
493
+ </div>
494
+ )}
495
+
496
+ <div className="flex justify-end">
497
+ <button type="button" onClick={onRemove} className="inline-flex items-center gap-1.5 text-[11.5px] font-medium text-faint transition-colors hover:text-danger">
498
+ <Trash2 size={12} /> Remove server
499
+ </button>
500
+ </div>
501
+ </div>
502
+ )
503
+ }
504
+
505
+ function ServerForm({
506
+ title,
507
+ initial,
508
+ busy,
509
+ presets = false,
510
+ submitLabel,
511
+ onCancel,
512
+ onSubmit,
513
+ }: {
514
+ title: string
515
+ initial: McpDraft
516
+ busy: boolean
517
+ presets?: boolean
518
+ submitLabel: string
519
+ onCancel?: () => void
520
+ onSubmit: (draft: McpDraft) => void
521
+ }) {
522
+ const [draft, setDraft] = useState<McpDraft>(initial)
523
+ const [advanced, setAdvanced] = useState(Boolean(initial.clientId || initial.scope))
524
+ const remote = draft.transport !== 'stdio'
525
+ const set = (change: Partial<McpDraft>) => setDraft((state) => ({ ...state, ...change }))
526
+
527
+ return (
528
+ <form
529
+ className="space-y-4 px-5 py-5 sm:px-7 sm:py-6"
530
+ onSubmit={(event) => {
531
+ event.preventDefault()
532
+ if (draftIsValid(draft) && !busy) onSubmit(draft)
533
+ }}
534
+ >
535
+ <h3 className="font-display text-[22px] font-semibold leading-tight tracking-[-0.03em] text-ink">{title}</h3>
536
+
537
+ {presets && (
538
+ <div>
539
+ <p className="mb-2 text-[11px] font-medium text-muted">Start from a template</p>
540
+ <div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
541
+ {MCP_PRESETS.map((preset) => (
542
+ <button
543
+ key={preset.label}
544
+ type="button"
545
+ onClick={() => setDraft({ ...EMPTY_DRAFT, ...preset.draft })}
546
+ className="rounded-xl border border-line px-3 py-2 text-left transition-all hover:border-accent/40 hover:bg-raised/60 active:scale-[0.98]"
547
+ >
548
+ <span className="block text-[12px] font-medium text-ink">{preset.label}</span>
549
+ <span className="mt-0.5 block truncate text-[10.5px] text-muted">{preset.description}</span>
550
+ </button>
551
+ ))}
552
+ </div>
553
+ </div>
554
+ )}
555
+
556
+ <div className="grid gap-3">
557
+ <input data-autofocus className={field} placeholder="Name (e.g. GitHub)" value={draft.name} onChange={(event) => set({ name: event.target.value })} />
558
+ <div>
559
+ <Segmented options={TRANSPORTS.map(({ value, label }) => ({ value, label }))} value={draft.transport} onChange={(transport) => set({ transport })} size="md" />
560
+ <p className="mt-1.5 text-[11px] text-faint">{TRANSPORTS.find((item) => item.value === draft.transport)?.hint}</p>
561
+ </div>
562
+ </div>
563
+
564
+ {!remote ? (
565
+ <>
566
+ <div className="grid gap-3 sm:grid-cols-[160px_1fr]">
567
+ <input className={cn(field, 'font-mono')} placeholder="command" value={draft.command} onChange={(event) => set({ command: event.target.value })} />
568
+ <input className={cn(field, 'font-mono')} placeholder="arguments" value={draft.args} onChange={(event) => set({ args: event.target.value })} />
569
+ </div>
570
+ <textarea className={cn(area, 'h-20 resize-none py-2 font-mono')} placeholder={'Environment, one per line\nAPI_KEY=…'} value={draft.env} onChange={(event) => set({ env: event.target.value })} />
571
+ <p className="text-[11px] leading-relaxed text-faint">Local servers run as a process on this computer with your permissions, so only add servers you trust.</p>
572
+ </>
573
+ ) : (
574
+ <>
575
+ <input className={cn(field, 'font-mono')} placeholder={draft.transport === 'sse' ? 'https://example.com/sse' : 'https://example.com/mcp'} value={draft.url} onChange={(event) => set({ url: event.target.value })} />
576
+ <div className="rounded-2xl border border-line p-3.5">
577
+ <div className="flex items-center gap-3">
578
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent">
579
+ <KeyRound size={14} />
580
+ </span>
581
+ <div className="min-w-0 flex-1">
582
+ <p className="text-[12.5px] font-medium text-ink">Sign in with OAuth</p>
583
+ <p className="text-[11px] text-muted">For services that ask you to approve access in the browser (Linear, Notion, Sentry…).</p>
584
+ </div>
585
+ <Switch checked={draft.oauth} onChange={(oauth) => set({ oauth })} label="Sign in with OAuth" />
586
+ </div>
587
+ {draft.oauth && (
588
+ <div className="mt-3 border-t border-line pt-3">
589
+ <button type="button" className="text-[11px] font-medium text-muted transition-colors hover:text-ink" onClick={() => setAdvanced((value) => !value)}>
590
+ {advanced ? 'Hide' : 'Show'} advanced client settings
591
+ </button>
592
+ {advanced && (
593
+ <div className="mt-2.5 grid gap-2.5 sm:grid-cols-2">
594
+ <input className={cn(field, 'font-mono')} placeholder="Client ID (optional)" value={draft.clientId} onChange={(event) => set({ clientId: event.target.value })} />
595
+ <input className={cn(field, 'font-mono')} type="password" autoComplete="off" placeholder="Client secret (optional)" value={draft.clientSecret} onChange={(event) => set({ clientSecret: event.target.value })} />
596
+ <input className={cn(field, 'font-mono sm:col-span-2')} placeholder="Scopes (optional, space separated)" value={draft.scope} onChange={(event) => set({ scope: event.target.value })} />
597
+ <p className="text-[10.5px] leading-relaxed text-faint sm:col-span-2">
598
+ Leave the client empty to register automatically. Redirect URI to allow: <span className="font-mono">{`${window.location.origin}/oauth/mcp/callback`}</span>
599
+ </p>
600
+ </div>
601
+ )}
602
+ </div>
603
+ )}
604
+ </div>
605
+ <textarea className={cn(area, 'h-20 resize-none py-2 font-mono')} placeholder={'Headers, one per line (API keys, bearer tokens)\nAuthorization: Bearer …'} value={draft.headers} onChange={(event) => set({ headers: event.target.value })} />
606
+ </>
607
+ )}
608
+
609
+ <div className="flex justify-end gap-2 pt-1">
610
+ {onCancel && (
611
+ <button type="button" className="chip h-8 rounded-full px-4" onClick={onCancel}>
612
+ Cancel
613
+ </button>
614
+ )}
615
+ <button
616
+ type="submit"
617
+ disabled={busy || !draftIsValid(draft)}
618
+ className="inline-flex h-8 items-center gap-1.5 rounded-full bg-accent px-4 text-[11.5px] font-medium text-on-accent transition-transform active:scale-95 disabled:opacity-40"
619
+ >
620
+ {busy && <Spinner size={11} />} {submitLabel}
621
+ </button>
622
+ </div>
623
+ </form>
624
+ )
625
+ }
626
+
627
+ function ImportView({ busy, onCancel, onImport }: { busy: boolean; onCancel: () => void; onImport: (servers: McpServerInput[]) => void }) {
628
+ const [text, setText] = useState('')
629
+ const result = useMemo(() => {
630
+ if (!text.trim()) return null
631
+ try {
632
+ return { ...parseMcpJson(text), error: '' }
633
+ } catch (failure) {
634
+ return { servers: [] as McpServerInput[], skipped: [] as string[], error: failure instanceof Error ? failure.message : 'Could not read that JSON.' }
635
+ }
636
+ }, [text])
637
+
638
+ return (
639
+ <div className="space-y-4 px-5 py-5 sm:px-7 sm:py-6">
640
+ <div>
641
+ <h3 className="font-display text-[22px] font-semibold leading-tight tracking-[-0.03em] text-ink">Import from JSON</h3>
642
+ <p className="mt-1 text-[12px] leading-relaxed text-muted">
643
+ Paste the <span className="font-mono">mcpServers</span> block from Claude Desktop, Cursor or VS Code. Local commands and remote URLs (HTTP or SSE) both work.
644
+ </p>
645
+ </div>
646
+ <textarea
647
+ data-autofocus
648
+ className={cn(area, 'h-48 resize-none py-2.5 font-mono text-[11.5px]')}
649
+ placeholder={'{\n "mcpServers": {\n "github": { "url": "https://api.githubcopilot.com/mcp/" },\n "files": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }\n }\n}'}
650
+ value={text}
651
+ onChange={(event) => setText(event.target.value)}
652
+ spellCheck={false}
653
+ />
654
+ {result?.error && <p className="rounded-xl border border-danger/25 bg-danger/[0.05] px-3 py-2 text-[11.5px] text-danger">{result.error}</p>}
655
+ {result && result.servers.length > 0 && (
656
+ <ul className="divide-y divide-line rounded-xl border border-line">
657
+ {result.servers.map((server) => (
658
+ <li key={server.name} className="flex items-center gap-3 px-3 py-2">
659
+ <span className="text-faint">
660
+ <TransportIcon transport={server.transport ?? 'stdio'} />
661
+ </span>
662
+ <span className="min-w-0 flex-1">
663
+ <span className="block truncate text-[12px] font-medium text-ink">{server.name}</span>
664
+ <span className="block truncate font-mono text-[10.5px] text-muted">{server.transport === 'stdio' ? `${server.command} ${(server.args ?? []).join(' ')}` : server.url}</span>
665
+ </span>
666
+ {server.oauth?.enabled && <span className="rounded-full bg-accent-soft px-2 py-px text-[9.5px] text-accent">OAuth</span>}
667
+ </li>
668
+ ))}
669
+ </ul>
670
+ )}
671
+ {result && result.skipped.length > 0 && <p className="text-[11px] text-faint">Skipped (no command or url): {result.skipped.join(', ')}</p>}
672
+ <div className="flex justify-end gap-2">
673
+ <button type="button" className="chip h-8 rounded-full px-4" onClick={onCancel}>
674
+ Cancel
675
+ </button>
676
+ <button
677
+ type="button"
678
+ disabled={busy || !result || result.servers.length === 0}
679
+ onClick={() => result && onImport(result.servers)}
680
+ className="inline-flex h-8 items-center gap-1.5 rounded-full bg-accent px-4 text-[11.5px] font-medium text-on-accent transition-transform active:scale-95 disabled:opacity-40"
681
+ >
682
+ {busy && <Spinner size={11} />} Import {result && result.servers.length > 0 ? `${result.servers.length} server${result.servers.length === 1 ? '' : 's'}` : ''}
683
+ </button>
684
+ </div>
685
+ </div>
686
+ )
687
+ }