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.
- package/dist/App.0mzj9cz9.js +213 -0
- package/dist/index.html +1 -1
- package/dist/styles.css +1 -1
- package/package.json +6 -6
- package/src/binary.ts +271 -1
- package/src/crypto.ts +53 -0
- package/src/db.ts +492 -3
- package/src/mcp-client.ts +599 -0
- package/src/providers.ts +31 -0
- package/src/routes/api.ts +832 -64
- package/src/server.ts +169 -5
- package/src/web/App.tsx +44 -2
- package/src/web/components/agents/AgentCard.tsx +53 -9
- package/src/web/components/agents/AgentPanel.tsx +381 -0
- package/src/web/components/agents/AgentsView.tsx +27 -10
- package/src/web/components/agents/CreateAgentModal.tsx +7 -7
- package/src/web/components/agents/index.ts +1 -1
- package/src/web/components/common/Icons.tsx +8 -0
- package/src/web/components/common/Modal.tsx +2 -2
- package/src/web/components/common/Select.tsx +1 -1
- package/src/web/components/common/index.ts +1 -0
- package/src/web/components/dashboard/Dashboard.tsx +74 -25
- package/src/web/components/index.ts +5 -2
- package/src/web/components/layout/Sidebar.tsx +22 -2
- package/src/web/components/mcp/McpPage.tsx +1144 -0
- package/src/web/components/mcp/index.ts +1 -0
- package/src/web/components/onboarding/OnboardingWizard.tsx +5 -1
- package/src/web/components/settings/SettingsPage.tsx +312 -82
- package/src/web/components/tasks/TasksPage.tsx +129 -0
- package/src/web/components/tasks/index.ts +1 -0
- package/src/web/components/telemetry/TelemetryPage.tsx +359 -0
- package/src/web/context/TelemetryContext.tsx +202 -0
- package/src/web/context/index.ts +2 -0
- package/src/web/hooks/useAgents.ts +23 -0
- package/src/web/styles.css +18 -0
- package/src/web/types.ts +75 -1
- package/dist/App.wfhmfhx7.js +0 -213
- 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
|
+
}
|