@mohou/panel 1.0.20
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/README.md +8 -0
- package/lib/types/chrome/hue.d.ts +3 -0
- package/lib/types/chrome/hue.d.ts.map +1 -0
- package/lib/types/gallery/card-style.d.ts +8 -0
- package/lib/types/gallery/card-style.d.ts.map +1 -0
- package/lib/types/gallery/client.d.ts +20 -0
- package/lib/types/gallery/client.d.ts.map +1 -0
- package/lib/types/gallery/desk.d.ts +16 -0
- package/lib/types/gallery/desk.d.ts.map +1 -0
- package/lib/types/gallery/list.d.ts +37 -0
- package/lib/types/gallery/list.d.ts.map +1 -0
- package/lib/types/gallery/sort.d.ts +24 -0
- package/lib/types/gallery/sort.d.ts.map +1 -0
- package/lib/types/gallery/state.d.ts +90 -0
- package/lib/types/gallery/state.d.ts.map +1 -0
- package/lib/types/gallery/tabs.d.ts +45 -0
- package/lib/types/gallery/tabs.d.ts.map +1 -0
- package/lib/types/gallery/view.d.ts +57 -0
- package/lib/types/gallery/view.d.ts.map +1 -0
- package/lib/types/history/client.d.ts +25 -0
- package/lib/types/history/client.d.ts.map +1 -0
- package/lib/types/history/state.d.ts +39 -0
- package/lib/types/history/state.d.ts.map +1 -0
- package/lib/types/history/view.d.ts +15 -0
- package/lib/types/history/view.d.ts.map +1 -0
- package/lib/types/index.d.ts +24 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/labels.d.ts +267 -0
- package/lib/types/labels.d.ts.map +1 -0
- package/lib/types/settings/author-mcp-view.d.ts +15 -0
- package/lib/types/settings/author-mcp-view.d.ts.map +1 -0
- package/lib/types/settings/author-mcp.d.ts +4 -0
- package/lib/types/settings/author-mcp.d.ts.map +1 -0
- package/lib/types/settings/client.d.ts +198 -0
- package/lib/types/settings/client.d.ts.map +1 -0
- package/lib/types/settings/credentials-view.d.ts +15 -0
- package/lib/types/settings/credentials-view.d.ts.map +1 -0
- package/lib/types/settings/form.d.ts +27 -0
- package/lib/types/settings/form.d.ts.map +1 -0
- package/lib/types/settings/install-row.d.ts +10 -0
- package/lib/types/settings/install-row.d.ts.map +1 -0
- package/lib/types/settings/mcp-view.d.ts +14 -0
- package/lib/types/settings/mcp-view.d.ts.map +1 -0
- package/lib/types/settings/skill-dirs.d.ts +6 -0
- package/lib/types/settings/skill-dirs.d.ts.map +1 -0
- package/lib/types/settings/skill-install.d.ts +13 -0
- package/lib/types/settings/skill-install.d.ts.map +1 -0
- package/lib/types/settings/state.d.ts +94 -0
- package/lib/types/settings/state.d.ts.map +1 -0
- package/lib/types/settings/update-dialog.d.ts +56 -0
- package/lib/types/settings/update-dialog.d.ts.map +1 -0
- package/lib/types/settings/view.d.ts +26 -0
- package/lib/types/settings/view.d.ts.map +1 -0
- package/lib/types/storage/client.d.ts +15 -0
- package/lib/types/storage/client.d.ts.map +1 -0
- package/lib/types/storage/state.d.ts +42 -0
- package/lib/types/storage/state.d.ts.map +1 -0
- package/lib/types/storage/view.d.ts +16 -0
- package/lib/types/storage/view.d.ts.map +1 -0
- package/lib/types/surface/state.d.ts +42 -0
- package/lib/types/surface/state.d.ts.map +1 -0
- package/lib/types/surface/view.d.ts +34 -0
- package/lib/types/surface/view.d.ts.map +1 -0
- package/lib/types/theme/client.d.ts +45 -0
- package/lib/types/theme/client.d.ts.map +1 -0
- package/lib/types/theme/menu.d.ts +25 -0
- package/lib/types/theme/menu.d.ts.map +1 -0
- package/lib/types/theme/state.d.ts +46 -0
- package/lib/types/theme/state.d.ts.map +1 -0
- package/lib/types/theme/view.d.ts +16 -0
- package/lib/types/theme/view.d.ts.map +1 -0
- package/lib/types/ui/dialog.d.ts +12 -0
- package/lib/types/ui/dialog.d.ts.map +1 -0
- package/lib/types/ui/tooltip.d.ts +12 -0
- package/lib/types/ui/tooltip.d.ts.map +1 -0
- package/package.json +38 -0
- package/src/chrome/hue.ts +6 -0
- package/src/gallery/card-style.ts +27 -0
- package/src/gallery/client.ts +26 -0
- package/src/gallery/desk.tsx +54 -0
- package/src/gallery/list.ts +54 -0
- package/src/gallery/sort.ts +72 -0
- package/src/gallery/state.ts +229 -0
- package/src/gallery/tabs.ts +85 -0
- package/src/gallery/view.tsx +501 -0
- package/src/history/client.ts +27 -0
- package/src/history/state.ts +90 -0
- package/src/history/view.tsx +131 -0
- package/src/index.ts +61 -0
- package/src/labels.ts +536 -0
- package/src/settings/author-mcp-view.tsx +154 -0
- package/src/settings/author-mcp.ts +24 -0
- package/src/settings/client.ts +205 -0
- package/src/settings/credentials-view.tsx +195 -0
- package/src/settings/form.ts +27 -0
- package/src/settings/install-row.tsx +35 -0
- package/src/settings/mcp-view.tsx +793 -0
- package/src/settings/skill-dirs.ts +41 -0
- package/src/settings/skill-install.tsx +134 -0
- package/src/settings/state.ts +225 -0
- package/src/settings/update-dialog.tsx +207 -0
- package/src/settings/view.tsx +722 -0
- package/src/storage/client.ts +16 -0
- package/src/storage/state.ts +94 -0
- package/src/storage/view.tsx +190 -0
- package/src/surface/state.ts +72 -0
- package/src/surface/view.tsx +255 -0
- package/src/theme/client.ts +34 -0
- package/src/theme/menu.tsx +286 -0
- package/src/theme/state.ts +113 -0
- package/src/theme/view.tsx +73 -0
- package/src/ui/dialog.tsx +25 -0
- package/src/ui/tooltip.tsx +19 -0
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import { Settings } from 'lucide-react'
|
|
2
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
import { panelText, type PanelLabelMode, type PanelLocale } from '../labels.ts'
|
|
5
|
+
import { Dialog } from '../ui/dialog.tsx'
|
|
6
|
+
import type { McpCheckResult, McpServerDraft, PanelMcpFailure, PanelSettingsClient } from './client.ts'
|
|
7
|
+
|
|
8
|
+
type Transport = 'stdio' | 'sse' | 'http'
|
|
9
|
+
type CheckMap = Readonly<Record<string, McpCheckResult>>
|
|
10
|
+
|
|
11
|
+
interface Draft {
|
|
12
|
+
readonly id: string
|
|
13
|
+
readonly description: string
|
|
14
|
+
readonly transport: Transport
|
|
15
|
+
readonly command: string
|
|
16
|
+
readonly args: string
|
|
17
|
+
readonly url: string
|
|
18
|
+
readonly env: string
|
|
19
|
+
readonly headers: string
|
|
20
|
+
readonly editing: string | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const emptyDraft: Draft = {
|
|
24
|
+
id: '',
|
|
25
|
+
description: '',
|
|
26
|
+
transport: 'stdio',
|
|
27
|
+
command: '',
|
|
28
|
+
args: '',
|
|
29
|
+
url: '',
|
|
30
|
+
env: '',
|
|
31
|
+
headers: '',
|
|
32
|
+
editing: undefined,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* MCP settings. Cards show status and tools. Add, edit, paste, and import open a dialog.
|
|
37
|
+
* A connection check does not write the file. Import writes only the servers the person keeps.
|
|
38
|
+
* @param props - settings client and chrome locale
|
|
39
|
+
*/
|
|
40
|
+
export function McpSettings(props: {
|
|
41
|
+
readonly client: PanelSettingsClient
|
|
42
|
+
readonly locale: PanelLocale
|
|
43
|
+
readonly mode: PanelLabelMode
|
|
44
|
+
}): ReactNode {
|
|
45
|
+
const label = (key: string) => panelText(props.locale, key, props.mode)
|
|
46
|
+
const [servers, setServers] = useState<McpServerDraft[]>([])
|
|
47
|
+
const [unresolved, setUnresolved] = useState<readonly PanelMcpFailure[]>([])
|
|
48
|
+
const [draft, setDraft] = useState<Draft | undefined>(undefined)
|
|
49
|
+
const [paste, setPaste] = useState(false)
|
|
50
|
+
const [candidates, setCandidates] = useState<readonly McpServerDraft[] | undefined>(undefined)
|
|
51
|
+
const [queue, setQueue] = useState<readonly McpServerDraft[]>([])
|
|
52
|
+
const [removing, setRemoving] = useState<string | undefined>(undefined)
|
|
53
|
+
const [checks, setChecks] = useState<CheckMap>({})
|
|
54
|
+
const [busy, setBusy] = useState('')
|
|
55
|
+
const [notice, setNotice] = useState('')
|
|
56
|
+
const [loadFailed, setLoadFailed] = useState(false)
|
|
57
|
+
const [alert, setAlert] = useState<'import' | 'save' | undefined>(undefined)
|
|
58
|
+
const loaded = useRef(false)
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const list = props.client.listMcp
|
|
61
|
+
if (list === undefined || loaded.current) return
|
|
62
|
+
loaded.current = true
|
|
63
|
+
void list().then(
|
|
64
|
+
(listed) => {
|
|
65
|
+
setServers([...listed.servers])
|
|
66
|
+
setUnresolved([...listed.unresolved])
|
|
67
|
+
},
|
|
68
|
+
() => setLoadFailed(true),
|
|
69
|
+
)
|
|
70
|
+
}, [props.client.listMcp])
|
|
71
|
+
if (props.client.listMcp === undefined) return <p className="text-sm text-muted-foreground">{label('mcp-empty')}</p>
|
|
72
|
+
const write = (next: readonly McpServerDraft[]) => commit(props.client, next, setServers, setUnresolved, setAlert, setNotice, label)
|
|
73
|
+
return (
|
|
74
|
+
<div className="flex flex-col gap-4">
|
|
75
|
+
<div className="flex items-start justify-between gap-3">
|
|
76
|
+
<div>
|
|
77
|
+
<h4 className="m-0">{label('section-mcp')}</h4>
|
|
78
|
+
{servers.length === 0 ? <p className="mt-1 text-xs text-muted-foreground">{label('mcp-empty')}</p> : null}
|
|
79
|
+
{unresolved.length === 0 ? null : <p className="mt-1 text-xs text-destructive">{label('mcp-unresolved-hint')}</p>}
|
|
80
|
+
</div>
|
|
81
|
+
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
|
82
|
+
<button type="button" className="h-8 rounded-lg border bg-card px-3 text-sm" onClick={() => setPaste(true)}>{label('mcp-paste')}</button>
|
|
83
|
+
<button type="button" className="h-8 rounded-lg border bg-card px-3 text-sm" onClick={() => { void pullSource(props.client, 'pi', setCandidates, setAlert) }}>{label('mcp-from-pi')}</button>
|
|
84
|
+
<label className="inline-flex h-8 cursor-pointer items-center rounded-lg border bg-card px-3 text-sm">
|
|
85
|
+
{label('mcp-from-file')}
|
|
86
|
+
<input type="file" accept="application/json,.json" className="sr-only" onChange={(event) => { void readChosen(event.target.files?.[0], props.client, setCandidates, setAlert) }} />
|
|
87
|
+
</label>
|
|
88
|
+
<button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={() => setDraft(emptyDraft)}>{label('mcp-add')}</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
<ul className="grid grid-cols-1 gap-2 min-[720px]:grid-cols-2">
|
|
92
|
+
{servers.map(server => (
|
|
93
|
+
<ServerCard
|
|
94
|
+
key={server.id}
|
|
95
|
+
server={server}
|
|
96
|
+
failure={unresolved.find(item => item.id === server.id)}
|
|
97
|
+
check={checks[server.id]}
|
|
98
|
+
busy={busy === server.id}
|
|
99
|
+
label={label}
|
|
100
|
+
onEdit={() => setDraft(toDraft(server))}
|
|
101
|
+
onRemove={() => setRemoving(server.id)}
|
|
102
|
+
onToggle={() => {
|
|
103
|
+
const next = servers.map(item => item.id === server.id ? { ...item, enabled: item.enabled === false } : item)
|
|
104
|
+
void write(next)
|
|
105
|
+
}}
|
|
106
|
+
onCheck={() => { void runCheck(props.client, server, setChecks, setBusy, setAlert) }}
|
|
107
|
+
/>
|
|
108
|
+
))}
|
|
109
|
+
</ul>
|
|
110
|
+
{loadFailed ? <p className="text-sm text-muted-foreground">{label('mcp-load-failed')}</p> : null}
|
|
111
|
+
{notice.length > 0 ? <p className="text-xs text-muted-foreground">{notice}</p> : null}
|
|
112
|
+
{draft === undefined ? null : (
|
|
113
|
+
<EditorDialog
|
|
114
|
+
draft={draft}
|
|
115
|
+
label={label}
|
|
116
|
+
onChange={setDraft}
|
|
117
|
+
onCancel={() => {
|
|
118
|
+
setDraft(undefined)
|
|
119
|
+
setQueue([])
|
|
120
|
+
}}
|
|
121
|
+
onSubmit={() => {
|
|
122
|
+
void saveDraft(props.client, servers, draft, setServers, setUnresolved, setAlert, setNotice, label).then((ok) => {
|
|
123
|
+
if (!ok) return
|
|
124
|
+
const next = queue[0]
|
|
125
|
+
setQueue(queue.slice(1))
|
|
126
|
+
setDraft(next === undefined ? undefined : openImported(next, servers))
|
|
127
|
+
})
|
|
128
|
+
}}
|
|
129
|
+
{...props.client.checkMcp === undefined ? {} : { onCheck: props.client.checkMcp }}
|
|
130
|
+
/>
|
|
131
|
+
)}
|
|
132
|
+
{paste ? (
|
|
133
|
+
<PasteDialog
|
|
134
|
+
label={label}
|
|
135
|
+
onCancel={() => setPaste(false)}
|
|
136
|
+
onPick={(server) => {
|
|
137
|
+
setPaste(false)
|
|
138
|
+
setDraft(openImported(server, servers))
|
|
139
|
+
}}
|
|
140
|
+
{...props.client.admitMcp === undefined ? {} : { admit: props.client.admitMcp }}
|
|
141
|
+
/>
|
|
142
|
+
) : null}
|
|
143
|
+
{candidates === undefined ? null : (
|
|
144
|
+
<PickDialog
|
|
145
|
+
servers={candidates}
|
|
146
|
+
label={label}
|
|
147
|
+
onCancel={() => setCandidates(undefined)}
|
|
148
|
+
onConfirm={(picked) => {
|
|
149
|
+
if (picked.length === 0) return
|
|
150
|
+
setCandidates(undefined)
|
|
151
|
+
setQueue(picked.slice(1))
|
|
152
|
+
setDraft(openImported(picked[0]!, servers))
|
|
153
|
+
}}
|
|
154
|
+
/>
|
|
155
|
+
)}
|
|
156
|
+
{alert === undefined ? null : (
|
|
157
|
+
<Dialog width="sm" onClose={() => setAlert(undefined)}>
|
|
158
|
+
<p className="m-0 text-sm">{label(alert === 'import' ? 'mcp-import-none' : 'save-failed')}</p>
|
|
159
|
+
<div className="mt-4 flex justify-end">
|
|
160
|
+
<button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={() => setAlert(undefined)}>{label('confirm')}</button>
|
|
161
|
+
</div>
|
|
162
|
+
</Dialog>
|
|
163
|
+
)}
|
|
164
|
+
{removing === undefined ? null : (
|
|
165
|
+
<ConfirmDialog
|
|
166
|
+
label={label}
|
|
167
|
+
title={label('delete-confirm')}
|
|
168
|
+
onCancel={() => setRemoving(undefined)}
|
|
169
|
+
onConfirm={() => { void write(servers.filter(item => item.id !== removing)).then((ok) => { if (ok) setRemoving(undefined) }) }}
|
|
170
|
+
/>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function ServerCard(props: {
|
|
177
|
+
readonly server: McpServerDraft
|
|
178
|
+
readonly check: McpCheckResult | undefined
|
|
179
|
+
/** Set when the last boot left this server out, so the card says why instead of looking live. */
|
|
180
|
+
readonly failure: PanelMcpFailure | undefined
|
|
181
|
+
readonly busy: boolean
|
|
182
|
+
readonly label: (key: string) => string
|
|
183
|
+
readonly onEdit: () => void
|
|
184
|
+
readonly onRemove: () => void
|
|
185
|
+
readonly onToggle: () => void
|
|
186
|
+
readonly onCheck: () => void
|
|
187
|
+
}): ReactNode {
|
|
188
|
+
const enabled = props.server.enabled !== false
|
|
189
|
+
const running = enabled && props.check?.ok === true
|
|
190
|
+
const kind = transportOf(props.server)
|
|
191
|
+
const tools = props.check?.ok === true ? props.check.tools : []
|
|
192
|
+
const shown = tools.slice(0, 4)
|
|
193
|
+
const extra = tools.length - shown.length
|
|
194
|
+
const [openTool, setOpenTool] = useState<string | undefined>(undefined)
|
|
195
|
+
return (
|
|
196
|
+
<li className="flex flex-col gap-2 rounded-2xl border bg-card px-3 py-3">
|
|
197
|
+
<div className="flex items-center justify-between gap-2">
|
|
198
|
+
<span className="min-w-0 truncate text-sm font-semibold">{props.server.id}</span>
|
|
199
|
+
<span className="flex shrink-0 items-center gap-1.5">
|
|
200
|
+
<Switch on={enabled} label={props.label('section-mcp')} onClick={props.onToggle} />
|
|
201
|
+
<button type="button" className="inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground" aria-label={props.label('mcp-edit')} onClick={props.onEdit}>
|
|
202
|
+
<Settings size={16} strokeWidth={2} />
|
|
203
|
+
</button>
|
|
204
|
+
</span>
|
|
205
|
+
</div>
|
|
206
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
207
|
+
<Status running={running} label={props.label(running ? 'mcp-running' : 'mcp-stopped')} />
|
|
208
|
+
<span className="inline-flex h-6 items-center rounded-full bg-muted px-2 text-[11px] leading-none text-muted-foreground">{props.label(transportKey(kind))}</span>
|
|
209
|
+
</div>
|
|
210
|
+
{props.server.description === undefined ? null : <p className="truncate text-xs text-muted-foreground">{props.server.description}</p>}
|
|
211
|
+
{shown.length === 0 ? null : (
|
|
212
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
213
|
+
{shown.map(tool => (
|
|
214
|
+
<button key={tool.name} type="button" className="inline-flex h-6 items-center rounded-full border px-2 text-[11px] leading-none hover:bg-muted" onClick={() => setOpenTool(tool.name)}>{tool.name}</button>
|
|
215
|
+
))}
|
|
216
|
+
{extra > 0 ? (
|
|
217
|
+
<button type="button" className="inline-flex h-6 items-center rounded-full px-2 text-[11px] leading-none text-muted-foreground hover:bg-muted" onClick={() => setOpenTool(tools[shown.length]?.name ?? tools[0]?.name)}>{countLabel(props.label('mcp-more'), extra)}</button>
|
|
218
|
+
) : null}
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
<div className="flex gap-2">
|
|
222
|
+
<button type="button" className="h-7 rounded-md px-2 text-xs hover:bg-muted" onClick={props.onCheck}>{props.busy ? '…' : props.label('mcp-check')}</button>
|
|
223
|
+
<button type="button" className="h-7 rounded-md px-2 text-xs text-destructive hover:bg-muted" onClick={props.onRemove}>{props.label('delete')}</button>
|
|
224
|
+
</div>
|
|
225
|
+
{props.failure === undefined ? null : (
|
|
226
|
+
<p className="m-0 text-xs text-destructive">{props.label('mcp-unstarted')}: {props.failure.message}</p>
|
|
227
|
+
)}
|
|
228
|
+
{props.check !== undefined && props.check.ok !== true ? <p className="text-xs text-destructive">{props.check.message ?? props.label('mcp-offline')}</p> : null}
|
|
229
|
+
{openTool === undefined ? null : (
|
|
230
|
+
<ToolsDialog
|
|
231
|
+
title={props.server.id}
|
|
232
|
+
tools={tools}
|
|
233
|
+
selected={openTool}
|
|
234
|
+
label={props.label}
|
|
235
|
+
onSelect={setOpenTool}
|
|
236
|
+
onClose={() => setOpenTool(undefined)}
|
|
237
|
+
/>
|
|
238
|
+
)}
|
|
239
|
+
</li>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function ToolsDialog(props: {
|
|
244
|
+
readonly title: string
|
|
245
|
+
readonly tools: McpCheckResult['tools']
|
|
246
|
+
readonly selected: string
|
|
247
|
+
readonly label: (key: string) => string
|
|
248
|
+
readonly onSelect: (name: string) => void
|
|
249
|
+
readonly onClose: () => void
|
|
250
|
+
}): ReactNode {
|
|
251
|
+
const tool = props.tools.find(item => item.name === props.selected) ?? props.tools[0]
|
|
252
|
+
return (
|
|
253
|
+
<Dialog width="wide" onClose={props.onClose}>
|
|
254
|
+
<div className="flex h-[min(70vh,36rem)] min-h-[24rem] flex-col">
|
|
255
|
+
<div>
|
|
256
|
+
<h5 className="m-0 text-base font-semibold">{props.title}</h5>
|
|
257
|
+
<p className="mt-1 text-xs text-muted-foreground">{`${props.tools.length} ${props.label('mcp-tools')}`}</p>
|
|
258
|
+
</div>
|
|
259
|
+
<div className="mt-4 grid min-h-0 flex-1 grid-cols-1 overflow-hidden rounded-2xl border min-[720px]:grid-cols-[16rem_minmax(0,1fr)]">
|
|
260
|
+
<ul className="max-h-48 overflow-auto border-b bg-muted/40 p-2 min-[720px]:max-h-none min-[720px]:border-r min-[720px]:border-b-0">
|
|
261
|
+
{props.tools.map(item => (
|
|
262
|
+
<li key={item.name}>
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
className={item.name === tool?.name ? 'w-full rounded-lg bg-card px-3 py-2.5 text-left text-sm font-medium shadow-sm' : 'w-full rounded-lg px-3 py-2.5 text-left text-sm text-muted-foreground hover:bg-card/80'}
|
|
266
|
+
onClick={() => props.onSelect(item.name)}
|
|
267
|
+
>
|
|
268
|
+
{item.name}
|
|
269
|
+
</button>
|
|
270
|
+
</li>
|
|
271
|
+
))}
|
|
272
|
+
</ul>
|
|
273
|
+
<section className="overflow-auto p-6">
|
|
274
|
+
{tool === undefined ? null : (
|
|
275
|
+
<>
|
|
276
|
+
<p className="m-0 text-xs text-muted-foreground">{props.label('mcp-tools')}</p>
|
|
277
|
+
<h6 className="mt-2 text-xl font-semibold tracking-tight">{tool.name}</h6>
|
|
278
|
+
<p className="mt-4 text-sm leading-7 whitespace-pre-wrap text-muted-foreground">{tool.description ?? props.label('mcp-tool-empty')}</p>
|
|
279
|
+
{tool.inputSchema === undefined ? null : <SchemaBlock caption={props.label('mcp-schema-input')} schema={tool.inputSchema} label={props.label} />}
|
|
280
|
+
{tool.outputSchema === undefined ? null : <SchemaBlock caption={props.label('mcp-schema-output')} schema={tool.outputSchema} label={props.label} />}
|
|
281
|
+
</>
|
|
282
|
+
)}
|
|
283
|
+
</section>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</Dialog>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* One declared schema: its top-level fields, then the schema the server sent.
|
|
292
|
+
* @param props - caption, schema, and the label lookup
|
|
293
|
+
*/
|
|
294
|
+
function SchemaBlock(props: {
|
|
295
|
+
readonly caption: string
|
|
296
|
+
readonly schema: Record<string, unknown>
|
|
297
|
+
readonly label: (key: string) => string
|
|
298
|
+
}): ReactNode {
|
|
299
|
+
const fields = schemaFields(props.schema)
|
|
300
|
+
return (
|
|
301
|
+
<section className="mt-6">
|
|
302
|
+
<p className="m-0 text-xs text-muted-foreground">{props.caption}</p>
|
|
303
|
+
{fields.length === 0 ? (
|
|
304
|
+
<p className="mt-2 text-sm text-muted-foreground">{props.label('mcp-schema-no-fields')}</p>
|
|
305
|
+
) : (
|
|
306
|
+
<ul className="mt-2 list-none space-y-2 p-0">
|
|
307
|
+
{fields.map(field => (
|
|
308
|
+
<li key={field.name} className="rounded-lg border p-3">
|
|
309
|
+
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
310
|
+
<span className="font-mono text-sm">{field.name}</span>
|
|
311
|
+
<span className="text-[11px] text-muted-foreground">{field.type}</span>
|
|
312
|
+
{field.required ? <span className="rounded-full border px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">{props.label('mcp-schema-required')}</span> : null}
|
|
313
|
+
</div>
|
|
314
|
+
{field.description === undefined ? null : <p className="mt-1.5 mb-0 text-xs leading-6 text-muted-foreground">{field.description}</p>}
|
|
315
|
+
</li>
|
|
316
|
+
))}
|
|
317
|
+
</ul>
|
|
318
|
+
)}
|
|
319
|
+
<pre className="mt-3 max-h-64 overflow-auto rounded-lg border bg-muted/40 p-3 font-mono text-[11px] leading-5 break-all whitespace-pre-wrap">{JSON.stringify(props.schema, null, 2)}</pre>
|
|
320
|
+
</section>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The top-level fields of an object schema, in the order the server declared them. */
|
|
325
|
+
function schemaFields(schema: Record<string, unknown>): readonly { name: string; type: string; required: boolean; description?: string }[] {
|
|
326
|
+
const properties = recordValue(schema.properties)
|
|
327
|
+
if (properties === undefined) return []
|
|
328
|
+
const required = Array.isArray(schema.required) ? schema.required.filter((item): item is string => typeof item === 'string') : []
|
|
329
|
+
return Object.entries(properties).flatMap(([name, value]) => {
|
|
330
|
+
const field = recordValue(value)
|
|
331
|
+
if (field === undefined) return []
|
|
332
|
+
return [{
|
|
333
|
+
name,
|
|
334
|
+
type: fieldType(field),
|
|
335
|
+
required: required.includes(name),
|
|
336
|
+
...typeof field.description === 'string' && field.description.length > 0 ? { description: field.description } : {},
|
|
337
|
+
}]
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
|
342
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function fieldType(field: Record<string, unknown>): string {
|
|
346
|
+
const declared = field.type
|
|
347
|
+
if (typeof declared === 'string') return declared
|
|
348
|
+
if (Array.isArray(declared)) {
|
|
349
|
+
const names = declared.filter((item): item is string => typeof item === 'string')
|
|
350
|
+
if (names.length > 0) return names.join(' | ')
|
|
351
|
+
}
|
|
352
|
+
if (field.properties !== undefined) return 'object'
|
|
353
|
+
if (field.items !== undefined) return 'array'
|
|
354
|
+
return 'any'
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function EditorDialog(props: {
|
|
358
|
+
readonly draft: Draft
|
|
359
|
+
readonly label: (key: string) => string
|
|
360
|
+
readonly onChange: (draft: Draft) => void
|
|
361
|
+
readonly onCancel: () => void
|
|
362
|
+
readonly onSubmit: () => void
|
|
363
|
+
readonly onCheck?: (server: McpServerDraft) => Promise<McpCheckResult>
|
|
364
|
+
}): ReactNode {
|
|
365
|
+
const set = (patch: Partial<Draft>) => props.onChange({ ...props.draft, ...patch })
|
|
366
|
+
const [busy, setBusy] = useState(false)
|
|
367
|
+
const [check, setCheck] = useState<McpCheckResult | undefined>(undefined)
|
|
368
|
+
return (
|
|
369
|
+
<Dialog onClose={props.onCancel}>
|
|
370
|
+
<h5 className="m-0 text-sm font-semibold">{props.draft.editing === undefined ? props.label('mcp-new') : props.label('mcp-edit')}</h5>
|
|
371
|
+
<p className="mt-4 mb-2 text-xs text-muted-foreground">{props.label('mcp-transport')}</p>
|
|
372
|
+
<div className="inline-flex rounded-lg bg-muted p-0.5">
|
|
373
|
+
{(['stdio', 'sse', 'http'] as const).map(kind => (
|
|
374
|
+
<button key={kind} type="button" className="h-7 rounded-md px-3 text-xs data-[on=1]:bg-foreground data-[on=1]:text-background" data-on={props.draft.transport === kind ? '1' : '0'} onClick={() => set({ transport: kind })}>{props.label(transportKey(kind))}</button>
|
|
375
|
+
))}
|
|
376
|
+
</div>
|
|
377
|
+
<div className="mt-4 grid gap-3 min-[720px]:grid-cols-2">
|
|
378
|
+
<Field label={props.label('mcp-name')} value={props.draft.id} hint={props.label('mcp-name-hint')} onChange={id => set({ id })} />
|
|
379
|
+
<Field label={props.label('mcp-description')} value={props.draft.description} hint={props.label('mcp-desc-hint')} onChange={description => set({ description })} />
|
|
380
|
+
</div>
|
|
381
|
+
{props.draft.transport === 'stdio' ? (
|
|
382
|
+
<div className="mt-3 grid gap-3 min-[720px]:grid-cols-2">
|
|
383
|
+
<Field label={props.label('mcp-command')} value={props.draft.command} hint={props.label('mcp-command-hint')} onChange={command => set({ command })} />
|
|
384
|
+
<Field label={props.label('mcp-args')} value={props.draft.args} hint={props.label('mcp-args-hint')} onChange={args => set({ args })} />
|
|
385
|
+
</div>
|
|
386
|
+
) : <div className="mt-3"><Field label={props.label('mcp-url')} value={props.draft.url} hint={props.label('mcp-url-hint')} onChange={url => set({ url })} /></div>}
|
|
387
|
+
{props.draft.transport === 'stdio' ? (
|
|
388
|
+
<label className="mt-3 flex flex-col gap-1.5 text-xs text-muted-foreground">
|
|
389
|
+
{props.label('mcp-env')}
|
|
390
|
+
<textarea className="min-h-24 rounded-lg border bg-background px-3 py-2 font-mono text-sm text-foreground outline-none" placeholder={props.label('mcp-env-hint')} value={props.draft.env} onChange={event => set({ env: event.target.value })} />
|
|
391
|
+
<span className="font-sans text-muted-foreground">{props.label('mcp-reference-hint')}</span>
|
|
392
|
+
</label>
|
|
393
|
+
) : (
|
|
394
|
+
<label className="mt-3 flex flex-col gap-1.5 text-xs text-muted-foreground">
|
|
395
|
+
{props.label('mcp-headers')}
|
|
396
|
+
<textarea className="min-h-24 rounded-lg border bg-background px-3 py-2 font-mono text-sm text-foreground outline-none" placeholder={props.label('mcp-headers-hint')} value={props.draft.headers} onChange={event => set({ headers: event.target.value })} />
|
|
397
|
+
<span className="font-sans text-muted-foreground">{props.label('mcp-reference-hint')}</span>
|
|
398
|
+
</label>
|
|
399
|
+
)}
|
|
400
|
+
{check === undefined ? null : (
|
|
401
|
+
<div className="mt-3">
|
|
402
|
+
{check.ok ? (
|
|
403
|
+
<>
|
|
404
|
+
<p className="m-0 text-sm text-primary">{countLabel(props.label('mcp-check-ok'), check.tools.length)}</p>
|
|
405
|
+
{check.tools.length === 0 ? null : (
|
|
406
|
+
<div className="mt-2 flex flex-wrap gap-1">
|
|
407
|
+
{check.tools.slice(0, 8).map(tool => <span key={tool.name} className="rounded-full border px-2 py-0.5 text-[11px]">{tool.name}</span>)}
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
</>
|
|
411
|
+
) : <p className="m-0 text-sm text-destructive">{check.message ?? props.label('mcp-offline')}</p>}
|
|
412
|
+
</div>
|
|
413
|
+
)}
|
|
414
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
415
|
+
{props.onCheck === undefined ? null : (
|
|
416
|
+
<button type="button" className="mr-auto h-8 rounded-lg border px-3 text-sm disabled:opacity-60" disabled={busy} onClick={() => {
|
|
417
|
+
const checkMcp = props.onCheck
|
|
418
|
+
if (checkMcp === undefined) return
|
|
419
|
+
void testDraft(props.draft, checkMcp, setBusy, setCheck, props.label)
|
|
420
|
+
}}>{busy ? '…' : props.label('mcp-check')}</button>
|
|
421
|
+
)}
|
|
422
|
+
<button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={props.onCancel}>{props.label('cancel')}</button>
|
|
423
|
+
<button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={props.onSubmit}>{props.draft.editing === undefined ? props.label('mcp-add') : props.label('save')}</button>
|
|
424
|
+
</div>
|
|
425
|
+
</Dialog>
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function PasteDialog(props: {
|
|
430
|
+
readonly label: (key: string) => string
|
|
431
|
+
readonly admit?: (text: string) => Promise<readonly McpServerDraft[]>
|
|
432
|
+
readonly onCancel: () => void
|
|
433
|
+
readonly onPick: (server: McpServerDraft) => void
|
|
434
|
+
}): ReactNode {
|
|
435
|
+
const [text, setText] = useState('')
|
|
436
|
+
const [found, setFound] = useState<readonly McpServerDraft[] | undefined>(undefined)
|
|
437
|
+
const [failed, setFailed] = useState('')
|
|
438
|
+
const [busy, setBusy] = useState(false)
|
|
439
|
+
return (
|
|
440
|
+
<Dialog onClose={props.onCancel}>
|
|
441
|
+
<h5 className="m-0 text-sm font-semibold">{props.label('mcp-paste')}</h5>
|
|
442
|
+
<textarea className="mt-3 min-h-36 w-full rounded-lg border bg-background px-3 py-2 font-mono text-xs text-foreground outline-none" value={text} onChange={event => setText(event.target.value)} />
|
|
443
|
+
{failed.length > 0 ? <p className="mt-2 text-sm text-destructive">{failed}</p> : null}
|
|
444
|
+
{found === undefined ? null : (
|
|
445
|
+
<ul className="mt-3 flex max-h-48 flex-col gap-1 overflow-auto">
|
|
446
|
+
{found.map(server => (
|
|
447
|
+
<li key={server.id}>
|
|
448
|
+
<button type="button" className="flex w-full items-baseline gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted" onClick={() => props.onPick(server)}>
|
|
449
|
+
<span className="font-medium">{server.id}</span>
|
|
450
|
+
<span className="text-xs text-muted-foreground">{props.label(transportKey(transportOf(server)))}</span>
|
|
451
|
+
</button>
|
|
452
|
+
</li>
|
|
453
|
+
))}
|
|
454
|
+
</ul>
|
|
455
|
+
)}
|
|
456
|
+
<div className="mt-3 flex justify-end gap-2">
|
|
457
|
+
<button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={props.onCancel}>{props.label('cancel')}</button>
|
|
458
|
+
<button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground disabled:opacity-60" disabled={busy || props.admit === undefined} onClick={() => {
|
|
459
|
+
const admit = props.admit
|
|
460
|
+
if (admit === undefined) return
|
|
461
|
+
void recognizePaste(text, admit, setBusy, setFailed, setFound, props.onPick, props.label)
|
|
462
|
+
}}>{busy ? '…' : props.label('mcp-admit')}</button>
|
|
463
|
+
</div>
|
|
464
|
+
</Dialog>
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function PickDialog(props: {
|
|
469
|
+
readonly servers: readonly McpServerDraft[]
|
|
470
|
+
readonly label: (key: string) => string
|
|
471
|
+
readonly onCancel: () => void
|
|
472
|
+
readonly onConfirm: (servers: readonly McpServerDraft[]) => void
|
|
473
|
+
}): ReactNode {
|
|
474
|
+
const [picked, setPicked] = useState<ReadonlySet<string>>(() => new Set(props.servers.map(server => server.id)))
|
|
475
|
+
const chosen = props.servers.filter(server => picked.has(server.id))
|
|
476
|
+
return (
|
|
477
|
+
<Dialog width="wide" onClose={props.onCancel}>
|
|
478
|
+
<h5 className="m-0 text-base font-semibold">{props.label('mcp-pick')}</h5>
|
|
479
|
+
<ul className="mt-4 grid max-h-[60vh] grid-cols-1 gap-2 overflow-auto min-[720px]:grid-cols-2">
|
|
480
|
+
{props.servers.map(server => (
|
|
481
|
+
<li key={server.id}>
|
|
482
|
+
<label className="flex h-full items-start gap-3 rounded-xl border px-3 py-3 text-sm hover:bg-muted">
|
|
483
|
+
<input className="mt-1" type="checkbox" checked={picked.has(server.id)} onChange={() => setPicked(toggleSet(picked, server.id))} />
|
|
484
|
+
<span className="min-w-0">
|
|
485
|
+
<span className="flex items-baseline gap-2">
|
|
486
|
+
<span className="truncate font-medium">{server.id}</span>
|
|
487
|
+
<span className="shrink-0 text-xs text-muted-foreground">{props.label(transportKey(transportOf(server)))}</span>
|
|
488
|
+
</span>
|
|
489
|
+
{server.description === undefined ? null : <span className="mt-1 block text-xs leading-5 text-muted-foreground">{server.description}</span>}
|
|
490
|
+
</span>
|
|
491
|
+
</label>
|
|
492
|
+
</li>
|
|
493
|
+
))}
|
|
494
|
+
</ul>
|
|
495
|
+
<div className="mt-3 flex justify-end gap-2">
|
|
496
|
+
<button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={props.onCancel}>{props.label('cancel')}</button>
|
|
497
|
+
<button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={() => props.onConfirm(chosen)}>{props.label('mcp-add')}</button>
|
|
498
|
+
</div>
|
|
499
|
+
</Dialog>
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function ConfirmDialog(props: {
|
|
504
|
+
readonly title: string
|
|
505
|
+
readonly label: (key: string) => string
|
|
506
|
+
readonly onCancel: () => void
|
|
507
|
+
readonly onConfirm: () => void
|
|
508
|
+
}): ReactNode {
|
|
509
|
+
return (
|
|
510
|
+
<Dialog onClose={props.onCancel}>
|
|
511
|
+
<p className="m-0 text-sm">{props.title}</p>
|
|
512
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
513
|
+
<button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={props.onCancel}>{props.label('cancel')}</button>
|
|
514
|
+
<button type="button" className="h-8 rounded-lg bg-destructive px-3 text-sm font-semibold text-destructive-foreground" onClick={props.onConfirm}>{props.label('delete')}</button>
|
|
515
|
+
</div>
|
|
516
|
+
</Dialog>
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function Switch(props: { readonly on: boolean; readonly label: string; readonly onClick: () => void }): ReactNode {
|
|
521
|
+
return (
|
|
522
|
+
<button type="button" role="switch" aria-checked={props.on} aria-label={props.label} className={props.on ? 'relative h-5 w-9 rounded-full bg-foreground' : 'relative h-5 w-9 rounded-full bg-muted'} onClick={props.onClick}>
|
|
523
|
+
<span className={props.on ? 'absolute top-0.5 left-4 size-4 rounded-full bg-background' : 'absolute top-0.5 left-0.5 size-4 rounded-full bg-background'} />
|
|
524
|
+
</button>
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function Status(props: { readonly running: boolean; readonly label: string }): ReactNode {
|
|
529
|
+
return (
|
|
530
|
+
<span className={props.running ? 'inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/15 px-2 text-[11px] leading-none font-medium text-primary' : 'inline-flex h-6 items-center gap-1.5 rounded-full bg-muted px-2 text-[11px] leading-none text-muted-foreground'}>
|
|
531
|
+
<i className={props.running ? 'size-1.5 rounded-full bg-primary' : 'size-1.5 rounded-full bg-muted-foreground'} />
|
|
532
|
+
{props.label}
|
|
533
|
+
</span>
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function countLabel(template: string, count: number): string {
|
|
538
|
+
return template.replace('{n}', String(count))
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function Field(props: {
|
|
542
|
+
readonly label: string
|
|
543
|
+
readonly value: string
|
|
544
|
+
readonly hint: string
|
|
545
|
+
readonly onChange: (value: string) => void
|
|
546
|
+
}): ReactNode {
|
|
547
|
+
return (
|
|
548
|
+
<label className="flex flex-col gap-1.5 text-xs text-muted-foreground">
|
|
549
|
+
{props.label}
|
|
550
|
+
<input className="h-9 rounded-lg border bg-background px-3 text-sm text-foreground outline-none" placeholder={props.hint} value={props.value} onChange={event => props.onChange(event.target.value)} />
|
|
551
|
+
</label>
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function transportOf(server: McpServerDraft): Transport {
|
|
556
|
+
if (server.url === undefined || server.url.length === 0) return 'stdio'
|
|
557
|
+
return server.transport === 'sse' ? 'sse' : 'http'
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function transportKey(kind: Transport): 'mcp-stdio' | 'mcp-sse' | 'mcp-http' {
|
|
561
|
+
if (kind === 'sse') return 'mcp-sse'
|
|
562
|
+
if (kind === 'http') return 'mcp-http'
|
|
563
|
+
return 'mcp-stdio'
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function toggleSet(current: ReadonlySet<string>, id: string): ReadonlySet<string> {
|
|
567
|
+
const next = new Set(current)
|
|
568
|
+
if (next.has(id)) next.delete(id)
|
|
569
|
+
else next.add(id)
|
|
570
|
+
return next
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function toDraft(server: McpServerDraft): Draft {
|
|
574
|
+
return {
|
|
575
|
+
id: server.id,
|
|
576
|
+
description: server.description ?? '',
|
|
577
|
+
transport: transportOf(server),
|
|
578
|
+
command: server.command ?? '',
|
|
579
|
+
args: (server.args ?? []).join(' '),
|
|
580
|
+
url: server.url ?? '',
|
|
581
|
+
env: Object.entries(server.env ?? {}).map(([key, value]) => `${key}=${value}`).join('\n'),
|
|
582
|
+
headers: Object.entries(server.headers ?? {}).map(([key, value]) => `${key}=${value}`).join('\n'),
|
|
583
|
+
editing: server.id,
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function fromDraft(draft: Draft): McpServerDraft {
|
|
588
|
+
const env = envOf(draft.env)
|
|
589
|
+
const headers = envOf(draft.headers)
|
|
590
|
+
const description = draft.description.trim()
|
|
591
|
+
const base = {
|
|
592
|
+
id: draft.id.trim(),
|
|
593
|
+
...description.length > 0 ? { description } : {},
|
|
594
|
+
}
|
|
595
|
+
if (draft.transport === 'stdio') {
|
|
596
|
+
const args = draft.args.split(/\s+/).filter(item => item.length > 0)
|
|
597
|
+
return {
|
|
598
|
+
...base,
|
|
599
|
+
...draft.command.trim().length > 0 ? { command: draft.command.trim() } : {},
|
|
600
|
+
...args.length > 0 ? { args } : {},
|
|
601
|
+
...Object.keys(env).length > 0 ? { env } : {},
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
...base,
|
|
606
|
+
...draft.url.trim().length > 0 ? { url: draft.url.trim() } : {},
|
|
607
|
+
transport: draft.transport === 'sse' ? 'sse' : 'streamable-http',
|
|
608
|
+
...Object.keys(headers).length > 0 ? { headers } : {},
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function envOf(text: string): Record<string, string> {
|
|
613
|
+
const env: Record<string, string> = {}
|
|
614
|
+
for (const line of text.split('\n')) {
|
|
615
|
+
const trimmed = line.trim()
|
|
616
|
+
const at = trimmed.indexOf('=')
|
|
617
|
+
if (at <= 0) continue
|
|
618
|
+
env[trimmed.slice(0, at).trim()] = trimmed.slice(at + 1)
|
|
619
|
+
}
|
|
620
|
+
return env
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function openImported(server: McpServerDraft, existing: readonly McpServerDraft[]): Draft {
|
|
624
|
+
const draft = toDraft(server)
|
|
625
|
+
return existing.some(item => item.id === server.id) ? draft : { ...draft, editing: undefined }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function saveDraft(
|
|
629
|
+
client: PanelSettingsClient,
|
|
630
|
+
servers: readonly McpServerDraft[],
|
|
631
|
+
draft: Draft,
|
|
632
|
+
setServers: (servers: McpServerDraft[]) => void,
|
|
633
|
+
setUnresolved: (failures: readonly PanelMcpFailure[]) => void,
|
|
634
|
+
setAlert: (alert: 'import' | 'save' | undefined) => void,
|
|
635
|
+
setNotice: (message: string) => void,
|
|
636
|
+
label: (key: string) => string,
|
|
637
|
+
): Promise<boolean> {
|
|
638
|
+
const next = servers.filter(server => server.id !== draft.editing && server.id !== draft.id.trim()).concat(fromDraft(draft))
|
|
639
|
+
return commit(client, next, setServers, setUnresolved, setAlert, setNotice, label)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function commit(
|
|
643
|
+
client: PanelSettingsClient,
|
|
644
|
+
servers: readonly McpServerDraft[],
|
|
645
|
+
setServers: (servers: McpServerDraft[]) => void,
|
|
646
|
+
setUnresolved: (failures: readonly PanelMcpFailure[]) => void,
|
|
647
|
+
setAlert: (alert: 'import' | 'save' | undefined) => void,
|
|
648
|
+
setNotice: (message: string) => void,
|
|
649
|
+
label: (key: string) => string,
|
|
650
|
+
): Promise<boolean> {
|
|
651
|
+
if (client.writeMcp === undefined) return false
|
|
652
|
+
if (servers.some(server => server.id.length === 0)) {
|
|
653
|
+
setAlert('save')
|
|
654
|
+
return false
|
|
655
|
+
}
|
|
656
|
+
try {
|
|
657
|
+
const written = await client.writeMcp(servers)
|
|
658
|
+
setServers([...servers])
|
|
659
|
+
// The write resolved the file: what it left out is the current answer, not boot's.
|
|
660
|
+
setUnresolved([...written.unresolved])
|
|
661
|
+
setAlert(undefined)
|
|
662
|
+
setNotice(label('mcp-saved'))
|
|
663
|
+
return true
|
|
664
|
+
} catch {
|
|
665
|
+
setAlert('save')
|
|
666
|
+
return false
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function testDraft(
|
|
671
|
+
draft: Draft,
|
|
672
|
+
checkMcp: (server: McpServerDraft) => Promise<McpCheckResult>,
|
|
673
|
+
setBusy: (busy: boolean) => void,
|
|
674
|
+
setCheck: (result: McpCheckResult) => void,
|
|
675
|
+
label: (key: string) => string,
|
|
676
|
+
): Promise<void> {
|
|
677
|
+
const server = fromDraft(draft)
|
|
678
|
+
if (server.id.length === 0) {
|
|
679
|
+
setCheck({ ok: false, tools: [], message: label('mcp-need-name') })
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
setBusy(true)
|
|
683
|
+
try {
|
|
684
|
+
setCheck(await checkMcp(server))
|
|
685
|
+
} catch (error) {
|
|
686
|
+
setCheck({
|
|
687
|
+
ok: false,
|
|
688
|
+
tools: [],
|
|
689
|
+
message: error instanceof Error && error.message.length > 0 ? error.message : label('mcp-offline'),
|
|
690
|
+
})
|
|
691
|
+
} finally {
|
|
692
|
+
setBusy(false)
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function runCheck(
|
|
697
|
+
client: PanelSettingsClient,
|
|
698
|
+
server: McpServerDraft,
|
|
699
|
+
setChecks: (value: CheckMap | ((current: CheckMap) => CheckMap)) => void,
|
|
700
|
+
setBusy: (id: string) => void,
|
|
701
|
+
setAlert: (alert: 'import' | 'save' | undefined) => void,
|
|
702
|
+
): Promise<void> {
|
|
703
|
+
if (client.checkMcp === undefined) return
|
|
704
|
+
setBusy(server.id)
|
|
705
|
+
try {
|
|
706
|
+
const result = await client.checkMcp(server)
|
|
707
|
+
setChecks(current => ({ ...current, [server.id]: result }))
|
|
708
|
+
} catch {
|
|
709
|
+
setAlert('save')
|
|
710
|
+
} finally {
|
|
711
|
+
setBusy('')
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function pullSource(
|
|
716
|
+
client: PanelSettingsClient,
|
|
717
|
+
source: string,
|
|
718
|
+
onCandidates: (servers: readonly McpServerDraft[]) => void,
|
|
719
|
+
setAlert: (alert: 'import' | 'save' | undefined) => void,
|
|
720
|
+
): Promise<void> {
|
|
721
|
+
if (client.importMcp === undefined) return
|
|
722
|
+
try {
|
|
723
|
+
const listed = await client.importMcp(source)
|
|
724
|
+
if (listed.length === 0) {
|
|
725
|
+
setAlert('import')
|
|
726
|
+
return
|
|
727
|
+
}
|
|
728
|
+
onCandidates(listed)
|
|
729
|
+
} catch {
|
|
730
|
+
setAlert('import')
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function recognizePaste(
|
|
735
|
+
text: string,
|
|
736
|
+
admit: (text: string) => Promise<readonly McpServerDraft[]>,
|
|
737
|
+
setBusy: (busy: boolean) => void,
|
|
738
|
+
setFailed: (message: string) => void,
|
|
739
|
+
setFound: (servers: readonly McpServerDraft[]) => void,
|
|
740
|
+
onPick: (server: McpServerDraft) => void,
|
|
741
|
+
label: (key: string) => string,
|
|
742
|
+
): Promise<void> {
|
|
743
|
+
setBusy(true)
|
|
744
|
+
setFailed('')
|
|
745
|
+
try {
|
|
746
|
+
const listed = await admit(text)
|
|
747
|
+
if (listed.length === 0) {
|
|
748
|
+
setFailed(label('mcp-import-none'))
|
|
749
|
+
setFound([])
|
|
750
|
+
return
|
|
751
|
+
}
|
|
752
|
+
if (listed.length === 1) {
|
|
753
|
+
onPick(listed[0]!)
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
setFound(listed)
|
|
757
|
+
} catch {
|
|
758
|
+
setFailed(label('mcp-import-none'))
|
|
759
|
+
} finally {
|
|
760
|
+
setBusy(false)
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function admit(
|
|
765
|
+
client: PanelSettingsClient,
|
|
766
|
+
text: string,
|
|
767
|
+
onCandidates: (servers: readonly McpServerDraft[]) => void,
|
|
768
|
+
setPaste: (open: boolean) => void,
|
|
769
|
+
setAlert: (alert: 'import' | 'save' | undefined) => void,
|
|
770
|
+
): Promise<void> {
|
|
771
|
+
if (client.admitMcp === undefined) return
|
|
772
|
+
try {
|
|
773
|
+
const listed = await client.admitMcp(text)
|
|
774
|
+
if (listed.length === 0) {
|
|
775
|
+
setAlert('import')
|
|
776
|
+
return
|
|
777
|
+
}
|
|
778
|
+
onCandidates(listed)
|
|
779
|
+
setPaste(false)
|
|
780
|
+
} catch {
|
|
781
|
+
setAlert('import')
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function readChosen(
|
|
786
|
+
file: File | undefined,
|
|
787
|
+
client: PanelSettingsClient,
|
|
788
|
+
onCandidates: (servers: readonly McpServerDraft[]) => void,
|
|
789
|
+
setAlert: (alert: 'import' | 'save' | undefined) => void,
|
|
790
|
+
): Promise<void> {
|
|
791
|
+
if (file === undefined) return
|
|
792
|
+
await admit(client, await file.text(), onCandidates, () => undefined, setAlert)
|
|
793
|
+
}
|