apteva 0.2.3 → 0.2.6

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.
Files changed (38) hide show
  1. package/dist/App.0mzj9cz9.js +213 -0
  2. package/dist/index.html +1 -1
  3. package/dist/styles.css +1 -1
  4. package/package.json +6 -6
  5. package/src/binary.ts +271 -1
  6. package/src/crypto.ts +53 -0
  7. package/src/db.ts +492 -3
  8. package/src/mcp-client.ts +599 -0
  9. package/src/providers.ts +31 -0
  10. package/src/routes/api.ts +832 -64
  11. package/src/server.ts +169 -5
  12. package/src/web/App.tsx +44 -2
  13. package/src/web/components/agents/AgentCard.tsx +53 -9
  14. package/src/web/components/agents/AgentPanel.tsx +381 -0
  15. package/src/web/components/agents/AgentsView.tsx +27 -10
  16. package/src/web/components/agents/CreateAgentModal.tsx +7 -7
  17. package/src/web/components/agents/index.ts +1 -1
  18. package/src/web/components/common/Icons.tsx +8 -0
  19. package/src/web/components/common/Modal.tsx +2 -2
  20. package/src/web/components/common/Select.tsx +1 -1
  21. package/src/web/components/common/index.ts +1 -0
  22. package/src/web/components/dashboard/Dashboard.tsx +74 -25
  23. package/src/web/components/index.ts +5 -2
  24. package/src/web/components/layout/Sidebar.tsx +22 -2
  25. package/src/web/components/mcp/McpPage.tsx +1144 -0
  26. package/src/web/components/mcp/index.ts +1 -0
  27. package/src/web/components/onboarding/OnboardingWizard.tsx +5 -1
  28. package/src/web/components/settings/SettingsPage.tsx +312 -82
  29. package/src/web/components/tasks/TasksPage.tsx +129 -0
  30. package/src/web/components/tasks/index.ts +1 -0
  31. package/src/web/components/telemetry/TelemetryPage.tsx +359 -0
  32. package/src/web/context/TelemetryContext.tsx +202 -0
  33. package/src/web/context/index.ts +2 -0
  34. package/src/web/hooks/useAgents.ts +23 -0
  35. package/src/web/styles.css +18 -0
  36. package/src/web/types.ts +75 -1
  37. package/dist/App.wfhmfhx7.js +0 -213
  38. package/src/web/components/agents/ChatPanel.tsx +0 -63
