@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.
@@ -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
- : 'bg-orange-500/10 text-orange-400 border border-orange-500/20'
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
- {expanded && (
109
- <div className="border-t border-white/[0.06] px-4 py-3">
110
- <div className="flex flex-col gap-1.5">
111
- <div className="flex items-center gap-2">
112
- <span className="font-mono text-[10px] font-medium text-muted-foreground uppercase tracking-wider">URL</span>
113
- <span className="text-xs font-mono text-foreground/80">{server.url}</span>
114
- </div>
115
- <div className="flex items-center gap-2">
116
- <span className="font-mono text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Transport</span>
117
- <span className="text-xs font-mono text-foreground/80">{server.transport || 'http'}</span>
118
- </div>
119
- {server.headers && Object.keys(server.headers).length > 0 && (
120
- <div>
121
- <span className="font-mono text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Headers</span>
122
- <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]">
123
- {JSON.stringify(server.headers, null, 2)}
124
- </pre>
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
- </div>
128
- </div>
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
- onClick={handleTest}
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
- {/* ── Server — Exposed MCP ── */}
288
- {serverInfo && (
289
- <>
290
- <SectionHeader label="Server — Exposed to External Clients" count={serverInfo.tools.length + serverInfo.resources.length} />
291
- <div className="flex items-center gap-2 mb-3">
292
- <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">
293
- /api/mcp
294
- </span>
295
- <div className="w-1.5 h-1.5 rounded-full bg-[--success]" />
296
- </div>
297
- <div className="flex flex-col gap-2 mb-2">
298
- {serverInfo.tools.map((t, i) => (
299
- <ItemCard key={t.name} name={t.name} description={t.description} badge="tool" index={i} />
300
- ))}
301
- {serverInfo.resources.map((r, i) => (
302
- <ItemCard key={r.uri} name={r.uri} description={r.description} badge="resource" index={serverInfo.tools.length + i} />
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
- {/* ── Client — External MCP Servers ── */}
312
- <SectionHeader label="Client External MCP Servers" count={servers.length} />
313
- {servers.length === 0 ? (
314
- <div className="flex flex-col items-center justify-center py-12 text-center rounded-lg border border-white/[0.06] bg-[--card]">
315
- <div className="rounded-full bg-[--cyan]/10 p-4 mb-4">
316
- <PlugIcon size={24} className="text-[--cyan]" />
317
- </div>
318
- <p className="text-sm font-medium mb-1 text-foreground">No external MCP servers configured</p>
319
- <p className="text-xs text-muted-foreground max-w-sm">
320
- Add servers by editing <span className="font-mono text-[--cyan]">config/MCP_SERVERS.json</span> in your project.
321
- </p>
322
- </div>
323
- ) : (
324
- <div className="flex flex-col gap-3">
325
- {enabledServers.length > 0 && enabledServers.map((s, i) => (
326
- <ServerCard key={`enabled-${i}`} server={s} index={i} />
327
- ))}
328
- {disabledServers.length > 0 && (
329
- <>
330
- <div className="flex items-center gap-2 pt-2 pb-1">
331
- <span className="font-mono text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Disabled</span>
332
- <span className="text-[10px] font-mono text-muted-foreground">({disabledServers.length})</span>
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
- {disabledServers.map((s, i) => (
335
- <ServerCard key={`disabled-${i}`} server={s} index={i} />
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
- </div>
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
- {/* ── Loaded External Tools ── */}
343
- {status?.tools?.length > 0 && (
344
- <>
345
- <SectionHeader label="Loaded External Tools" count={status.tools.length} />
346
- <div className="flex flex-col gap-2">
347
- {status.tools.map((t, i) => (
348
- <ToolCard key={t.name} tool={t} index={i} />
349
- ))}
350
- </div>
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
  }