@bibobonking/opensol 3.1.0 → 3.1.2
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.
- package/CHANGELOG.md +4 -1
- package/README.md +2 -2
- package/desktop/src-tauri/Cargo.toml +1 -1
- package/dist/assets/index-CeN54A2N.css +1 -0
- package/dist/assets/index-fhIO9a7_.js +318 -0
- package/dist/index.html +4 -4
- package/index.html +2 -2
- package/package.json +1 -1
- package/server/README.md +2 -2
- package/server/daemon.mjs +76 -1
- package/server/lib/mcp-oauth.mjs +219 -0
- package/server/lib/mcp-oauth.test.mjs +175 -0
- package/server/lib/mcp.mjs +404 -37
- package/src/App.tsx +2 -0
- package/src/components/CommandPalette.tsx +14 -1
- package/src/components/Logo.tsx +1 -1
- package/src/components/McpPanel.tsx +687 -0
- package/src/components/ModelDeck.tsx +12 -3
- package/src/components/SettingsModal.tsx +43 -5
- package/src/components/Sidebar.tsx +2 -1
- package/src/components/Splash.tsx +1 -1
- package/src/data/providers.ts +1 -1
- package/src/lib/daemon.ts +10 -1
- package/src/lib/mcp-config.test.mjs +47 -0
- package/src/lib/mcp-config.ts +203 -0
- package/src/store/mcp.ts +28 -1
- package/src/store/settings.ts +1 -1
- package/src/store/ui.ts +4 -0
- package/src/types.ts +14 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-DdI1_iPD.css +0 -1
- package/dist/assets/index-egG5Bgfo.js +0 -312
- package/src/components/McpTab.tsx +0 -337
|
@@ -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
|
+
}
|