@harbinger-ai/harbinger 0.1.1 → 0.1.3
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/lib/chat/actions.js +416 -0
- package/lib/chat/components/agents-page.js +545 -0
- package/lib/chat/components/agents-page.jsx +571 -0
- package/lib/chat/components/app-sidebar.js +33 -1
- package/lib/chat/components/app-sidebar.jsx +37 -1
- package/lib/chat/components/findings-page.js +164 -103
- package/lib/chat/components/findings-page.jsx +156 -101
- package/lib/chat/components/icons.js +62 -0
- package/lib/chat/components/icons.jsx +62 -0
- package/lib/chat/components/index.js +3 -0
- package/lib/chat/components/mcp-page.js +383 -55
- package/lib/chat/components/mcp-page.jsx +404 -101
- package/lib/chat/components/mission-control.js +490 -0
- package/lib/chat/components/mission-control.jsx +618 -0
- package/lib/chat/components/registry-page.js +267 -133
- package/lib/chat/components/registry-page.jsx +299 -138
- package/lib/chat/components/settings-layout.js +3 -2
- package/lib/chat/components/settings-layout.jsx +2 -1
- package/lib/chat/components/settings-providers-page.js +337 -0
- package/lib/chat/components/settings-providers-page.jsx +410 -0
- package/lib/chat/components/settings-secrets-page.js +91 -66
- package/lib/chat/components/settings-secrets-page.jsx +83 -72
- package/lib/chat/components/targets-page.js +269 -200
- package/lib/chat/components/targets-page.jsx +181 -111
- package/lib/mcp/actions.js +120 -0
- package/lib/mcp/registry.js +164 -0
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect } from 'react';
|
|
4
|
-
import { motion } from 'framer-motion';
|
|
5
|
-
import { PlugIcon, RefreshIcon, SpinnerIcon, ChevronDownIcon, WrenchIcon, CheckIcon, XIcon } from './icons.js';
|
|
6
|
-
import { getMcpServers, getMcpStatus, getOwnMcpServerInfo, testMcpTool, reloadMcpClient } from '../../mcp/actions.js';
|
|
4
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
5
|
+
import { PlugIcon, RefreshIcon, SpinnerIcon, ChevronDownIcon, WrenchIcon, CheckIcon, XIcon, SearchIcon, PlusIcon, TrashIcon, DownloadIcon } from './icons.js';
|
|
6
|
+
import { getMcpServers, getMcpStatus, getOwnMcpServerInfo, testMcpTool, reloadMcpClient, getMcpRegistry, addMcpServer, removeMcpServer, toggleMcpServer } from '../../mcp/actions.js';
|
|
7
7
|
|
|
8
8
|
// ─── Animation variants ─────────────────────────────────────────────────────
|
|
9
9
|
|
|
@@ -16,6 +16,21 @@ const cardVariants = {
|
|
|
16
16
|
}),
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
const CATEGORY_COLORS = {
|
|
20
|
+
search: { bg: 'bg-blue-500/10', text: 'text-blue-500', border: 'border-blue-500/20' },
|
|
21
|
+
web: { bg: 'bg-orange-500/10', text: 'text-orange-500', border: 'border-orange-500/20' },
|
|
22
|
+
dev: { bg: 'bg-purple-500/10', text: 'text-purple-500', border: 'border-purple-500/20' },
|
|
23
|
+
communication: { bg: 'bg-green-500/10', text: 'text-green-500', border: 'border-green-500/20' },
|
|
24
|
+
system: { bg: 'bg-gray-500/10', text: 'text-gray-500', border: 'border-gray-500/20' },
|
|
25
|
+
database: { bg: 'bg-cyan-500/10', text: 'text-cyan-500', border: 'border-cyan-500/20' },
|
|
26
|
+
monitoring: { bg: 'bg-yellow-500/10', text: 'text-yellow-500', border: 'border-yellow-500/20' },
|
|
27
|
+
cloud: { bg: 'bg-indigo-500/10', text: 'text-indigo-500', border: 'border-indigo-500/20' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function getCatStyle(cat) {
|
|
31
|
+
return CATEGORY_COLORS[cat] || { bg: 'bg-white/5', text: 'text-muted-foreground', border: 'border-white/10' };
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
// ─── Section Header ──────────────────────────────────────────────────────────
|
|
20
35
|
|
|
21
36
|
function SectionHeader({ label, count }) {
|
|
@@ -59,12 +74,109 @@ function ItemCard({ name, description, badge, index = 0 }) {
|
|
|
59
74
|
);
|
|
60
75
|
}
|
|
61
76
|
|
|
77
|
+
// ─── Registry Card ──────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
function RegistryCard({ server, installed, onInstall, installing, index }) {
|
|
80
|
+
const [apiKey, setApiKey] = useState('');
|
|
81
|
+
const [showKeyInput, setShowKeyInput] = useState(false);
|
|
82
|
+
const cat = getCatStyle(server.category);
|
|
83
|
+
|
|
84
|
+
function handleAdd() {
|
|
85
|
+
if (server.requiresKey && !apiKey && !showKeyInput) {
|
|
86
|
+
setShowKeyInput(true);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const config = {
|
|
90
|
+
name: server.name,
|
|
91
|
+
url: server.url || '',
|
|
92
|
+
transport: server.transport || 'http',
|
|
93
|
+
enabled: true,
|
|
94
|
+
};
|
|
95
|
+
if (apiKey && server.keyEnvVar) {
|
|
96
|
+
config.headers = { Authorization: `Bearer ${apiKey}` };
|
|
97
|
+
}
|
|
98
|
+
if (server.npmPackage) {
|
|
99
|
+
config.command = 'npx';
|
|
100
|
+
config.args = ['-y', server.npmPackage];
|
|
101
|
+
config.transport = 'stdio';
|
|
102
|
+
}
|
|
103
|
+
onInstall(config);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<motion.div
|
|
108
|
+
initial={{ opacity: 0, y: 8 }}
|
|
109
|
+
animate={{ opacity: 1, y: 0 }}
|
|
110
|
+
transition={{ duration: 0.25, delay: index * 0.02 }}
|
|
111
|
+
className="group rounded-lg border border-white/[0.06] bg-[--card] hover:border-[--cyan]/20 transition-colors overflow-hidden"
|
|
112
|
+
>
|
|
113
|
+
<div className="flex items-center gap-3 p-3">
|
|
114
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
115
|
+
<div className="w-2 h-2 rounded-full bg-[#ff5f57]" />
|
|
116
|
+
<div className="w-2 h-2 rounded-full bg-[#febc2e]" />
|
|
117
|
+
<div className="w-2 h-2 rounded-full bg-[#28c840]" />
|
|
118
|
+
</div>
|
|
119
|
+
<div className="flex-1 min-w-0">
|
|
120
|
+
<p className="text-sm font-mono font-medium truncate">{server.name}</p>
|
|
121
|
+
<p className="text-[11px] text-muted-foreground mt-0.5 line-clamp-1 font-mono">{server.description}</p>
|
|
122
|
+
</div>
|
|
123
|
+
<span className={`shrink-0 inline-flex rounded-full px-2 py-0.5 text-[10px] font-mono font-medium border ${cat.bg} ${cat.text} ${cat.border}`}>
|
|
124
|
+
{server.category}
|
|
125
|
+
</span>
|
|
126
|
+
{installed ? (
|
|
127
|
+
<span className="shrink-0 inline-flex items-center gap-1 rounded-full bg-green-500/10 text-green-500 border border-green-500/20 px-2 py-0.5 text-[10px] font-mono font-medium">
|
|
128
|
+
<CheckIcon size={10} /> added
|
|
129
|
+
</span>
|
|
130
|
+
) : (
|
|
131
|
+
<button
|
|
132
|
+
onClick={handleAdd}
|
|
133
|
+
disabled={installing}
|
|
134
|
+
className="shrink-0 inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors disabled:opacity-50"
|
|
135
|
+
>
|
|
136
|
+
{installing ? <SpinnerIcon size={12} /> : <DownloadIcon size={12} />}
|
|
137
|
+
Add
|
|
138
|
+
</button>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
{showKeyInput && !installed && (
|
|
142
|
+
<div className="border-t border-white/[0.06] px-3 py-2.5 flex gap-2">
|
|
143
|
+
<input
|
|
144
|
+
type="password"
|
|
145
|
+
value={apiKey}
|
|
146
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
147
|
+
placeholder={`${server.keyEnvVar || 'API_KEY'}...`}
|
|
148
|
+
className="flex-1 text-xs border border-white/[0.06] rounded-md px-2.5 py-1.5 bg-black/20 font-mono text-foreground/80 placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 focus:ring-1 focus:ring-[--cyan]/20 transition-colors"
|
|
149
|
+
autoFocus
|
|
150
|
+
/>
|
|
151
|
+
<button
|
|
152
|
+
onClick={handleAdd}
|
|
153
|
+
disabled={!apiKey}
|
|
154
|
+
className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors disabled:opacity-50"
|
|
155
|
+
>
|
|
156
|
+
Add
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</motion.div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
62
164
|
// ─── External Server Card ────────────────────────────────────────────────────
|
|
63
165
|
|
|
64
|
-
function ServerCard({ server, index = 0 }) {
|
|
166
|
+
function ServerCard({ server, onToggle, onRemove, index = 0 }) {
|
|
65
167
|
const [expanded, setExpanded] = useState(false);
|
|
168
|
+
const [confirming, setConfirming] = useState(false);
|
|
66
169
|
const disabled = server.enabled === false;
|
|
67
170
|
|
|
171
|
+
function handleRemove() {
|
|
172
|
+
if (!confirming) {
|
|
173
|
+
setConfirming(true);
|
|
174
|
+
setTimeout(() => setConfirming(false), 3000);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
onRemove(server.name);
|
|
178
|
+
}
|
|
179
|
+
|
|
68
180
|
return (
|
|
69
181
|
<motion.div
|
|
70
182
|
custom={index}
|
|
@@ -88,7 +200,9 @@ function ServerCard({ server, index = 0 }) {
|
|
|
88
200
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-mono font-medium ${
|
|
89
201
|
server.transport === 'http'
|
|
90
202
|
? 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
|
|
91
|
-
:
|
|
203
|
+
: server.transport === 'sse'
|
|
204
|
+
? 'bg-orange-500/10 text-orange-400 border border-orange-500/20'
|
|
205
|
+
: 'bg-purple-500/10 text-purple-400 border border-purple-500/20'
|
|
92
206
|
}`}>
|
|
93
207
|
{server.transport || 'http'}
|
|
94
208
|
</span>
|
|
@@ -105,32 +219,119 @@ function ServerCard({ server, index = 0 }) {
|
|
|
105
219
|
</div>
|
|
106
220
|
</button>
|
|
107
221
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
<div
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
222
|
+
<AnimatePresence>
|
|
223
|
+
{expanded && (
|
|
224
|
+
<motion.div
|
|
225
|
+
initial={{ height: 0, opacity: 0 }}
|
|
226
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
227
|
+
exit={{ height: 0, opacity: 0 }}
|
|
228
|
+
transition={{ duration: 0.2 }}
|
|
229
|
+
className="overflow-hidden"
|
|
230
|
+
>
|
|
231
|
+
<div className="border-t border-white/[0.06] px-4 py-3">
|
|
232
|
+
<div className="flex flex-col gap-1.5">
|
|
233
|
+
{server.url && (
|
|
234
|
+
<div className="flex items-center gap-2">
|
|
235
|
+
<span className="font-mono text-[10px] font-medium text-muted-foreground uppercase tracking-wider w-16 shrink-0">URL</span>
|
|
236
|
+
<span className="text-xs font-mono text-foreground/80">{server.url}</span>
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
<div className="flex items-center gap-2">
|
|
240
|
+
<span className="font-mono text-[10px] font-medium text-muted-foreground uppercase tracking-wider w-16 shrink-0">Transport</span>
|
|
241
|
+
<span className="text-xs font-mono text-foreground/80">{server.transport || 'http'}</span>
|
|
242
|
+
</div>
|
|
243
|
+
{server.headers && Object.keys(server.headers).length > 0 && (
|
|
244
|
+
<div>
|
|
245
|
+
<span className="font-mono text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Headers</span>
|
|
246
|
+
<pre className="text-[11px] bg-black/30 rounded-md p-2.5 mt-1 font-mono overflow-auto max-h-24 text-foreground/80 border border-white/[0.04]">
|
|
247
|
+
{JSON.stringify(server.headers, null, 2)}
|
|
248
|
+
</pre>
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
125
251
|
</div>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
252
|
+
<div className="flex items-center gap-2 mt-3">
|
|
253
|
+
<button
|
|
254
|
+
onClick={(e) => { e.stopPropagation(); onToggle(server.name); }}
|
|
255
|
+
className="inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] transition-colors"
|
|
256
|
+
>
|
|
257
|
+
{disabled ? 'Enable' : 'Disable'}
|
|
258
|
+
</button>
|
|
259
|
+
<div className="flex-1" />
|
|
260
|
+
<button
|
|
261
|
+
onClick={(e) => { e.stopPropagation(); handleRemove(); }}
|
|
262
|
+
className={`text-xs font-mono transition-colors ${
|
|
263
|
+
confirming ? 'text-[--destructive]' : 'text-muted-foreground hover:text-[--destructive]'
|
|
264
|
+
}`}
|
|
265
|
+
>
|
|
266
|
+
{confirming ? 'Confirm remove' : 'Remove'}
|
|
267
|
+
</button>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</motion.div>
|
|
271
|
+
)}
|
|
272
|
+
</AnimatePresence>
|
|
130
273
|
</motion.div>
|
|
131
274
|
);
|
|
132
275
|
}
|
|
133
276
|
|
|
277
|
+
// ─── Add Custom Server Form ─────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
function AddServerForm({ onAdd }) {
|
|
280
|
+
const [name, setName] = useState('');
|
|
281
|
+
const [url, setUrl] = useState('');
|
|
282
|
+
const [transport, setTransport] = useState('http');
|
|
283
|
+
const [headers, setHeaders] = useState('');
|
|
284
|
+
const [loading, setLoading] = useState(false);
|
|
285
|
+
const [result, setResult] = useState(null);
|
|
286
|
+
|
|
287
|
+
async function handleAdd() {
|
|
288
|
+
if (!name || !url) return;
|
|
289
|
+
setLoading(true);
|
|
290
|
+
setResult(null);
|
|
291
|
+
let headerObj = {};
|
|
292
|
+
if (headers) {
|
|
293
|
+
try { headerObj = JSON.parse(headers); } catch { setResult({ error: 'Invalid JSON headers' }); setLoading(false); return; }
|
|
294
|
+
}
|
|
295
|
+
const res = await onAdd({ name, url, transport, headers: headerObj, enabled: true });
|
|
296
|
+
setResult(res);
|
|
297
|
+
if (!res.error) { setName(''); setUrl(''); setHeaders(''); }
|
|
298
|
+
setLoading(false);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<div className="rounded-lg border border-white/[0.06] bg-[--card] p-4 mb-4">
|
|
303
|
+
<div className="flex items-center gap-2 mb-3">
|
|
304
|
+
<PlusIcon size={14} />
|
|
305
|
+
<p className="text-sm font-mono font-medium text-[--cyan]">Add Custom Server</p>
|
|
306
|
+
</div>
|
|
307
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
|
|
308
|
+
<input placeholder="Server name" value={name} onChange={(e) => setName(e.target.value)}
|
|
309
|
+
className="text-sm border border-white/[0.06] rounded-md px-3 py-2 bg-black/20 font-mono text-foreground/80 placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 focus:ring-1 focus:ring-[--cyan]/20 transition-colors" />
|
|
310
|
+
<input placeholder="https://mcp.example.com/sse" value={url} onChange={(e) => setUrl(e.target.value)}
|
|
311
|
+
className="text-sm border border-white/[0.06] rounded-md px-3 py-2 bg-black/20 font-mono text-foreground/80 placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 focus:ring-1 focus:ring-[--cyan]/20 transition-colors" />
|
|
312
|
+
<select value={transport} onChange={(e) => setTransport(e.target.value)}
|
|
313
|
+
className="text-sm border border-white/[0.06] rounded-md px-3 py-2 bg-black/20 font-mono text-foreground/80 focus:outline-none focus:border-[--cyan]/40 focus:ring-1 focus:ring-[--cyan]/20 transition-colors appearance-none">
|
|
314
|
+
<option value="http">HTTP</option>
|
|
315
|
+
<option value="sse">SSE</option>
|
|
316
|
+
</select>
|
|
317
|
+
<input placeholder='{"Authorization": "Bearer ..."}' value={headers} onChange={(e) => setHeaders(e.target.value)}
|
|
318
|
+
className="text-sm border border-white/[0.06] rounded-md px-3 py-2 bg-black/20 font-mono text-foreground/80 placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 focus:ring-1 focus:ring-[--cyan]/20 transition-colors" />
|
|
319
|
+
</div>
|
|
320
|
+
<div className="flex items-center gap-2">
|
|
321
|
+
<button onClick={handleAdd} disabled={loading || !name || !url}
|
|
322
|
+
className="inline-flex items-center gap-1.5 rounded-md px-4 py-2 text-xs font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors disabled:opacity-50">
|
|
323
|
+
{loading ? <SpinnerIcon size={12} /> : <PlusIcon size={12} />} Add Server
|
|
324
|
+
</button>
|
|
325
|
+
{result && (
|
|
326
|
+
<p className={`text-xs font-mono ${result.error ? 'text-[--destructive]' : 'text-green-500'}`}>
|
|
327
|
+
{result.error || 'Added successfully'}
|
|
328
|
+
</p>
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
134
335
|
// ─── Tool Card with Test ─────────────────────────────────────────────────────
|
|
135
336
|
|
|
136
337
|
function ToolCard({ tool, index = 0 }) {
|
|
@@ -167,8 +368,6 @@ function ToolCard({ tool, index = 0 }) {
|
|
|
167
368
|
<div className="flex-1 min-w-0">
|
|
168
369
|
<p className="text-sm font-medium font-mono truncate text-foreground">{tool.name}</p>
|
|
169
370
|
{tool.description && <p className="text-xs text-muted-foreground mt-0.5">{tool.description}</p>}
|
|
170
|
-
|
|
171
|
-
{/* Terminal-style test output */}
|
|
172
371
|
{hasResult && (
|
|
173
372
|
<div className="mt-2 rounded-md border border-white/[0.04] bg-black/30 overflow-hidden">
|
|
174
373
|
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border-b border-white/[0.04]">
|
|
@@ -178,11 +377,7 @@ function ToolCard({ tool, index = 0 }) {
|
|
|
178
377
|
<div className="w-2 h-2 rounded-full bg-[#28c840]" />
|
|
179
378
|
</div>
|
|
180
379
|
<span className="font-mono text-[9px] text-muted-foreground ml-1">output</span>
|
|
181
|
-
{isError ?
|
|
182
|
-
<XIcon size={10} className="text-[--destructive] ml-auto" />
|
|
183
|
-
) : (
|
|
184
|
-
<CheckIcon size={10} className="text-[--success] ml-auto" />
|
|
185
|
-
)}
|
|
380
|
+
{isError ? <XIcon size={10} className="text-[--destructive] ml-auto" /> : <CheckIcon size={10} className="text-[--success] ml-auto" />}
|
|
186
381
|
</div>
|
|
187
382
|
<pre className="text-[11px] p-2.5 font-mono overflow-auto max-h-32 whitespace-pre-wrap break-words text-foreground/80">
|
|
188
383
|
{isError ? `Error: ${result.error}` : JSON.stringify(result.result, null, 2)}
|
|
@@ -190,11 +385,8 @@ function ToolCard({ tool, index = 0 }) {
|
|
|
190
385
|
</div>
|
|
191
386
|
)}
|
|
192
387
|
</div>
|
|
193
|
-
<button
|
|
194
|
-
|
|
195
|
-
disabled={testing}
|
|
196
|
-
className="shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] hover:border-[--cyan]/30 hover:text-[--cyan] transition-colors disabled:opacity-50"
|
|
197
|
-
>
|
|
388
|
+
<button onClick={handleTest} disabled={testing}
|
|
389
|
+
className="shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] hover:border-[--cyan]/30 hover:text-[--cyan] transition-colors disabled:opacity-50">
|
|
198
390
|
{testing ? <SpinnerIcon size={12} /> : 'Test'}
|
|
199
391
|
</button>
|
|
200
392
|
</motion.div>
|
|
@@ -220,17 +412,23 @@ export function McpPage() {
|
|
|
220
412
|
const [servers, setServers] = useState([]);
|
|
221
413
|
const [status, setStatus] = useState(null);
|
|
222
414
|
const [serverInfo, setServerInfo] = useState(null);
|
|
415
|
+
const [registry, setRegistry] = useState({ servers: [], categories: [] });
|
|
416
|
+
const [search, setSearch] = useState('');
|
|
417
|
+
const [installing, setInstalling] = useState(false);
|
|
418
|
+
const [tab, setTab] = useState('servers');
|
|
223
419
|
|
|
224
420
|
async function load() {
|
|
225
421
|
try {
|
|
226
|
-
const [s, st, info] = await Promise.all([
|
|
422
|
+
const [s, st, info, reg] = await Promise.all([
|
|
227
423
|
getMcpServers(),
|
|
228
424
|
getMcpStatus(),
|
|
229
425
|
getOwnMcpServerInfo(),
|
|
426
|
+
getMcpRegistry(),
|
|
230
427
|
]);
|
|
231
428
|
setServers(s);
|
|
232
429
|
setStatus(st);
|
|
233
430
|
setServerInfo(info);
|
|
431
|
+
setRegistry(reg);
|
|
234
432
|
} catch {}
|
|
235
433
|
setLoading(false);
|
|
236
434
|
}
|
|
@@ -246,6 +444,29 @@ export function McpPage() {
|
|
|
246
444
|
setReloading(false);
|
|
247
445
|
}
|
|
248
446
|
|
|
447
|
+
async function handleAddFromRegistry(config) {
|
|
448
|
+
setInstalling(true);
|
|
449
|
+
await addMcpServer(config);
|
|
450
|
+
await load();
|
|
451
|
+
setInstalling(false);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function handleAddCustom(config) {
|
|
455
|
+
const result = await addMcpServer(config);
|
|
456
|
+
if (!result.error) await load();
|
|
457
|
+
return result;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function handleToggle(name) {
|
|
461
|
+
await toggleMcpServer(name);
|
|
462
|
+
await load();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function handleRemove(name) {
|
|
466
|
+
await removeMcpServer(name);
|
|
467
|
+
await load();
|
|
468
|
+
}
|
|
469
|
+
|
|
249
470
|
if (loading) {
|
|
250
471
|
return (
|
|
251
472
|
<div className="flex flex-col gap-3">
|
|
@@ -258,6 +479,19 @@ export function McpPage() {
|
|
|
258
479
|
|
|
259
480
|
const enabledServers = servers.filter(s => s.enabled !== false);
|
|
260
481
|
const disabledServers = servers.filter(s => s.enabled === false);
|
|
482
|
+
const serverNames = new Set(servers.map(s => s.name));
|
|
483
|
+
|
|
484
|
+
const filteredRegistry = registry.servers.filter(s => {
|
|
485
|
+
if (!search) return true;
|
|
486
|
+
const q = search.toLowerCase();
|
|
487
|
+
return s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q) || s.category.toLowerCase().includes(q);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const tabs = [
|
|
491
|
+
{ id: 'servers', label: 'SERVERS', count: servers.length },
|
|
492
|
+
{ id: 'hub', label: 'MCP HUB', count: registry.servers.length },
|
|
493
|
+
{ id: 'tools', label: 'TOOLS', count: status?.toolCount || 0 },
|
|
494
|
+
];
|
|
261
495
|
|
|
262
496
|
return (
|
|
263
497
|
<>
|
|
@@ -284,72 +518,141 @@ export function McpPage() {
|
|
|
284
518
|
</button>
|
|
285
519
|
</div>
|
|
286
520
|
|
|
287
|
-
{/*
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
<
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
))}
|
|
304
|
-
{serverInfo.prompts.map((p, i) => (
|
|
305
|
-
<ItemCard key={p.name} name={p.name} description={p.description} badge="prompt" index={serverInfo.tools.length + serverInfo.resources.length + i} />
|
|
306
|
-
))}
|
|
307
|
-
</div>
|
|
308
|
-
</>
|
|
309
|
-
)}
|
|
521
|
+
{/* Tabs */}
|
|
522
|
+
<div className="flex gap-1 border-b border-white/[0.06] mb-4">
|
|
523
|
+
{tabs.map(t => (
|
|
524
|
+
<button
|
|
525
|
+
key={t.id}
|
|
526
|
+
onClick={() => setTab(t.id)}
|
|
527
|
+
className={`px-4 py-2.5 text-[11px] font-mono font-medium uppercase tracking-wider border-b-2 transition-colors ${
|
|
528
|
+
tab === t.id
|
|
529
|
+
? 'border-[--cyan] text-[--cyan]'
|
|
530
|
+
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
531
|
+
}`}
|
|
532
|
+
>
|
|
533
|
+
{t.label} <span className="text-muted-foreground">({t.count})</span>
|
|
534
|
+
</button>
|
|
535
|
+
))}
|
|
536
|
+
</div>
|
|
310
537
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
538
|
+
<AnimatePresence mode="wait">
|
|
539
|
+
{/* Servers tab */}
|
|
540
|
+
{tab === 'servers' && (
|
|
541
|
+
<motion.div key="servers" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
|
542
|
+
{/* ── Server — Exposed MCP ── */}
|
|
543
|
+
{serverInfo && (
|
|
544
|
+
<>
|
|
545
|
+
<SectionHeader label="Server — Exposed to External Clients" count={serverInfo.tools.length + serverInfo.resources.length} />
|
|
546
|
+
<div className="flex items-center gap-2 mb-3">
|
|
547
|
+
<span className="inline-flex items-center rounded-md bg-[--card] border border-white/[0.06] px-2.5 py-1 text-[11px] font-mono text-muted-foreground">
|
|
548
|
+
/api/mcp
|
|
549
|
+
</span>
|
|
550
|
+
<div className="w-1.5 h-1.5 rounded-full bg-[--success]" />
|
|
551
|
+
</div>
|
|
552
|
+
<div className="flex flex-col gap-2 mb-2">
|
|
553
|
+
{serverInfo.tools.map((t, i) => (
|
|
554
|
+
<ItemCard key={t.name} name={t.name} description={t.description} badge="tool" index={i} />
|
|
555
|
+
))}
|
|
556
|
+
{serverInfo.resources.map((r, i) => (
|
|
557
|
+
<ItemCard key={r.uri} name={r.uri} description={r.description} badge="resource" index={serverInfo.tools.length + i} />
|
|
558
|
+
))}
|
|
559
|
+
{serverInfo.prompts.map((p, i) => (
|
|
560
|
+
<ItemCard key={p.name} name={p.name} description={p.description} badge="prompt" index={serverInfo.tools.length + serverInfo.resources.length + i} />
|
|
561
|
+
))}
|
|
562
|
+
</div>
|
|
563
|
+
</>
|
|
564
|
+
)}
|
|
565
|
+
|
|
566
|
+
{/* ── Client — External MCP Servers ── */}
|
|
567
|
+
<SectionHeader label="Client — External MCP Servers" count={servers.length} />
|
|
568
|
+
|
|
569
|
+
<AddServerForm onAdd={handleAddCustom} />
|
|
570
|
+
|
|
571
|
+
{servers.length === 0 ? (
|
|
572
|
+
<div className="flex flex-col items-center justify-center py-12 text-center rounded-lg border border-white/[0.06] bg-[--card]">
|
|
573
|
+
<div className="rounded-full bg-[--cyan]/10 p-4 mb-4">
|
|
574
|
+
<PlugIcon size={24} className="text-[--cyan]" />
|
|
575
|
+
</div>
|
|
576
|
+
<p className="text-sm font-medium mb-1 text-foreground">No external MCP servers configured</p>
|
|
577
|
+
<p className="text-xs text-muted-foreground max-w-sm">
|
|
578
|
+
Add servers from the <button onClick={() => setTab('hub')} className="text-[--cyan] hover:underline">MCP Hub</button> or add a custom server above.
|
|
579
|
+
</p>
|
|
580
|
+
</div>
|
|
581
|
+
) : (
|
|
582
|
+
<div className="flex flex-col gap-3">
|
|
583
|
+
{enabledServers.map((s, i) => (
|
|
584
|
+
<ServerCard key={`enabled-${s.name}-${i}`} server={s} onToggle={handleToggle} onRemove={handleRemove} index={i} />
|
|
585
|
+
))}
|
|
586
|
+
{disabledServers.length > 0 && (
|
|
587
|
+
<>
|
|
588
|
+
<div className="flex items-center gap-2 pt-2 pb-1">
|
|
589
|
+
<span className="font-mono text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Disabled</span>
|
|
590
|
+
<span className="text-[10px] font-mono text-muted-foreground">({disabledServers.length})</span>
|
|
591
|
+
</div>
|
|
592
|
+
{disabledServers.map((s, i) => (
|
|
593
|
+
<ServerCard key={`disabled-${s.name}-${i}`} server={s} onToggle={handleToggle} onRemove={handleRemove} index={i} />
|
|
594
|
+
))}
|
|
595
|
+
</>
|
|
596
|
+
)}
|
|
333
597
|
</div>
|
|
334
|
-
|
|
335
|
-
|
|
598
|
+
)}
|
|
599
|
+
</motion.div>
|
|
600
|
+
)}
|
|
601
|
+
|
|
602
|
+
{/* Hub tab */}
|
|
603
|
+
{tab === 'hub' && (
|
|
604
|
+
<motion.div key="hub" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
|
605
|
+
{/* Search */}
|
|
606
|
+
<div className="relative mb-4">
|
|
607
|
+
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"><SearchIcon size={14} /></div>
|
|
608
|
+
<input
|
|
609
|
+
placeholder="Search MCP servers..."
|
|
610
|
+
value={search}
|
|
611
|
+
onChange={e => setSearch(e.target.value)}
|
|
612
|
+
className="w-full text-sm border border-white/[0.06] rounded-md pl-9 pr-3 py-2 bg-black/20 font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 focus:ring-1 focus:ring-[--cyan]/20 transition-colors"
|
|
613
|
+
/>
|
|
614
|
+
</div>
|
|
615
|
+
|
|
616
|
+
<div className="flex flex-col gap-2">
|
|
617
|
+
{filteredRegistry.map((s, i) => (
|
|
618
|
+
<RegistryCard
|
|
619
|
+
key={s.id}
|
|
620
|
+
server={s}
|
|
621
|
+
installed={serverNames.has(s.name)}
|
|
622
|
+
onInstall={handleAddFromRegistry}
|
|
623
|
+
installing={installing}
|
|
624
|
+
index={i}
|
|
625
|
+
/>
|
|
336
626
|
))}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
627
|
+
{filteredRegistry.length === 0 && (
|
|
628
|
+
<div className="flex flex-col items-center py-12 text-center">
|
|
629
|
+
<div className="rounded-full bg-white/[0.04] border border-white/[0.06] p-4 mb-4"><SearchIcon size={24} /></div>
|
|
630
|
+
<p className="text-sm font-mono text-muted-foreground">No servers match your search.</p>
|
|
631
|
+
</div>
|
|
632
|
+
)}
|
|
633
|
+
</div>
|
|
634
|
+
</motion.div>
|
|
635
|
+
)}
|
|
341
636
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
637
|
+
{/* Tools tab */}
|
|
638
|
+
{tab === 'tools' && (
|
|
639
|
+
<motion.div key="tools" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
|
640
|
+
{status?.tools?.length > 0 ? (
|
|
641
|
+
<div className="flex flex-col gap-2">
|
|
642
|
+
{status.tools.map((t, i) => (
|
|
643
|
+
<ToolCard key={t.name} tool={t} index={i} />
|
|
644
|
+
))}
|
|
645
|
+
</div>
|
|
646
|
+
) : (
|
|
647
|
+
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
648
|
+
<div className="rounded-full bg-white/[0.04] border border-white/[0.06] p-4 mb-4"><WrenchIcon size={24} /></div>
|
|
649
|
+
<p className="text-sm font-mono font-medium mb-1">No tools loaded</p>
|
|
650
|
+
<p className="text-[11px] text-muted-foreground font-mono">Add MCP servers to load external tools.</p>
|
|
651
|
+
</div>
|
|
652
|
+
)}
|
|
653
|
+
</motion.div>
|
|
654
|
+
)}
|
|
655
|
+
</AnimatePresence>
|
|
353
656
|
</>
|
|
354
657
|
);
|
|
355
658
|
}
|