@@ -0,0 +1,1144 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { McpIcon } from "../common/Icons";
3
+ import type { McpTool, McpToolCallResult } from "../../types";
4
+
5
+ interface McpServer {
6
+ id: string;
7
+ name: string;
8
+ type: "npm" | "github" | "http" | "custom";
9
+ package: string | null;
10
+ command: string | null;
11
+ args: string | null;
12
+ env: Record<string, string>;
13
+ port: number | null;
14
+ status: "stopped" | "running";
15
+ created_at: string;
16
+ }
17
+
18
+ interface RegistryServer {
19
+ name: string;
20
+ description: string;
21
+ vendor: string;
22
+ sourceUrl: string;
23
+ npmPackage: string | null;
24
+ githubStars: number | null;
25
+ }
26
+
27
+ export function McpPage() {
28
+ const [servers, setServers] = useState<McpServer[]>([]);
29
+ const [loading, setLoading] = useState(true);
30
+ const [showAdd, setShowAdd] = useState(false);
31
+ const [selectedServer, setSelectedServer] = useState<McpServer | null>(null);
32
+ const [activeTab, setActiveTab] = useState<"servers" | "registry">("servers");
33
+
34
+ const fetchServers = async () => {
35
+ try {
36
+ const res = await fetch("/api/mcp/servers");
37
+ const data = await res.json();
38
+ setServers(data.servers || []);
39
+ } catch (e) {
40
+ console.error("Failed to fetch MCP servers:", e);
41
+ }
42
+ setLoading(false);
43
+ };
44
+
45
+ useEffect(() => {
46
+ fetchServers();
47
+ }, []);
48
+
49
+ const startServer = async (id: string) => {
50
+ try {
51
+ await fetch(`/api/mcp/servers/${id}/start`, { method: "POST" });
52
+ fetchServers();
53
+ } catch (e) {
54
+ console.error("Failed to start server:", e);
55
+ }
56
+ };
57
+
58
+ const stopServer = async (id: string) => {
59
+ try {
60
+ await fetch(`/api/mcp/servers/${id}/stop`, { method: "POST" });
61
+ fetchServers();
62
+ } catch (e) {
63
+ console.error("Failed to stop server:", e);
64
+ }
65
+ };
66
+
67
+ const deleteServer = async (id: string) => {
68
+ if (!confirm("Delete this MCP server?")) return;
69
+ try {
70
+ await fetch(`/api/mcp/servers/${id}`, { method: "DELETE" });
71
+ if (selectedServer?.id === id) {
72
+ setSelectedServer(null);
73
+ }
74
+ fetchServers();
75
+ } catch (e) {
76
+ console.error("Failed to delete server:", e);
77
+ }
78
+ };
79
+
80
+ return (
81
+ <div className="flex-1 overflow-auto p-6">
82
+ <div className="max-w-6xl">
83
+ {/* Header */}
84
+ <div className="flex items-center justify-between mb-6">
85
+ <div>
86
+ <h1 className="text-2xl font-semibold mb-1">MCP Servers</h1>
87
+ <p className="text-[#666]">
88
+ Manage Model Context Protocol servers for tool integrations.
89
+ </p>
90
+ </div>
91
+ {activeTab === "servers" && (
92
+ <button
93
+ onClick={() => setShowAdd(true)}
94
+ className="bg-[#f97316] hover:bg-[#fb923c] text-black px-4 py-2 rounded font-medium transition"
95
+ >
96
+ + Add Server
97
+ </button>
98
+ )}
99
+ </div>
100
+
101
+ {/* Tabs */}
102
+ <div className="flex gap-1 mb-6 bg-[#111] border border-[#1a1a1a] rounded-lg p-1 w-fit">
103
+ <button
104
+ onClick={() => setActiveTab("servers")}
105
+ className={`px-4 py-2 rounded text-sm font-medium transition ${
106
+ activeTab === "servers"
107
+ ? "bg-[#1a1a1a] text-white"
108
+ : "text-[#666] hover:text-[#888]"
109
+ }`}
110
+ >
111
+ My Servers
112
+ </button>
113
+ <button
114
+ onClick={() => setActiveTab("registry")}
115
+ className={`px-4 py-2 rounded text-sm font-medium transition ${
116
+ activeTab === "registry"
117
+ ? "bg-[#1a1a1a] text-white"
118
+ : "text-[#666] hover:text-[#888]"
119
+ }`}
120
+ >
121
+ Browse Registry
122
+ </button>
123
+ </div>
124
+
125
+ {/* My Servers Tab */}
126
+ {activeTab === "servers" && (
127
+ <>
128
+ {/* Loading */}
129
+ {loading && (
130
+ <div className="text-center py-8 text-[#666]">Loading...</div>
131
+ )}
132
+
133
+ {/* Empty State */}
134
+ {!loading && servers.length === 0 && (
135
+ <div className="bg-[#111] border border-[#1a1a1a] rounded-lg p-8 text-center">
136
+ <McpIcon className="w-12 h-12 text-[#333] mx-auto mb-4" />
137
+ <h3 className="text-lg font-medium mb-2">No MCP servers configured</h3>
138
+ <p className="text-[#666] mb-6 max-w-md mx-auto">
139
+ MCP servers extend your agents with tools like file access, web browsing,
140
+ database connections, and more.
141
+ </p>
142
+ <div className="flex gap-3 justify-center">
143
+ <button
144
+ onClick={() => setShowAdd(true)}
145
+ className="bg-[#f97316] hover:bg-[#fb923c] text-black px-4 py-2 rounded font-medium transition"
146
+ >
147
+ Add Manually
148
+ </button>
149
+ <button
150
+ onClick={() => setActiveTab("registry")}
151
+ className="border border-[#333] hover:border-[#666] px-4 py-2 rounded font-medium transition"
152
+ >
153
+ Browse Registry
154
+ </button>
155
+ </div>
156
+ </div>
157
+ )}
158
+
159
+ {/* Main content with server list and tools panel */}
160
+ {!loading && servers.length > 0 && (
161
+ <div className="flex gap-6">
162
+ {/* Server List */}
163
+ <div className={`space-y-3 ${selectedServer ? "w-1/2" : "w-full"}`}>
164
+ {servers.map(server => (
165
+ <McpServerCard
166
+ key={server.id}
167
+ server={server}
168
+ selected={selectedServer?.id === server.id}
169
+ onSelect={() => setSelectedServer(server.status === "running" ? server : null)}
170
+ onStart={() => startServer(server.id)}
171
+ onStop={() => stopServer(server.id)}
172
+ onDelete={() => deleteServer(server.id)}
173
+ />
174
+ ))}
175
+ </div>
176
+
177
+ {/* Tools Panel */}
178
+ {selectedServer && (
179
+ <div className="w-1/2">
180
+ <ToolsPanel
181
+ server={selectedServer}
182
+ onClose={() => setSelectedServer(null)}
183
+ />
184
+ </div>
185
+ )}
186
+ </div>
187
+ )}
188
+ </>
189
+ )}
190
+
191
+ {/* Browse Registry Tab */}
192
+ {activeTab === "registry" && (
193
+ <RegistryBrowser
194
+ onInstall={(server) => {
195
+ // After installing, switch to servers tab and refresh
196
+ fetchServers();
197
+ setActiveTab("servers");
198
+ }}
199
+ />
200
+ )}
201
+
202
+ {/* Info - only show on servers tab */}
203
+ {activeTab === "servers" && (
204
+ <div className="mt-8 p-4 bg-[#111] border border-[#1a1a1a] rounded-lg">
205
+ <h3 className="font-medium mb-2">Quick Start</h3>
206
+ <p className="text-sm text-[#666] mb-3">
207
+ Add an MCP server by providing its npm package name. For example:
208
+ </p>
209
+ <div className="flex flex-wrap gap-2">
210
+ {[
211
+ { name: "filesystem", pkg: "@modelcontextprotocol/server-filesystem" },
212
+ { name: "fetch", pkg: "@modelcontextprotocol/server-fetch" },
213
+ { name: "memory", pkg: "@modelcontextprotocol/server-memory" },
214
+ ].map(s => (
215
+ <code key={s.name} className="text-xs bg-[#0a0a0a] px-2 py-1 rounded">
216
+ {s.pkg}
217
+ </code>
218
+ ))}
219
+ </div>
220
+ </div>
221
+ )}
222
+ </div>
223
+
224
+ {/* Add Server Modal */}
225
+ {showAdd && (
226
+ <AddServerModal
227
+ onClose={() => setShowAdd(false)}
228
+ onAdded={() => {
229
+ setShowAdd(false);
230
+ fetchServers();
231
+ }}
232
+ />
233
+ )}
234
+ </div>
235
+ );
236
+ }
237
+
238
+ function McpServerCard({
239
+ server,
240
+ selected,
241
+ onSelect,
242
+ onStart,
243
+ onStop,
244
+ onDelete,
245
+ }: {
246
+ server: McpServer;
247
+ selected: boolean;
248
+ onSelect: () => void;
249
+ onStart: () => void;
250
+ onStop: () => void;
251
+ onDelete: () => void;
252
+ }) {
253
+ return (
254
+ <div
255
+ className={`bg-[#111] border rounded-lg p-4 cursor-pointer transition ${
256
+ selected ? "border-[#f97316]" : "border-[#1a1a1a] hover:border-[#333]"
257
+ }`}
258
+ onClick={server.status === "running" ? onSelect : undefined}
259
+ >
260
+ <div className="flex items-center justify-between">
261
+ <div className="flex items-center gap-3">
262
+ <div className={`w-2 h-2 rounded-full ${
263
+ server.status === "running" ? "bg-green-400" : "bg-[#444]"
264
+ }`} />
265
+ <div>
266
+ <h3 className="font-medium">{server.name}</h3>
267
+ <p className="text-sm text-[#666]">
268
+ {server.type} • {server.package || server.command || "custom"}
269
+ {server.status === "running" && server.port && ` • :${server.port}`}
270
+ </p>
271
+ </div>
272
+ </div>
273
+ <div className="flex items-center gap-2">
274
+ {server.status === "running" ? (
275
+ <>
276
+ <button
277
+ onClick={(e) => { e.stopPropagation(); onSelect(); }}
278
+ className="text-sm text-[#f97316] hover:text-[#fb923c] px-3 py-1 transition"
279
+ >
280
+ Tools
281
+ </button>
282
+ <button
283
+ onClick={(e) => { e.stopPropagation(); onStop(); }}
284
+ className="text-sm text-[#666] hover:text-red-400 px-3 py-1 transition"
285
+ >
286
+ Stop
287
+ </button>
288
+ </>
289
+ ) : (
290
+ <button
291
+ onClick={(e) => { e.stopPropagation(); onStart(); }}
292
+ className="text-sm text-[#666] hover:text-green-400 px-3 py-1 transition"
293
+ >
294
+ Start
295
+ </button>
296
+ )}
297
+ <button
298
+ onClick={(e) => { e.stopPropagation(); onDelete(); }}
299
+ className="text-sm text-[#666] hover:text-red-400 px-3 py-1 transition"
300
+ >
301
+ Delete
302
+ </button>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ );
307
+ }
308
+
309
+ function ToolsPanel({
310
+ server,
311
+ onClose,
312
+ }: {
313
+ server: McpServer;
314
+ onClose: () => void;
315
+ }) {
316
+ const [tools, setTools] = useState<McpTool[]>([]);
317
+ const [serverInfo, setServerInfo] = useState<{ name: string; version: string } | null>(null);
318
+ const [loading, setLoading] = useState(true);
319
+ const [error, setError] = useState<string | null>(null);
320
+ const [selectedTool, setSelectedTool] = useState<McpTool | null>(null);
321
+
322
+ useEffect(() => {
323
+ const fetchTools = async () => {
324
+ setLoading(true);
325
+ setError(null);
326
+ try {
327
+ const res = await fetch(`/api/mcp/servers/${server.id}/tools`);
328
+ const data = await res.json();
329
+ if (!res.ok) {
330
+ setError(data.error || "Failed to fetch tools");
331
+ return;
332
+ }
333
+ setTools(data.tools || []);
334
+ setServerInfo(data.serverInfo || null);
335
+ } catch (e) {
336
+ setError(`Failed to fetch tools: ${e}`);
337
+ } finally {
338
+ setLoading(false);
339
+ }
340
+ };
341
+
342
+ fetchTools();
343
+ }, [server.id]);
344
+
345
+ return (
346
+ <div className="bg-[#111] border border-[#1a1a1a] rounded-lg overflow-hidden">
347
+ {/* Header */}
348
+ <div className="p-4 border-b border-[#1a1a1a] flex items-center justify-between">
349
+ <div>
350
+ <h3 className="font-medium">{server.name} Tools</h3>
351
+ {serverInfo && (
352
+ <p className="text-xs text-[#666]">
353
+ {serverInfo.name} v{serverInfo.version}
354
+ </p>
355
+ )}
356
+ </div>
357
+ <button
358
+ onClick={onClose}
359
+ className="text-[#666] hover:text-[#888] text-xl leading-none"
360
+ >
361
+ ×
362
+ </button>
363
+ </div>
364
+
365
+ {/* Content */}
366
+ <div className="p-4 max-h-[500px] overflow-auto">
367
+ {loading && <p className="text-[#666]">Loading tools...</p>}
368
+
369
+ {error && (
370
+ <div className="text-red-400 text-sm p-3 bg-red-500/10 rounded">
371
+ {error}
372
+ </div>
373
+ )}
374
+
375
+ {!loading && !error && tools.length === 0 && (
376
+ <p className="text-[#666]">No tools available from this server.</p>
377
+ )}
378
+
379
+ {!loading && !error && tools.length > 0 && !selectedTool && (
380
+ <div className="space-y-2">
381
+ {tools.map(tool => (
382
+ <button
383
+ key={tool.name}
384
+ onClick={() => setSelectedTool(tool)}
385
+ className="w-full text-left p-3 bg-[#0a0a0a] hover:bg-[#1a1a1a] border border-[#222] hover:border-[#333] rounded transition"
386
+ >
387
+ <div className="font-medium text-sm">{tool.name}</div>
388
+ {tool.description && (
389
+ <div className="text-xs text-[#666] mt-1">{tool.description}</div>
390
+ )}
391
+ </button>
392
+ ))}
393
+ </div>
394
+ )}
395
+
396
+ {selectedTool && (
397
+ <ToolTester
398
+ serverId={server.id}
399
+ tool={selectedTool}
400
+ onBack={() => setSelectedTool(null)}
401
+ />
402
+ )}
403
+ </div>
404
+ </div>
405
+ );
406
+ }
407
+
408
+ function ToolTester({
409
+ serverId,
410
+ tool,
411
+ onBack,
412
+ }: {
413
+ serverId: string;
414
+ tool: McpTool;
415
+ onBack: () => void;
416
+ }) {
417
+ const [args, setArgs] = useState<string>("{}");
418
+ const [result, setResult] = useState<McpToolCallResult | null>(null);
419
+ const [error, setError] = useState<string | null>(null);
420
+ const [loading, setLoading] = useState(false);
421
+
422
+ // Generate default args from schema
423
+ useEffect(() => {
424
+ const schema = tool.inputSchema;
425
+ if (schema && typeof schema === "object" && "properties" in schema) {
426
+ const properties = schema.properties as Record<string, { type?: string; default?: unknown }>;
427
+ const defaultArgs: Record<string, unknown> = {};
428
+ for (const [key, prop] of Object.entries(properties)) {
429
+ if (prop.default !== undefined) {
430
+ defaultArgs[key] = prop.default;
431
+ } else if (prop.type === "string") {
432
+ defaultArgs[key] = "";
433
+ } else if (prop.type === "number" || prop.type === "integer") {
434
+ defaultArgs[key] = 0;
435
+ } else if (prop.type === "boolean") {
436
+ defaultArgs[key] = false;
437
+ } else if (prop.type === "array") {
438
+ defaultArgs[key] = [];
439
+ } else if (prop.type === "object") {
440
+ defaultArgs[key] = {};
441
+ }
442
+ }
443
+ setArgs(JSON.stringify(defaultArgs, null, 2));
444
+ }
445
+ }, [tool]);
446
+
447
+ const callTool = async () => {
448
+ setLoading(true);
449
+ setError(null);
450
+ setResult(null);
451
+
452
+ try {
453
+ const parsedArgs = JSON.parse(args);
454
+ const res = await fetch(`/api/mcp/servers/${serverId}/tools/${encodeURIComponent(tool.name)}/call`, {
455
+ method: "POST",
456
+ headers: { "Content-Type": "application/json" },
457
+ body: JSON.stringify({ arguments: parsedArgs }),
458
+ });
459
+ const data = await res.json();
460
+
461
+ if (!res.ok) {
462
+ setError(data.error || "Failed to call tool");
463
+ return;
464
+ }
465
+
466
+ setResult(data.result);
467
+ } catch (e) {
468
+ setError(`Error: ${e}`);
469
+ } finally {
470
+ setLoading(false);
471
+ }
472
+ };
473
+
474
+ return (
475
+ <div className="space-y-4">
476
+ {/* Header */}
477
+ <div className="flex items-center gap-2">
478
+ <button
479
+ onClick={onBack}
480
+ className="text-[#666] hover:text-[#888] text-sm"
481
+ >
482
+ ← Back
483
+ </button>
484
+ <span className="text-[#444]">/</span>
485
+ <span className="font-medium">{tool.name}</span>
486
+ </div>
487
+
488
+ {/* Description */}
489
+ {tool.description && (
490
+ <p className="text-sm text-[#666]">{tool.description}</p>
491
+ )}
492
+
493
+ {/* Schema info */}
494
+ {tool.inputSchema && (
495
+ <div className="text-xs">
496
+ <details className="cursor-pointer">
497
+ <summary className="text-[#666] hover:text-[#888]">Input Schema</summary>
498
+ <pre className="mt-2 p-2 bg-[#0a0a0a] rounded text-[#888] overflow-auto max-h-32">
499
+ {JSON.stringify(tool.inputSchema, null, 2)}
500
+ </pre>
501
+ </details>
502
+ </div>
503
+ )}
504
+
505
+ {/* Arguments input */}
506
+ <div>
507
+ <label className="block text-sm text-[#666] mb-1">Arguments (JSON)</label>
508
+ <textarea
509
+ value={args}
510
+ onChange={(e) => setArgs(e.target.value)}
511
+ className="w-full bg-[#0a0a0a] border border-[#333] rounded px-3 py-2 h-32 font-mono text-sm focus:outline-none focus:border-[#f97316] resize-none"
512
+ placeholder="{}"
513
+ />
514
+ </div>
515
+
516
+ {/* Call button */}
517
+ <button
518
+ onClick={callTool}
519
+ disabled={loading}
520
+ className="w-full bg-[#f97316] hover:bg-[#fb923c] disabled:opacity-50 text-black px-4 py-2 rounded font-medium transition"
521
+ >
522
+ {loading ? "Calling..." : "Call Tool"}
523
+ </button>
524
+
525
+ {/* Error */}
526
+ {error && (
527
+ <div className="text-red-400 text-sm p-3 bg-red-500/10 rounded">
528
+ {error}
529
+ </div>
530
+ )}
531
+
532
+ {/* Result */}
533
+ {result && (
534
+ <div className="space-y-2">
535
+ <div className="text-sm text-[#666]">
536
+ Result {result.isError && <span className="text-red-400">(error)</span>}
537
+ </div>
538
+ <div className={`p-3 rounded text-sm ${result.isError ? "bg-red-500/10" : "bg-green-500/10"}`}>
539
+ {result.content.map((block, i) => (
540
+ <div key={i} className="mb-2 last:mb-0">
541
+ {block.type === "text" && (
542
+ <pre className="whitespace-pre-wrap font-mono text-xs">
543
+ {block.text}
544
+ </pre>
545
+ )}
546
+ {block.type === "image" && block.data && (
547
+ <img
548
+ src={`data:${block.mimeType || "image/png"};base64,${block.data}`}
549
+ alt="Tool result"
550
+ className="max-w-full rounded"
551
+ />
552
+ )}
553
+ </div>
554
+ ))}
555
+ </div>
556
+ </div>
557
+ )}
558
+ </div>
559
+ );
560
+ }
561
+
562
+ function RegistryBrowser({
563
+ onInstall,
564
+ }: {
565
+ onInstall: (server: RegistryServer) => void;
566
+ }) {
567
+ const [search, setSearch] = useState("");
568
+ const [servers, setServers] = useState<RegistryServer[]>([]);
569
+ const [loading, setLoading] = useState(false);
570
+ const [searched, setSearched] = useState(false);
571
+ const [installing, setInstalling] = useState<string | null>(null);
572
+ const [error, setError] = useState<string | null>(null);
573
+
574
+ const searchRegistry = async (query: string) => {
575
+ setLoading(true);
576
+ setError(null);
577
+ try {
578
+ const res = await fetch(`/api/mcp/registry?search=${encodeURIComponent(query)}&limit=20`);
579
+ const data = await res.json();
580
+ if (!res.ok) {
581
+ setError(data.error || "Failed to search registry");
582
+ setServers([]);
583
+ } else {
584
+ setServers(data.servers || []);
585
+ }
586
+ } catch (e) {
587
+ setError(`Failed to search: ${e}`);
588
+ setServers([]);
589
+ } finally {
590
+ setLoading(false);
591
+ setSearched(true);
592
+ }
593
+ };
594
+
595
+ const handleSearch = (e: React.FormEvent) => {
596
+ e.preventDefault();
597
+ if (search.trim()) {
598
+ searchRegistry(search.trim());
599
+ }
600
+ };
601
+
602
+ // Load popular servers on mount
603
+ useEffect(() => {
604
+ searchRegistry("");
605
+ }, []);
606
+
607
+ const installServer = async (server: RegistryServer) => {
608
+ if (!server.npmPackage) {
609
+ setError("This server does not have an npm package");
610
+ return;
611
+ }
612
+
613
+ setInstalling(server.name);
614
+ setError(null);
615
+
616
+ try {
617
+ const res = await fetch("/api/mcp/servers", {
618
+ method: "POST",
619
+ headers: { "Content-Type": "application/json" },
620
+ body: JSON.stringify({
621
+ name: server.name,
622
+ type: "npm",
623
+ package: server.npmPackage,
624
+ }),
625
+ });
626
+
627
+ if (!res.ok) {
628
+ const data = await res.json();
629
+ setError(data.error || "Failed to add server");
630
+ return;
631
+ }
632
+
633
+ onInstall(server);
634
+ } catch (e) {
635
+ setError(`Failed to add server: ${e}`);
636
+ } finally {
637
+ setInstalling(null);
638
+ }
639
+ };
640
+
641
+ return (
642
+ <div className="space-y-6">
643
+ {/* Search */}
644
+ <form onSubmit={handleSearch} className="flex gap-2">
645
+ <input
646
+ type="text"
647
+ value={search}
648
+ onChange={(e) => setSearch(e.target.value)}
649
+ placeholder="Search MCP servers (e.g., filesystem, github, slack...)"
650
+ className="flex-1 bg-[#111] border border-[#333] rounded-lg px-4 py-3 focus:outline-none focus:border-[#f97316]"
651
+ />
652
+ <button
653
+ type="submit"
654
+ disabled={loading}
655
+ className="bg-[#f97316] hover:bg-[#fb923c] disabled:opacity-50 text-black px-6 py-3 rounded-lg font-medium transition"
656
+ >
657
+ {loading ? "..." : "Search"}
658
+ </button>
659
+ </form>
660
+
661
+ {/* Error */}
662
+ {error && (
663
+ <div className="text-red-400 text-sm p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
664
+ {error}
665
+ </div>
666
+ )}
667
+
668
+ {/* Results */}
669
+ {!loading && searched && servers.length === 0 && (
670
+ <div className="text-center py-8 text-[#666]">
671
+ No servers found. Try a different search term.
672
+ </div>
673
+ )}
674
+
675
+ {servers.length > 0 && (
676
+ <div className="grid gap-4 md:grid-cols-2">
677
+ {servers.map((server) => (
678
+ <div
679
+ key={server.name}
680
+ className="bg-[#111] border border-[#1a1a1a] rounded-lg p-4 hover:border-[#333] transition"
681
+ >
682
+ <div className="flex items-start justify-between gap-3">
683
+ <div className="flex-1 min-w-0">
684
+ <h3 className="font-medium truncate">{server.name}</h3>
685
+ <p className="text-sm text-[#666] mt-1 line-clamp-2">
686
+ {server.description || "No description"}
687
+ </p>
688
+ <div className="flex items-center gap-3 mt-2 text-xs text-[#555]">
689
+ {server.vendor && <span>by {server.vendor}</span>}
690
+ {server.githubStars !== null && server.githubStars > 0 && (
691
+ <span>★ {server.githubStars.toLocaleString()}</span>
692
+ )}
693
+ </div>
694
+ {server.npmPackage && (
695
+ <code className="text-xs text-[#666] bg-[#0a0a0a] px-2 py-0.5 rounded mt-2 inline-block truncate max-w-full">
696
+ {server.npmPackage}
697
+ </code>
698
+ )}
699
+ </div>
700
+ <div className="flex-shrink-0">
701
+ {server.npmPackage ? (
702
+ <button
703
+ onClick={() => installServer(server)}
704
+ disabled={installing === server.name}
705
+ className="text-sm bg-[#1a1a1a] hover:bg-[#222] border border-[#333] hover:border-[#f97316] px-3 py-1.5 rounded transition disabled:opacity-50"
706
+ >
707
+ {installing === server.name ? "Adding..." : "Add"}
708
+ </button>
709
+ ) : (
710
+ <a
711
+ href={server.sourceUrl}
712
+ target="_blank"
713
+ rel="noopener noreferrer"
714
+ className="text-sm text-[#666] hover:text-[#f97316] transition"
715
+ >
716
+ View →
717
+ </a>
718
+ )}
719
+ </div>
720
+ </div>
721
+ </div>
722
+ ))}
723
+ </div>
724
+ )}
725
+
726
+ {/* Loading */}
727
+ {loading && (
728
+ <div className="text-center py-8 text-[#666]">
729
+ Searching registry...
730
+ </div>
731
+ )}
732
+
733
+ {/* Registry info */}
734
+ <div className="p-4 bg-[#111] border border-[#1a1a1a] rounded-lg text-sm text-[#666]">
735
+ <p>
736
+ Servers are sourced from the{" "}
737
+ <a
738
+ href="https://github.com/modelcontextprotocol/servers"
739
+ target="_blank"
740
+ rel="noopener noreferrer"
741
+ className="text-[#f97316] hover:underline"
742
+ >
743
+ official MCP registry
744
+ </a>
745
+ . Not all servers have npm packages - some require manual setup.
746
+ </p>
747
+ </div>
748
+ </div>
749
+ );
750
+ }
751
+
752
+ // Parse command and extract credential placeholders
753
+ function parseCommandForCredentials(cmd: string): {
754
+ cleanCommand: string;
755
+ credentials: Array<{ key: string; flag: string }>;
756
+ serverName: string | null;
757
+ } {
758
+ const credentials: Array<{ key: string; flag: string }> = [];
759
+ let cleanCommand = cmd;
760
+ let serverName: string | null = null;
761
+
762
+ // Try to extract server name from package (e.g., pushover-mcp@latest -> pushover)
763
+ const pkgMatch = cmd.match(/(?:npx\s+-y\s+)?(@?[\w-]+\/)?(@?[\w-]+)(?:@[\w.-]+)?/);
764
+ if (pkgMatch) {
765
+ const pkg = pkgMatch[2] || pkgMatch[1];
766
+ if (pkg) {
767
+ // Extract name: "pushover-mcp" -> "pushover", "@org/server-github" -> "github"
768
+ serverName = pkg
769
+ .replace(/^@/, '')
770
+ .replace(/-mcp$/, '')
771
+ .replace(/-server$/, '')
772
+ .replace(/^server-/, '')
773
+ .replace(/^mcp-/, '');
774
+ }
775
+ }
776
+
777
+ // Pattern: --flag YOUR_VALUE, --flag <value>, --flag {value}, --flag $VALUE
778
+ // Matches: --token YOUR_TOKEN, --user YOUR_USER, --api-key <API_KEY>, etc.
779
+ const argPattern = /--(\w+[-\w]*)\s+(YOUR_\w+|<[\w_]+>|\{[\w_]+\}|\$[\w_]+|[\w_]*(?:TOKEN|KEY|SECRET|PASSWORD|USER|ID|APIKEY)[\w_]*)/gi;
780
+
781
+ let match;
782
+ while ((match = argPattern.exec(cmd)) !== null) {
783
+ const flag = match[1];
784
+ const placeholder = match[2];
785
+
786
+ // Convert flag to env var name: api-key -> API_KEY, token -> TOKEN
787
+ const envKey = flag.toUpperCase().replace(/-/g, '_');
788
+
789
+ // Add prefix based on server name if available
790
+ const fullKey = serverName
791
+ ? `${serverName.toUpperCase().replace(/-/g, '_')}_${envKey}`
792
+ : envKey;
793
+
794
+ credentials.push({ key: fullKey, flag });
795
+
796
+ // Replace placeholder with $ENV_VAR reference in command
797
+ cleanCommand = cleanCommand.replace(
798
+ new RegExp(`(--${flag}\\s+)${placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i'),
799
+ `--${flag} $${fullKey}`
800
+ );
801
+ }
802
+
803
+ return { cleanCommand, credentials, serverName };
804
+ }
805
+
806
+ function AddServerModal({
807
+ onClose,
808
+ onAdded,
809
+ }: {
810
+ onClose: () => void;
811
+ onAdded: () => void;
812
+ }) {
813
+ const [mode, setMode] = useState<"npm" | "command">("npm");
814
+ const [name, setName] = useState("");
815
+ const [pkg, setPkg] = useState("");
816
+ const [command, setCommand] = useState("");
817
+ const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>([]);
818
+ const [saving, setSaving] = useState(false);
819
+ const [error, setError] = useState<string | null>(null);
820
+
821
+ const addEnvVar = () => {
822
+ setEnvVars([...envVars, { key: "", value: "" }]);
823
+ };
824
+
825
+ const updateEnvVar = (index: number, field: "key" | "value", value: string) => {
826
+ const updated = [...envVars];
827
+ updated[index][field] = value;
828
+ setEnvVars(updated);
829
+ };
830
+
831
+ const removeEnvVar = (index: number) => {
832
+ setEnvVars(envVars.filter((_, i) => i !== index));
833
+ };
834
+
835
+ // Handle command input - parse and extract credentials
836
+ const handleCommandChange = (value: string) => {
837
+ setCommand(value);
838
+
839
+ // Only parse if it looks like a full command with placeholders
840
+ if (value.includes('YOUR_') || value.includes('<') || value.includes('{') ||
841
+ /TOKEN|KEY|SECRET|PASSWORD/i.test(value)) {
842
+ const { cleanCommand, credentials, serverName } = parseCommandForCredentials(value);
843
+
844
+ // Auto-set name if empty
845
+ if (!name && serverName) {
846
+ setName(serverName);
847
+ }
848
+
849
+ // Add any new credentials that don't already exist
850
+ if (credentials.length > 0) {
851
+ const existingKeys = new Set(envVars.map(e => e.key));
852
+ const newVars = credentials
853
+ .filter(c => !existingKeys.has(c.key))
854
+ .map(c => ({ key: c.key, value: "" }));
855
+
856
+ if (newVars.length > 0) {
857
+ setEnvVars([...envVars, ...newVars]);
858
+ // Update command to use clean version with env var references
859
+ setCommand(cleanCommand);
860
+ }
861
+ }
862
+ }
863
+ };
864
+
865
+ // Handle package input - detect if user pasted a full command
866
+ const handlePackageChange = (value: string) => {
867
+ // Check if this looks like a full command (has npx, spaces with args, or credential placeholders)
868
+ const looksLikeCommand =
869
+ value.startsWith('npx ') ||
870
+ value.includes(' --') ||
871
+ value.includes('YOUR_') ||
872
+ value.includes('<') ||
873
+ /\s+(TOKEN|KEY|SECRET|PASSWORD)/i.test(value);
874
+
875
+ if (looksLikeCommand) {
876
+ // Switch to command mode and parse
877
+ setMode("command");
878
+ handleCommandChange(value);
879
+ } else {
880
+ // Just a package name
881
+ setPkg(value);
882
+
883
+ // Try to auto-set name from package
884
+ if (!name && value) {
885
+ const serverName = value
886
+ .replace(/^@[\w-]+\//, '') // Remove org prefix
887
+ .replace(/@[\w.-]+$/, '') // Remove version
888
+ .replace(/^server-/, '')
889
+ .replace(/-server$/, '')
890
+ .replace(/^mcp-/, '')
891
+ .replace(/-mcp$/, '');
892
+ if (serverName && serverName !== value) {
893
+ setName(serverName);
894
+ }
895
+ }
896
+ }
897
+ };
898
+
899
+ const handleAdd = async () => {
900
+ if (!name) {
901
+ setError("Name is required");
902
+ return;
903
+ }
904
+
905
+ if (mode === "npm" && !pkg) {
906
+ setError("npm package is required");
907
+ return;
908
+ }
909
+
910
+ if (mode === "command" && !command) {
911
+ setError("Command is required");
912
+ return;
913
+ }
914
+
915
+ setSaving(true);
916
+ setError(null);
917
+
918
+ // Build env object from envVars array
919
+ const env: Record<string, string> = {};
920
+ for (const { key, value } of envVars) {
921
+ if (key.trim()) {
922
+ env[key.trim()] = value;
923
+ }
924
+ }
925
+
926
+ try {
927
+ const body: Record<string, unknown> = { name };
928
+
929
+ if (mode === "npm") {
930
+ body.type = "npm";
931
+ body.package = pkg;
932
+ } else {
933
+ // Parse command into parts
934
+ const parts = command.trim().split(/\s+/);
935
+ body.type = "custom";
936
+ body.command = parts[0];
937
+ body.args = parts.slice(1).join(" ");
938
+ }
939
+
940
+ if (Object.keys(env).length > 0) {
941
+ body.env = env;
942
+ }
943
+
944
+ const res = await fetch("/api/mcp/servers", {
945
+ method: "POST",
946
+ headers: { "Content-Type": "application/json" },
947
+ body: JSON.stringify(body),
948
+ });
949
+
950
+ if (!res.ok) {
951
+ const data = await res.json();
952
+ setError(data.error || "Failed to add server");
953
+ setSaving(false);
954
+ return;
955
+ }
956
+
957
+ onAdded();
958
+ } catch (e) {
959
+ setError("Failed to add server");
960
+ setSaving(false);
961
+ }
962
+ };
963
+
964
+ const quickAdd = (serverName: string, serverPkg: string) => {
965
+ setMode("npm");
966
+ setName(serverName);
967
+ setPkg(serverPkg);
968
+ };
969
+
970
+ return (
971
+ <div className="fixed inset-0 bg-black/50 backdrop-blur-[2px] z-50 flex items-center justify-center p-4">
972
+ <div className="bg-[#111] border border-[#1a1a1a] rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto">
973
+ <div className="p-4 border-b border-[#1a1a1a] flex items-center justify-between sticky top-0 bg-[#111]">
974
+ <h2 className="text-lg font-semibold">Add MCP Server</h2>
975
+ <button onClick={onClose} className="text-[#666] hover:text-[#888]">
976
+
977
+ </button>
978
+ </div>
979
+
980
+ <div className="p-4 space-y-4">
981
+ {/* Quick picks */}
982
+ <div>
983
+ <p className="text-sm text-[#666] mb-2">Quick add:</p>
984
+ <div className="flex flex-wrap gap-2">
985
+ {[
986
+ { name: "filesystem", pkg: "@modelcontextprotocol/server-filesystem" },
987
+ { name: "fetch", pkg: "@modelcontextprotocol/server-fetch" },
988
+ { name: "memory", pkg: "@modelcontextprotocol/server-memory" },
989
+ { name: "github", pkg: "@modelcontextprotocol/server-github" },
990
+ ].map(s => (
991
+ <button
992
+ key={s.name}
993
+ onClick={() => quickAdd(s.name, s.pkg)}
994
+ className="text-sm bg-[#1a1a1a] hover:bg-[#222] px-3 py-1 rounded transition"
995
+ >
996
+ {s.name}
997
+ </button>
998
+ ))}
999
+ </div>
1000
+ </div>
1001
+
1002
+ {/* Mode toggle */}
1003
+ <div className="flex gap-1 bg-[#0a0a0a] border border-[#222] rounded p-1">
1004
+ <button
1005
+ onClick={() => setMode("npm")}
1006
+ className={`flex-1 px-3 py-1.5 rounded text-sm transition ${
1007
+ mode === "npm"
1008
+ ? "bg-[#1a1a1a] text-white"
1009
+ : "text-[#666] hover:text-[#888]"
1010
+ }`}
1011
+ >
1012
+ npm Package
1013
+ </button>
1014
+ <button
1015
+ onClick={() => setMode("command")}
1016
+ className={`flex-1 px-3 py-1.5 rounded text-sm transition ${
1017
+ mode === "command"
1018
+ ? "bg-[#1a1a1a] text-white"
1019
+ : "text-[#666] hover:text-[#888]"
1020
+ }`}
1021
+ >
1022
+ Custom Command
1023
+ </button>
1024
+ </div>
1025
+
1026
+ {/* Name */}
1027
+ <div>
1028
+ <label className="block text-sm text-[#666] mb-1">Name</label>
1029
+ <input
1030
+ type="text"
1031
+ value={name}
1032
+ onChange={e => setName(e.target.value)}
1033
+ placeholder="e.g., pushover"
1034
+ className="w-full bg-[#0a0a0a] border border-[#333] rounded px-3 py-2 focus:outline-none focus:border-[#f97316]"
1035
+ />
1036
+ </div>
1037
+
1038
+ {/* npm Package */}
1039
+ {mode === "npm" && (
1040
+ <div>
1041
+ <label className="block text-sm text-[#666] mb-1">npm Package</label>
1042
+ <input
1043
+ type="text"
1044
+ value={pkg}
1045
+ onChange={e => handlePackageChange(e.target.value)}
1046
+ placeholder="e.g., @modelcontextprotocol/server-filesystem or paste full command"
1047
+ className="w-full bg-[#0a0a0a] border border-[#333] rounded px-3 py-2 focus:outline-none focus:border-[#f97316]"
1048
+ />
1049
+ <p className="text-xs text-[#555] mt-1">
1050
+ Package name or paste a full npx command with credentials
1051
+ </p>
1052
+ </div>
1053
+ )}
1054
+
1055
+ {/* Custom Command */}
1056
+ {mode === "command" && (
1057
+ <div>
1058
+ <label className="block text-sm text-[#666] mb-1">Command</label>
1059
+ <input
1060
+ type="text"
1061
+ value={command}
1062
+ onChange={e => handleCommandChange(e.target.value)}
1063
+ placeholder="e.g., npx -y pushover-mcp@latest start --token YOUR_TOKEN"
1064
+ className="w-full bg-[#0a0a0a] border border-[#333] rounded px-3 py-2 font-mono text-sm focus:outline-none focus:border-[#f97316]"
1065
+ />
1066
+ <p className="text-xs text-[#555] mt-1">
1067
+ Paste the full command - credentials like YOUR_TOKEN will be auto-extracted
1068
+ </p>
1069
+ </div>
1070
+ )}
1071
+
1072
+ {/* Environment Variables / Credentials */}
1073
+ <div>
1074
+ <div className="flex items-center justify-between mb-2">
1075
+ <label className="text-sm text-[#666]">
1076
+ Environment Variables / Credentials
1077
+ </label>
1078
+ <button
1079
+ onClick={addEnvVar}
1080
+ className="text-xs text-[#f97316] hover:text-[#fb923c] transition"
1081
+ >
1082
+ + Add Variable
1083
+ </button>
1084
+ </div>
1085
+
1086
+ {envVars.length === 0 && (
1087
+ <p className="text-xs text-[#555] bg-[#0a0a0a] border border-[#222] rounded p-3">
1088
+ Add environment variables for API tokens and credentials.
1089
+ These are stored encrypted and passed to the server at startup.
1090
+ </p>
1091
+ )}
1092
+
1093
+ {envVars.length > 0 && (
1094
+ <div className="space-y-2">
1095
+ {envVars.map((env, index) => (
1096
+ <div key={index} className="flex gap-2">
1097
+ <input
1098
+ type="text"
1099
+ value={env.key}
1100
+ onChange={e => updateEnvVar(index, "key", e.target.value)}
1101
+ placeholder="KEY"
1102
+ className="w-1/3 bg-[#0a0a0a] border border-[#333] rounded px-2 py-1.5 text-sm font-mono focus:outline-none focus:border-[#f97316]"
1103
+ />
1104
+ <input
1105
+ type="password"
1106
+ value={env.value}
1107
+ onChange={e => updateEnvVar(index, "value", e.target.value)}
1108
+ placeholder="value"
1109
+ className="flex-1 bg-[#0a0a0a] border border-[#333] rounded px-2 py-1.5 text-sm font-mono focus:outline-none focus:border-[#f97316]"
1110
+ />
1111
+ <button
1112
+ onClick={() => removeEnvVar(index)}
1113
+ className="text-[#666] hover:text-red-400 px-2 transition"
1114
+ >
1115
+
1116
+ </button>
1117
+ </div>
1118
+ ))}
1119
+ </div>
1120
+ )}
1121
+ </div>
1122
+
1123
+ {error && <p className="text-red-400 text-sm">{error}</p>}
1124
+ </div>
1125
+
1126
+ <div className="p-4 border-t border-[#1a1a1a] flex justify-end gap-2 sticky bottom-0 bg-[#111]">
1127
+ <button
1128
+ onClick={onClose}
1129
+ className="px-4 py-2 border border-[#333] hover:border-[#666] rounded transition"
1130
+ >
1131
+ Cancel
1132
+ </button>
1133
+ <button
1134
+ onClick={handleAdd}
1135
+ disabled={saving || !name || (mode === "npm" ? !pkg : !command)}
1136
+ className="px-4 py-2 bg-[#f97316] hover:bg-[#fb923c] text-black rounded font-medium transition disabled:opacity-50"
1137
+ >
1138
+ {saving ? "Adding..." : "Add Server"}
1139
+ </button>
1140
+ </div>
1141
+ </div>
1142
+ </div>
1143
+ );
1144
+ }