@nastechai/agent 0.16.0 → 0.17.0
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/eslint.config.js +23 -0
- package/index.html +24 -0
- package/package.json +54 -26
- package/package.json.bak +89 -0
- package/package.json.pub +88 -0
- package/src/App.tsx +1173 -0
- package/src/components/AuthWidget.tsx +150 -0
- package/src/components/AutoField.tsx +206 -0
- package/src/components/Backdrop.tsx +93 -0
- package/src/components/ChatSidebar.tsx +394 -0
- package/src/components/DeleteConfirmDialog.tsx +40 -0
- package/src/components/LanguageSwitcher.tsx +186 -0
- package/src/components/Markdown.tsx +383 -0
- package/src/components/ModelInfoCard.tsx +112 -0
- package/src/components/ModelPickerDialog.tsx +470 -0
- package/src/components/OAuthLoginModal.tsx +374 -0
- package/src/components/OAuthProvidersCard.tsx +287 -0
- package/src/components/PlatformsCard.tsx +97 -0
- package/src/components/ScheduleBuilder.tsx +273 -0
- package/src/components/SidebarFooter.tsx +42 -0
- package/src/components/SidebarStatusStrip.tsx +72 -0
- package/src/components/SlashPopover.tsx +171 -0
- package/src/components/ThemeSwitcher.tsx +243 -0
- package/src/components/ToolCall.tsx +228 -0
- package/src/components/ToolsetConfigDrawer.tsx +448 -0
- package/src/contexts/PageHeaderProvider.tsx +139 -0
- package/src/contexts/SystemActions.tsx +120 -0
- package/src/contexts/page-header-context.ts +12 -0
- package/src/contexts/system-actions-context.ts +18 -0
- package/src/contexts/usePageHeader.ts +10 -0
- package/src/contexts/useSystemActions.ts +15 -0
- package/src/hooks/useModalBehavior.ts +44 -0
- package/src/hooks/useSidebarStatus.ts +27 -0
- package/src/i18n/af.ts +702 -0
- package/src/i18n/context.tsx +123 -0
- package/src/i18n/de.ts +701 -0
- package/src/i18n/en.ts +708 -0
- package/src/i18n/es.ts +701 -0
- package/src/i18n/fr.ts +701 -0
- package/src/i18n/ga.ts +702 -0
- package/src/i18n/hu.ts +702 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/it.ts +701 -0
- package/src/i18n/ja.ts +702 -0
- package/src/i18n/ko.ts +702 -0
- package/src/i18n/pt.ts +702 -0
- package/src/i18n/ru.ts +702 -0
- package/src/i18n/tr.ts +702 -0
- package/src/i18n/types.ts +710 -0
- package/src/i18n/uk.ts +702 -0
- package/src/i18n/zh-hant.ts +702 -0
- package/src/i18n/zh.ts +698 -0
- package/src/index.css +274 -0
- package/src/lib/api.ts +1585 -0
- package/src/lib/dashboard-flags.ts +15 -0
- package/src/lib/format.ts +9 -0
- package/src/lib/fuzzy.ts +192 -0
- package/src/lib/gatewayClient.ts +253 -0
- package/src/lib/nested.ts +23 -0
- package/src/lib/resolve-page-title.ts +41 -0
- package/src/lib/schedule.ts +382 -0
- package/src/lib/slashExec.ts +163 -0
- package/src/lib/utils.ts +35 -0
- package/src/main.tsx +25 -0
- package/src/pages/AnalyticsPage.tsx +601 -0
- package/src/pages/ChannelsPage.tsx +772 -0
- package/src/pages/ChatPage.tsx +889 -0
- package/src/pages/ConfigPage.tsx +660 -0
- package/src/pages/CronPage.tsx +524 -0
- package/src/pages/DocsPage.tsx +69 -0
- package/src/pages/EnvPage.tsx +918 -0
- package/src/pages/LogsPage.tsx +246 -0
- package/src/pages/McpPage.tsx +757 -0
- package/src/pages/ModelsPage.tsx +994 -0
- package/src/pages/PairingPage.tsx +276 -0
- package/src/pages/PluginsPage.tsx +580 -0
- package/src/pages/ProfilesPage.tsx +559 -0
- package/src/pages/SessionsPage.tsx +936 -0
- package/src/pages/SkillsPage.tsx +557 -0
- package/src/pages/SystemPage.tsx +1259 -0
- package/src/pages/WebhooksPage.tsx +483 -0
- package/src/plugins/PluginPage.tsx +64 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/registry.ts +151 -0
- package/src/plugins/sdk.d.ts +160 -0
- package/src/plugins/slots.ts +199 -0
- package/src/plugins/types.ts +37 -0
- package/src/plugins/usePlugins.ts +133 -0
- package/src/themes/context.tsx +443 -0
- package/src/themes/fonts.ts +160 -0
- package/src/themes/index.ts +3 -0
- package/src/themes/presets.ts +477 -0
- package/src/themes/types.ts +187 -0
- package/tsconfig.app.json +34 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +124 -0
- package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
|
2
|
+
import { Package, Power, Server, Trash2, X, Zap } from "lucide-react";
|
|
3
|
+
import { Badge } from "@nastechai/ui/ui/components/badge";
|
|
4
|
+
import { Button } from "@nastechai/ui/ui/components/button";
|
|
5
|
+
import { Select, SelectOption } from "@nastechai/ui/ui/components/select";
|
|
6
|
+
import { Spinner } from "@nastechai/ui/ui/components/spinner";
|
|
7
|
+
import { H2 } from "@nastechai/ui/ui/components/typography/h2";
|
|
8
|
+
import { api } from "@/lib/api";
|
|
9
|
+
import type {
|
|
10
|
+
McpCatalogDiagnostic,
|
|
11
|
+
McpCatalogEntry,
|
|
12
|
+
McpServer,
|
|
13
|
+
McpServerCreate,
|
|
14
|
+
McpTestResult,
|
|
15
|
+
} from "@/lib/api";
|
|
16
|
+
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
|
17
|
+
import { useToast } from "@nastechai/ui/hooks/use-toast";
|
|
18
|
+
import { useConfirmDelete } from "@nastechai/ui/hooks/use-confirm-delete";
|
|
19
|
+
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
|
20
|
+
import { Toast } from "@nastechai/ui/ui/components/toast";
|
|
21
|
+
import { Card, CardContent } from "@nastechai/ui/ui/components/card";
|
|
22
|
+
import { Input } from "@nastechai/ui/ui/components/input";
|
|
23
|
+
import { Label } from "@nastechai/ui/ui/components/label";
|
|
24
|
+
import { usePageHeader } from "@/contexts/usePageHeader";
|
|
25
|
+
import { cn, themedBody } from "@/lib/utils";
|
|
26
|
+
|
|
27
|
+
type Transport = "http" | "stdio";
|
|
28
|
+
|
|
29
|
+
function truncateText(value: string, maxLength: number): string {
|
|
30
|
+
return value.length > maxLength ? value.slice(0, maxLength) + "..." : value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseArgs(raw: string): string[] {
|
|
34
|
+
return raw
|
|
35
|
+
.split(/[\s,]+/)
|
|
36
|
+
.map((s) => s.trim())
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseEnv(raw: string): Record<string, string> {
|
|
41
|
+
const env: Record<string, string> = {};
|
|
42
|
+
raw
|
|
43
|
+
.split("\n")
|
|
44
|
+
.map((line) => line.trim())
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.forEach((line) => {
|
|
47
|
+
const idx = line.indexOf("=");
|
|
48
|
+
if (idx === -1) return;
|
|
49
|
+
const key = line.slice(0, idx).trim();
|
|
50
|
+
const value = line.slice(idx + 1).trim();
|
|
51
|
+
if (key) env[key] = value;
|
|
52
|
+
});
|
|
53
|
+
return env;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TRANSPORT_TONE: Record<string, "success" | "warning" | "secondary"> = {
|
|
57
|
+
http: "success",
|
|
58
|
+
stdio: "warning",
|
|
59
|
+
unknown: "secondary",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export default function McpPage() {
|
|
63
|
+
const [servers, setServers] = useState<McpServer[]>([]);
|
|
64
|
+
const [catalog, setCatalog] = useState<McpCatalogEntry[]>([]);
|
|
65
|
+
const [diagnostics, setDiagnostics] = useState<McpCatalogDiagnostic[]>([]);
|
|
66
|
+
const [loading, setLoading] = useState(true);
|
|
67
|
+
const { toast, showToast } = useToast();
|
|
68
|
+
const { setEnd } = usePageHeader();
|
|
69
|
+
|
|
70
|
+
// Add server modal state
|
|
71
|
+
const [createModalOpen, setCreateModalOpen] = useState(false);
|
|
72
|
+
const [name, setName] = useState("");
|
|
73
|
+
const [transport, setTransport] = useState<Transport>("http");
|
|
74
|
+
const [url, setUrl] = useState("");
|
|
75
|
+
const [command, setCommand] = useState("");
|
|
76
|
+
const [args, setArgs] = useState("");
|
|
77
|
+
const [env, setEnv] = useState("");
|
|
78
|
+
const [creating, setCreating] = useState(false);
|
|
79
|
+
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
|
|
80
|
+
const createModalRef = useModalBehavior({
|
|
81
|
+
open: createModalOpen,
|
|
82
|
+
onClose: closeCreateModal,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Test results keyed by server name
|
|
86
|
+
const [testing, setTesting] = useState<string | null>(null);
|
|
87
|
+
const [testResults, setTestResults] = useState<
|
|
88
|
+
Record<string, McpTestResult>
|
|
89
|
+
>({});
|
|
90
|
+
|
|
91
|
+
// Enable/disable state
|
|
92
|
+
const [togglingName, setTogglingName] = useState<string | null>(null);
|
|
93
|
+
const [restartNote, setRestartNote] = useState<string | null>(null);
|
|
94
|
+
|
|
95
|
+
// Catalog install modal state
|
|
96
|
+
const [installEntry, setInstallEntry] = useState<McpCatalogEntry | null>(
|
|
97
|
+
null,
|
|
98
|
+
);
|
|
99
|
+
const [installEnv, setInstallEnv] = useState<Record<string, string>>({});
|
|
100
|
+
const [installingName, setInstallingName] = useState<string | null>(null);
|
|
101
|
+
const closeInstallModal = useCallback(() => setInstallEntry(null), []);
|
|
102
|
+
const installModalRef = useModalBehavior({
|
|
103
|
+
open: installEntry !== null,
|
|
104
|
+
onClose: closeInstallModal,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const loadServers = useCallback(() => {
|
|
108
|
+
return api
|
|
109
|
+
.getMcpServers()
|
|
110
|
+
.then((res) => setServers(res.servers))
|
|
111
|
+
.catch((e) => showToast(`Error: ${e}`, "error"));
|
|
112
|
+
}, [showToast]);
|
|
113
|
+
|
|
114
|
+
const loadCatalog = useCallback(() => {
|
|
115
|
+
return api
|
|
116
|
+
.getMcpCatalog()
|
|
117
|
+
.then((res) => {
|
|
118
|
+
setCatalog(res.entries);
|
|
119
|
+
setDiagnostics(res.diagnostics);
|
|
120
|
+
})
|
|
121
|
+
.catch((e) => showToast(`Error: ${e}`, "error"));
|
|
122
|
+
}, [showToast]);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
Promise.all([loadServers(), loadCatalog()]).finally(() =>
|
|
126
|
+
setLoading(false),
|
|
127
|
+
);
|
|
128
|
+
}, [loadServers, loadCatalog]);
|
|
129
|
+
|
|
130
|
+
const handleCreate = async () => {
|
|
131
|
+
if (!name.trim()) {
|
|
132
|
+
showToast("Name required", "error");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (transport === "http" && !url.trim()) {
|
|
136
|
+
showToast("URL required", "error");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (transport === "stdio" && !command.trim()) {
|
|
140
|
+
showToast("Command required", "error");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
setCreating(true);
|
|
144
|
+
try {
|
|
145
|
+
const body: McpServerCreate = { name: name.trim() };
|
|
146
|
+
if (transport === "http") {
|
|
147
|
+
body.url = url.trim();
|
|
148
|
+
} else {
|
|
149
|
+
body.command = command.trim();
|
|
150
|
+
const argList = parseArgs(args);
|
|
151
|
+
if (argList.length) body.args = argList;
|
|
152
|
+
}
|
|
153
|
+
const envMap = parseEnv(env);
|
|
154
|
+
if (Object.keys(envMap).length) body.env = envMap;
|
|
155
|
+
|
|
156
|
+
await api.addMcpServer(body);
|
|
157
|
+
showToast("Add ✓", "success");
|
|
158
|
+
setName("");
|
|
159
|
+
setUrl("");
|
|
160
|
+
setCommand("");
|
|
161
|
+
setArgs("");
|
|
162
|
+
setEnv("");
|
|
163
|
+
setTransport("http");
|
|
164
|
+
setCreateModalOpen(false);
|
|
165
|
+
loadServers();
|
|
166
|
+
} catch (e) {
|
|
167
|
+
showToast(`Failed to add: ${e}`, "error");
|
|
168
|
+
} finally {
|
|
169
|
+
setCreating(false);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const handleTest = async (server: McpServer) => {
|
|
174
|
+
setTesting(server.name);
|
|
175
|
+
try {
|
|
176
|
+
const result = await api.testMcpServer(server.name);
|
|
177
|
+
setTestResults((prev) => ({ ...prev, [server.name]: result }));
|
|
178
|
+
if (result.ok) {
|
|
179
|
+
showToast(`${server.name}: ${result.tools.length} tool(s)`, "success");
|
|
180
|
+
} else {
|
|
181
|
+
showToast(`${server.name}: ${result.error ?? "Failed"}`, "error");
|
|
182
|
+
}
|
|
183
|
+
} catch (e) {
|
|
184
|
+
showToast(`Error: ${e}`, "error");
|
|
185
|
+
} finally {
|
|
186
|
+
setTesting(null);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const handleToggleEnabled = async (server: McpServer) => {
|
|
191
|
+
const next = !server.enabled;
|
|
192
|
+
setTogglingName(server.name);
|
|
193
|
+
try {
|
|
194
|
+
await api.setMcpServerEnabled(server.name, next);
|
|
195
|
+
setServers((prev) =>
|
|
196
|
+
prev.map((s) =>
|
|
197
|
+
s.name === server.name ? { ...s, enabled: next } : s,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
setRestartNote(
|
|
201
|
+
"Enable/disable takes effect on the next gateway restart.",
|
|
202
|
+
);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
showToast(`Error: ${e}`, "error");
|
|
205
|
+
} finally {
|
|
206
|
+
setTogglingName(null);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const serverDelete = useConfirmDelete({
|
|
211
|
+
onDelete: useCallback(
|
|
212
|
+
async (serverName: string) => {
|
|
213
|
+
try {
|
|
214
|
+
await api.removeMcpServer(serverName);
|
|
215
|
+
showToast(`Delete: "${truncateText(serverName, 30)}"`, "success");
|
|
216
|
+
setTestResults((prev) => {
|
|
217
|
+
const next = { ...prev };
|
|
218
|
+
delete next[serverName];
|
|
219
|
+
return next;
|
|
220
|
+
});
|
|
221
|
+
loadServers();
|
|
222
|
+
} catch (e) {
|
|
223
|
+
showToast(`Error: ${e}`, "error");
|
|
224
|
+
throw e;
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
[loadServers, showToast],
|
|
228
|
+
),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ── Catalog install ──────────────────────────────────────────────────
|
|
232
|
+
const runInstall = useCallback(
|
|
233
|
+
async (entry: McpCatalogEntry, envMap: Record<string, string>) => {
|
|
234
|
+
setInstallingName(entry.name);
|
|
235
|
+
try {
|
|
236
|
+
const res = await api.installMcpCatalogEntry(entry.name, envMap, true);
|
|
237
|
+
if (res.background) {
|
|
238
|
+
showToast("Installing in background…", "success");
|
|
239
|
+
} else {
|
|
240
|
+
showToast(`Installed: "${truncateText(entry.name, 30)}"`, "success");
|
|
241
|
+
}
|
|
242
|
+
setInstallEntry(null);
|
|
243
|
+
setInstallEnv({});
|
|
244
|
+
await Promise.all([loadServers(), loadCatalog()]);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
showToast(`Failed to install: ${e}`, "error");
|
|
247
|
+
} finally {
|
|
248
|
+
setInstallingName(null);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
[loadServers, loadCatalog, showToast],
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const handleInstallClick = (entry: McpCatalogEntry) => {
|
|
255
|
+
if (entry.required_env.length > 0) {
|
|
256
|
+
const initial: Record<string, string> = {};
|
|
257
|
+
entry.required_env.forEach((item) => {
|
|
258
|
+
initial[item.name] = "";
|
|
259
|
+
});
|
|
260
|
+
setInstallEnv(initial);
|
|
261
|
+
setInstallEntry(entry);
|
|
262
|
+
} else {
|
|
263
|
+
void runInstall(entry, {});
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const handleInstallSubmit = () => {
|
|
268
|
+
if (!installEntry) return;
|
|
269
|
+
const missing = installEntry.required_env.filter(
|
|
270
|
+
(item) => item.required && !(installEnv[item.name] ?? "").trim(),
|
|
271
|
+
);
|
|
272
|
+
if (missing.length > 0) {
|
|
273
|
+
showToast(`${missing[0].prompt} required`, "error");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const envMap: Record<string, string> = {};
|
|
277
|
+
Object.entries(installEnv).forEach(([k, v]) => {
|
|
278
|
+
if (v.trim()) envMap[k] = v.trim();
|
|
279
|
+
});
|
|
280
|
+
void runInstall(installEntry, envMap);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// Put "Add Server" button in page header
|
|
284
|
+
useLayoutEffect(() => {
|
|
285
|
+
setEnd(
|
|
286
|
+
<Button
|
|
287
|
+
className="uppercase"
|
|
288
|
+
size="sm"
|
|
289
|
+
onClick={() => setCreateModalOpen(true)}
|
|
290
|
+
>
|
|
291
|
+
Add Server
|
|
292
|
+
</Button>,
|
|
293
|
+
);
|
|
294
|
+
return () => {
|
|
295
|
+
setEnd(null);
|
|
296
|
+
};
|
|
297
|
+
}, [setEnd, loading]);
|
|
298
|
+
|
|
299
|
+
if (loading) {
|
|
300
|
+
return (
|
|
301
|
+
<div className="flex items-center justify-center py-24">
|
|
302
|
+
<Spinner className="text-2xl text-primary" />
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const diagnosticsByName: Record<string, McpCatalogDiagnostic[]> = {};
|
|
308
|
+
diagnostics.forEach((d) => {
|
|
309
|
+
(diagnosticsByName[d.name] ??= []).push(d);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<div className="flex flex-col gap-6">
|
|
314
|
+
<Toast toast={toast} />
|
|
315
|
+
|
|
316
|
+
<DeleteConfirmDialog
|
|
317
|
+
open={serverDelete.isOpen}
|
|
318
|
+
onCancel={serverDelete.cancel}
|
|
319
|
+
onConfirm={serverDelete.confirm}
|
|
320
|
+
title="Remove MCP server"
|
|
321
|
+
description={
|
|
322
|
+
serverDelete.pendingId
|
|
323
|
+
? `"${truncateText(serverDelete.pendingId, 40)}" — this will remove the server.`
|
|
324
|
+
: "This will remove the server."
|
|
325
|
+
}
|
|
326
|
+
loading={serverDelete.isDeleting}
|
|
327
|
+
/>
|
|
328
|
+
|
|
329
|
+
{/* Add server modal */}
|
|
330
|
+
{createModalOpen && (
|
|
331
|
+
<div
|
|
332
|
+
ref={createModalRef}
|
|
333
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
|
334
|
+
onClick={(e) =>
|
|
335
|
+
e.target === e.currentTarget && setCreateModalOpen(false)
|
|
336
|
+
}
|
|
337
|
+
role="dialog"
|
|
338
|
+
aria-modal="true"
|
|
339
|
+
aria-labelledby="create-mcp-title"
|
|
340
|
+
>
|
|
341
|
+
<div
|
|
342
|
+
className={cn(
|
|
343
|
+
themedBody,
|
|
344
|
+
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col",
|
|
345
|
+
)}
|
|
346
|
+
>
|
|
347
|
+
<Button
|
|
348
|
+
ghost
|
|
349
|
+
size="icon"
|
|
350
|
+
onClick={() => setCreateModalOpen(false)}
|
|
351
|
+
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
|
352
|
+
aria-label="Close"
|
|
353
|
+
>
|
|
354
|
+
<X />
|
|
355
|
+
</Button>
|
|
356
|
+
|
|
357
|
+
<header className="p-5 pb-3 border-b border-border">
|
|
358
|
+
<h2
|
|
359
|
+
id="create-mcp-title"
|
|
360
|
+
className="font-mondwest text-display text-base tracking-wider"
|
|
361
|
+
>
|
|
362
|
+
Add MCP server
|
|
363
|
+
</h2>
|
|
364
|
+
</header>
|
|
365
|
+
|
|
366
|
+
<div className="p-5 grid gap-4">
|
|
367
|
+
<div className="grid gap-2">
|
|
368
|
+
<Label htmlFor="mcp-name">Name</Label>
|
|
369
|
+
<Input
|
|
370
|
+
id="mcp-name"
|
|
371
|
+
autoFocus
|
|
372
|
+
placeholder="my-server"
|
|
373
|
+
value={name}
|
|
374
|
+
onChange={(e) => setName(e.target.value)}
|
|
375
|
+
/>
|
|
376
|
+
</div>
|
|
377
|
+
|
|
378
|
+
<div className="grid gap-2">
|
|
379
|
+
<Label htmlFor="mcp-transport">Transport</Label>
|
|
380
|
+
<Select
|
|
381
|
+
id="mcp-transport"
|
|
382
|
+
value={transport}
|
|
383
|
+
onValueChange={(v) => setTransport(v as Transport)}
|
|
384
|
+
>
|
|
385
|
+
<SelectOption value="http">HTTP/SSE</SelectOption>
|
|
386
|
+
<SelectOption value="stdio">stdio</SelectOption>
|
|
387
|
+
</Select>
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
{transport === "http" ? (
|
|
391
|
+
<div className="grid gap-2">
|
|
392
|
+
<Label htmlFor="mcp-url">URL</Label>
|
|
393
|
+
<Input
|
|
394
|
+
id="mcp-url"
|
|
395
|
+
placeholder="https://example.com/mcp"
|
|
396
|
+
value={url}
|
|
397
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
398
|
+
/>
|
|
399
|
+
</div>
|
|
400
|
+
) : (
|
|
401
|
+
<>
|
|
402
|
+
<div className="grid gap-2">
|
|
403
|
+
<Label htmlFor="mcp-command">Command</Label>
|
|
404
|
+
<Input
|
|
405
|
+
id="mcp-command"
|
|
406
|
+
placeholder="npx"
|
|
407
|
+
value={command}
|
|
408
|
+
onChange={(e) => setCommand(e.target.value)}
|
|
409
|
+
/>
|
|
410
|
+
</div>
|
|
411
|
+
<div className="grid gap-2">
|
|
412
|
+
<Label htmlFor="mcp-args">Args</Label>
|
|
413
|
+
<Input
|
|
414
|
+
id="mcp-args"
|
|
415
|
+
placeholder="-y @modelcontextprotocol/server-foo"
|
|
416
|
+
value={args}
|
|
417
|
+
onChange={(e) => setArgs(e.target.value)}
|
|
418
|
+
/>
|
|
419
|
+
</div>
|
|
420
|
+
</>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
<div className="grid gap-2">
|
|
424
|
+
<Label htmlFor="mcp-env">Environment (KEY=VALUE per line)</Label>
|
|
425
|
+
<textarea
|
|
426
|
+
id="mcp-env"
|
|
427
|
+
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
|
428
|
+
placeholder={"API_KEY=secret\nDEBUG=1"}
|
|
429
|
+
value={env}
|
|
430
|
+
onChange={(e) => setEnv(e.target.value)}
|
|
431
|
+
/>
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
<div className="flex justify-end">
|
|
435
|
+
<Button
|
|
436
|
+
className="uppercase"
|
|
437
|
+
size="sm"
|
|
438
|
+
onClick={handleCreate}
|
|
439
|
+
disabled={creating}
|
|
440
|
+
prefix={creating ? <Spinner /> : undefined}
|
|
441
|
+
>
|
|
442
|
+
{creating ? "Adding..." : "Add"}
|
|
443
|
+
</Button>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
)}
|
|
449
|
+
|
|
450
|
+
{/* Catalog install modal (required env vars) */}
|
|
451
|
+
{installEntry && (
|
|
452
|
+
<div
|
|
453
|
+
ref={installModalRef}
|
|
454
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
|
455
|
+
onClick={(e) =>
|
|
456
|
+
e.target === e.currentTarget && setInstallEntry(null)
|
|
457
|
+
}
|
|
458
|
+
role="dialog"
|
|
459
|
+
aria-modal="true"
|
|
460
|
+
aria-labelledby="install-mcp-title"
|
|
461
|
+
>
|
|
462
|
+
<div
|
|
463
|
+
className={cn(
|
|
464
|
+
themedBody,
|
|
465
|
+
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col",
|
|
466
|
+
)}
|
|
467
|
+
>
|
|
468
|
+
<Button
|
|
469
|
+
ghost
|
|
470
|
+
size="icon"
|
|
471
|
+
onClick={() => setInstallEntry(null)}
|
|
472
|
+
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
|
473
|
+
aria-label="Close"
|
|
474
|
+
>
|
|
475
|
+
<X />
|
|
476
|
+
</Button>
|
|
477
|
+
|
|
478
|
+
<header className="p-5 pb-3 border-b border-border">
|
|
479
|
+
<h2
|
|
480
|
+
id="install-mcp-title"
|
|
481
|
+
className="font-mondwest text-display text-base tracking-wider"
|
|
482
|
+
>
|
|
483
|
+
Install {installEntry.name}
|
|
484
|
+
</h2>
|
|
485
|
+
</header>
|
|
486
|
+
|
|
487
|
+
<div className="p-5 grid gap-4">
|
|
488
|
+
<p className="text-xs text-muted-foreground">
|
|
489
|
+
This MCP requires the following values to be configured.
|
|
490
|
+
</p>
|
|
491
|
+
{installEntry.required_env.map((item) => (
|
|
492
|
+
<div className="grid gap-2" key={item.name}>
|
|
493
|
+
<Label htmlFor={`install-env-${item.name}`}>
|
|
494
|
+
{item.prompt}
|
|
495
|
+
{item.required ? " *" : ""}
|
|
496
|
+
</Label>
|
|
497
|
+
<Input
|
|
498
|
+
id={`install-env-${item.name}`}
|
|
499
|
+
type="password"
|
|
500
|
+
placeholder={item.name}
|
|
501
|
+
value={installEnv[item.name] ?? ""}
|
|
502
|
+
onChange={(e) =>
|
|
503
|
+
setInstallEnv((prev) => ({
|
|
504
|
+
...prev,
|
|
505
|
+
[item.name]: e.target.value,
|
|
506
|
+
}))
|
|
507
|
+
}
|
|
508
|
+
/>
|
|
509
|
+
</div>
|
|
510
|
+
))}
|
|
511
|
+
|
|
512
|
+
<div className="flex justify-end">
|
|
513
|
+
<Button
|
|
514
|
+
className="uppercase"
|
|
515
|
+
size="sm"
|
|
516
|
+
onClick={handleInstallSubmit}
|
|
517
|
+
disabled={installingName === installEntry.name}
|
|
518
|
+
prefix={
|
|
519
|
+
installingName === installEntry.name ? (
|
|
520
|
+
<Spinner />
|
|
521
|
+
) : undefined
|
|
522
|
+
}
|
|
523
|
+
>
|
|
524
|
+
{installingName === installEntry.name
|
|
525
|
+
? "Installing..."
|
|
526
|
+
: "Install"}
|
|
527
|
+
</Button>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
</div>
|
|
532
|
+
)}
|
|
533
|
+
|
|
534
|
+
{/* ── Your MCP servers ── */}
|
|
535
|
+
<div className="flex flex-col gap-3">
|
|
536
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
537
|
+
<H2
|
|
538
|
+
variant="sm"
|
|
539
|
+
className="flex items-center gap-2 text-muted-foreground"
|
|
540
|
+
>
|
|
541
|
+
<Server className="h-4 w-4" />
|
|
542
|
+
Your MCP servers ({servers.length})
|
|
543
|
+
</H2>
|
|
544
|
+
</div>
|
|
545
|
+
|
|
546
|
+
{restartNote && (
|
|
547
|
+
<p className="text-xs text-warning">{restartNote}</p>
|
|
548
|
+
)}
|
|
549
|
+
|
|
550
|
+
{servers.length === 0 && (
|
|
551
|
+
<Card>
|
|
552
|
+
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
553
|
+
No MCP servers configured.
|
|
554
|
+
</CardContent>
|
|
555
|
+
</Card>
|
|
556
|
+
)}
|
|
557
|
+
|
|
558
|
+
{servers.map((server) => {
|
|
559
|
+
const envCount = Object.keys(server.env ?? {}).length;
|
|
560
|
+
const result = testResults[server.name];
|
|
561
|
+
|
|
562
|
+
return (
|
|
563
|
+
<Card key={server.name}>
|
|
564
|
+
<CardContent
|
|
565
|
+
className={cn(
|
|
566
|
+
"flex items-start gap-4 py-4",
|
|
567
|
+
!server.enabled && "opacity-60",
|
|
568
|
+
)}
|
|
569
|
+
>
|
|
570
|
+
<div className="flex-1 min-w-0">
|
|
571
|
+
<div className="flex items-center gap-2 mb-1">
|
|
572
|
+
<span className="font-medium text-sm truncate">
|
|
573
|
+
{server.name}
|
|
574
|
+
</span>
|
|
575
|
+
<Badge
|
|
576
|
+
tone={TRANSPORT_TONE[server.transport] ?? "secondary"}
|
|
577
|
+
>
|
|
578
|
+
{server.transport}
|
|
579
|
+
</Badge>
|
|
580
|
+
{!server.enabled && (
|
|
581
|
+
<Badge tone="outline">disabled</Badge>
|
|
582
|
+
)}
|
|
583
|
+
</div>
|
|
584
|
+
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
585
|
+
{server.transport === "http" ? (
|
|
586
|
+
<span className="font-mono truncate">
|
|
587
|
+
{server.url ?? "—"}
|
|
588
|
+
</span>
|
|
589
|
+
) : (
|
|
590
|
+
<span className="font-mono truncate">
|
|
591
|
+
{[server.command, ...(server.args ?? [])]
|
|
592
|
+
.filter(Boolean)
|
|
593
|
+
.join(" ") || "—"}
|
|
594
|
+
</span>
|
|
595
|
+
)}
|
|
596
|
+
{envCount > 0 && (
|
|
597
|
+
<span>
|
|
598
|
+
{envCount} env var{envCount === 1 ? "" : "s"}
|
|
599
|
+
</span>
|
|
600
|
+
)}
|
|
601
|
+
</div>
|
|
602
|
+
{result && (
|
|
603
|
+
<div className="mt-2 text-xs">
|
|
604
|
+
{result.ok ? (
|
|
605
|
+
<p className="text-success">
|
|
606
|
+
{result.tools.length === 0
|
|
607
|
+
? "Connected — no tools"
|
|
608
|
+
: `Tools: ${result.tools
|
|
609
|
+
.map((tool) => tool.name)
|
|
610
|
+
.join(", ")}`}
|
|
611
|
+
</p>
|
|
612
|
+
) : (
|
|
613
|
+
<p className="text-destructive">
|
|
614
|
+
{result.error ?? "Connection failed"}
|
|
615
|
+
</p>
|
|
616
|
+
)}
|
|
617
|
+
</div>
|
|
618
|
+
)}
|
|
619
|
+
</div>
|
|
620
|
+
|
|
621
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
622
|
+
<Button
|
|
623
|
+
ghost
|
|
624
|
+
size="sm"
|
|
625
|
+
title={server.enabled ? "Disable" : "Enable"}
|
|
626
|
+
aria-label={server.enabled ? "Disable" : "Enable"}
|
|
627
|
+
onClick={() => handleToggleEnabled(server)}
|
|
628
|
+
disabled={togglingName === server.name}
|
|
629
|
+
prefix={
|
|
630
|
+
togglingName === server.name ? (
|
|
631
|
+
<Spinner />
|
|
632
|
+
) : (
|
|
633
|
+
<Power />
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
className={server.enabled ? "text-success" : undefined}
|
|
637
|
+
>
|
|
638
|
+
{server.enabled ? "Disable" : "Enable"}
|
|
639
|
+
</Button>
|
|
640
|
+
|
|
641
|
+
<Button
|
|
642
|
+
ghost
|
|
643
|
+
size="icon"
|
|
644
|
+
title="Test connection"
|
|
645
|
+
aria-label="Test connection"
|
|
646
|
+
onClick={() => handleTest(server)}
|
|
647
|
+
disabled={testing === server.name}
|
|
648
|
+
>
|
|
649
|
+
{testing === server.name ? <Spinner /> : <Zap />}
|
|
650
|
+
</Button>
|
|
651
|
+
|
|
652
|
+
<Button
|
|
653
|
+
ghost
|
|
654
|
+
destructive
|
|
655
|
+
size="icon"
|
|
656
|
+
title="Delete"
|
|
657
|
+
aria-label="Delete"
|
|
658
|
+
onClick={() => serverDelete.requestDelete(server.name)}
|
|
659
|
+
>
|
|
660
|
+
<Trash2 />
|
|
661
|
+
</Button>
|
|
662
|
+
</div>
|
|
663
|
+
</CardContent>
|
|
664
|
+
</Card>
|
|
665
|
+
);
|
|
666
|
+
})}
|
|
667
|
+
</div>
|
|
668
|
+
|
|
669
|
+
{/* ── Catalog ── */}
|
|
670
|
+
<div className="flex flex-col gap-3">
|
|
671
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
672
|
+
<H2
|
|
673
|
+
variant="sm"
|
|
674
|
+
className="flex items-center gap-2 text-muted-foreground"
|
|
675
|
+
>
|
|
676
|
+
<Package className="h-4 w-4" />
|
|
677
|
+
Catalog ({catalog.length})
|
|
678
|
+
</H2>
|
|
679
|
+
</div>
|
|
680
|
+
|
|
681
|
+
<p className="text-xs text-muted-foreground">
|
|
682
|
+
Browse Nous-approved MCP servers and install them with one click.
|
|
683
|
+
</p>
|
|
684
|
+
|
|
685
|
+
{catalog.length === 0 && (
|
|
686
|
+
<Card>
|
|
687
|
+
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
688
|
+
No catalog entries available.
|
|
689
|
+
</CardContent>
|
|
690
|
+
</Card>
|
|
691
|
+
)}
|
|
692
|
+
|
|
693
|
+
{catalog.map((entry) => {
|
|
694
|
+
const entryDiags = diagnosticsByName[entry.name] ?? [];
|
|
695
|
+
const isInstalling = installingName === entry.name;
|
|
696
|
+
|
|
697
|
+
return (
|
|
698
|
+
<Card key={entry.name}>
|
|
699
|
+
<CardContent className="flex items-start gap-4 py-4">
|
|
700
|
+
<div className="flex-1 min-w-0">
|
|
701
|
+
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
702
|
+
<span className="font-medium text-sm truncate">
|
|
703
|
+
{entry.name}
|
|
704
|
+
</span>
|
|
705
|
+
<Badge
|
|
706
|
+
tone={TRANSPORT_TONE[entry.transport] ?? "secondary"}
|
|
707
|
+
>
|
|
708
|
+
{entry.transport}
|
|
709
|
+
</Badge>
|
|
710
|
+
<Badge tone="outline">
|
|
711
|
+
{entry.source === "official" ? "official" : entry.source}
|
|
712
|
+
</Badge>
|
|
713
|
+
{entry.installed && (
|
|
714
|
+
<Badge tone="success">Installed</Badge>
|
|
715
|
+
)}
|
|
716
|
+
{entry.installed && !entry.enabled && (
|
|
717
|
+
<Badge tone="outline">disabled</Badge>
|
|
718
|
+
)}
|
|
719
|
+
</div>
|
|
720
|
+
{entry.description && (
|
|
721
|
+
<p className="text-xs text-muted-foreground">
|
|
722
|
+
{entry.description}
|
|
723
|
+
</p>
|
|
724
|
+
)}
|
|
725
|
+
{entryDiags.map((d, i) => (
|
|
726
|
+
<p
|
|
727
|
+
key={`${entry.name}-diag-${i}`}
|
|
728
|
+
className="text-xs text-warning mt-1"
|
|
729
|
+
>
|
|
730
|
+
{d.message}
|
|
731
|
+
</p>
|
|
732
|
+
))}
|
|
733
|
+
</div>
|
|
734
|
+
|
|
735
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
736
|
+
{entry.installed ? (
|
|
737
|
+
<Badge tone="success">Installed</Badge>
|
|
738
|
+
) : (
|
|
739
|
+
<Button
|
|
740
|
+
className="uppercase"
|
|
741
|
+
size="sm"
|
|
742
|
+
onClick={() => handleInstallClick(entry)}
|
|
743
|
+
disabled={isInstalling}
|
|
744
|
+
prefix={isInstalling ? <Spinner /> : undefined}
|
|
745
|
+
>
|
|
746
|
+
{isInstalling ? "Installing..." : "Install"}
|
|
747
|
+
</Button>
|
|
748
|
+
)}
|
|
749
|
+
</div>
|
|
750
|
+
</CardContent>
|
|
751
|
+
</Card>
|
|
752
|
+
);
|
|
753
|
+
})}
|
|
754
|
+
</div>
|
|
755
|
+
</div>
|
|
756
|
+
);
|
|
757
|
+
}
|