main
js 1,133 lines 35.2 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { marked } from "/vendor/marked/marked.esm.js";
3 import sleep from "/js/sleep.js";
4 import * as API from "/js/api.js";
5 import { openModal } from "/js/modals.js";
6 import { store as settingsStore } from "/components/settings/settings-store.js";
7 import { getUserTimezone } from "/js/time-utils.js";
8 import {
9 toastFrontendError,
10 toastFrontendSuccess,
11 toastFrontendWarning,
12 } from "/components/notifications/notification-store.js";
13
14 const EMPTY_CONFIG = '{\n "mcpServers": {}\n}';
15 const STATUS_INTERVAL_MS = 3000;
16 const SCAN_ASSET_BASE = "/components/settings/mcp/client";
17 const SCAN_POLL_INTERVAL_MS = 2000;
18 const SCAN_MAX_POLL_MS = 10 * 60 * 1000;
19 const SCAN_TITLE = "MCP Scanner";
20
21 let scanChecksConfig = null;
22 let scanPromptTemplate = null;
23 let scanPollGeneration = 0;
24
25 async function fetchText(url, label) {
26 const response = await fetch(url);
27 if (!response.ok) {
28 const body = await response.text().catch(() => "");
29 throw new Error(`Failed to load ${label}: ${response.status} ${response.statusText}${body ? ` - ${body}` : ""}`);
30 }
31 return response.text();
32 }
33
34 async function fetchJson(url, label) {
35 const response = await fetch(url);
36 if (!response.ok) {
37 const body = await response.text().catch(() => "");
38 throw new Error(`Failed to load ${label}: ${response.status} ${response.statusText}${body ? ` - ${body}` : ""}`);
39 }
40 return response.json();
41 }
42
43 async function loadScanChecks() {
44 if (scanChecksConfig) return scanChecksConfig;
45 scanChecksConfig = await fetchJson(`${SCAN_ASSET_BASE}/mcp-scan-checks.json`, "MCP scan checks");
46 return scanChecksConfig;
47 }
48
49 async function loadScanTemplate() {
50 if (scanPromptTemplate) return scanPromptTemplate;
51 scanPromptTemplate = await fetchText(`${SCAN_ASSET_BASE}/mcp-scan-prompt.md`, "MCP scan prompt");
52 return scanPromptTemplate;
53 }
54
55 function normalizeName(value) {
56 return String(value || "mcp_server")
57 .trim()
58 .toLowerCase()
59 .replace(/[^\w]/gu, "_")
60 .replace(/_+/g, "_")
61 .replace(/^_+|_+$/g, "") || "mcp_server";
62 }
63
64 function parseJsonConfig(value) {
65 const text = String(value || "").trim() || EMPTY_CONFIG;
66 const parsed = JSON.parse(text);
67 if (Array.isArray(parsed)) return { mcpServers: parsed };
68 if (parsed && typeof parsed === "object") {
69 if (!parsed.mcpServers) parsed.mcpServers = {};
70 return parsed;
71 }
72 return { mcpServers: {} };
73 }
74
75 function stringifyConfig(config) {
76 return JSON.stringify(config || { mcpServers: {} }, null, 2);
77 }
78
79 function matchesSearchQuery(query, values) {
80 const normalized = String(query || "").trim().toLowerCase();
81 if (!normalized) return true;
82 return values.some((value) => String(value ?? "").toLowerCase().includes(normalized));
83 }
84
85 function parseKeyValueText(text) {
86 const raw = String(text || "").trim();
87 if (!raw) return {};
88 if (raw.startsWith("{")) {
89 const parsed = JSON.parse(raw);
90 return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
91 }
92
93 return raw.split(/\r?\n/)
94 .map((line) => line.trim())
95 .filter(Boolean)
96 .reduce((acc, line) => {
97 const idx = line.indexOf("=");
98 if (idx <= 0) return acc;
99 const key = line.slice(0, idx).trim();
100 if (!key) return acc;
101 acc[key] = line.slice(idx + 1).trim();
102 return acc;
103 }, {});
104 }
105
106 function formatKeyValueText(value) {
107 if (!value || typeof value !== "object" || Array.isArray(value)) return "";
108 return Object.entries(value)
109 .map(([key, val]) => `${key}=${val ?? ""}`)
110 .join("\n");
111 }
112
113 function parseArgsText(text) {
114 const raw = String(text || "").trim();
115 if (!raw) return [];
116 if (raw.startsWith("[")) {
117 const parsed = JSON.parse(raw);
118 return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
119 }
120 return raw.split(/\r?\n/).flatMap((line) => splitCommandLine(line.trim())).filter(Boolean);
121 }
122
123 function formatArgsText(value) {
124 return Array.isArray(value) ? value.join("\n") : "";
125 }
126
127 function splitCommandLine(text) {
128 const raw = String(text || "").trim();
129 if (!raw) return [];
130
131 const tokens = [];
132 let current = "";
133 let quote = "";
134 let escaping = false;
135
136 for (const char of raw) {
137 if (escaping) {
138 current += char;
139 escaping = false;
140 continue;
141 }
142 if (char === "\\") {
143 escaping = true;
144 continue;
145 }
146 if (quote) {
147 if (char === quote) quote = "";
148 else current += char;
149 continue;
150 }
151 if (char === "\"" || char === "'") {
152 quote = char;
153 continue;
154 }
155 if (/\s/u.test(char)) {
156 if (current) {
157 tokens.push(current);
158 current = "";
159 }
160 continue;
161 }
162 current += char;
163 }
164
165 if (escaping) current += "\\";
166 if (current) tokens.push(current);
167 return tokens;
168 }
169
170 function getLocalCommandParts(form) {
171 const commandLine = String(form.command || "").trim();
172 const explicitArgs = parseArgsText(form.argsText);
173 if (explicitArgs.length) return { command: commandLine, args: explicitArgs };
174
175 const parts = splitCommandLine(commandLine);
176 return {
177 command: parts[0] || commandLine,
178 args: parts.slice(1),
179 };
180 }
181
182 function deriveNameFromUrl(url) {
183 try {
184 const parsed = new URL(url);
185 const parts = parsed.pathname.split("/").filter(Boolean);
186 return normalizeName(parts.at(-1) || parsed.hostname || "remote_mcp");
187 } catch {
188 return "remote_mcp";
189 }
190 }
191
192 function deriveNameFromCommand(command, argsText) {
193 let args = [];
194 try {
195 args = parseArgsText(argsText);
196 } catch {}
197
198 const parts = args.length
199 ? [String(command || "").trim(), ...args]
200 : splitCommandLine(command);
201 const ignored = new Set(["npx", "uvx", "uv", "node", "python", "python3"]);
202 const candidate = [...parts]
203 .reverse()
204 .find((part) => part && !part.startsWith("-") && !ignored.has(part.toLowerCase()));
205 return normalizeName(candidate || parts[0] || "local_mcp");
206 }
207
208 function formatCriteria(ratings, criteria) {
209 return Object.entries(criteria || {})
210 .map(([level, desc]) => `- ${ratings[level]?.icon || level}: ${desc}`)
211 .join("\n");
212 }
213
214 function formatStatusLegend(ratings) {
215 return Object.values(ratings || {})
216 .map((rating) => `- ${rating.icon} ${rating.label}`)
217 .join("\n");
218 }
219
220 function formatRatingIcons(ratings) {
221 return Object.values(ratings || {}).map((rating) => rating.icon).join("/");
222 }
223
224 function createDefaultScanOptions() {
225 return {
226 inspectRuntime: true,
227 allowLocalExecution: false,
228 allowRemoteNetwork: false,
229 };
230 }
231
232 function createEmptyForm() {
233 return {
234 mode: "local",
235 name: "",
236 description: "",
237 url: "",
238 type: "streamable-http",
239 command: "",
240 argsText: "",
241 headersText: "",
242 envText: "",
243 init_timeout: "",
244 tool_timeout: "",
245 verify: true,
246 disabled: false,
247 };
248 }
249
250 const model = {
251 editor: null,
252 servers: [],
253 loading: true,
254 applying: false,
255 statusCheck: false,
256 serverLog: "",
257 serverDetail: null,
258 serverSearch: "",
259 toolSearch: "",
260 activeView: "visual",
261 advancedOpen: false,
262 serverForm: createEmptyForm(),
263 scanChecks: {},
264 scanChecksMeta: {},
265 scanOptions: createDefaultScanOptions(),
266 scanPrompt: "",
267 scanOutput: "",
268 scanCtxId: "",
269 scanTargetJson: "",
270 scanServer: null,
271 agentScanning: false,
272 scanLoading: false,
273 scanResult: null,
274 scope: "global",
275 projectName: "",
276 projectModel: null,
277 standaloneProject: false,
278
279 async initialize() {
280 this.loading = true;
281 await this.ensureScopeLoaded();
282 this.setupEditor();
283 await this.loadStatus();
284 this.loading = false;
285 this.startStatusCheck();
286 },
287
288 setupEditor() {
289 const container = document.getElementById("mcp-servers-config-json");
290 if (!container) return;
291
292 const editor = ace.edit("mcp-servers-config-json");
293 const dark = localStorage.getItem("darkMode");
294 editor.setTheme(dark !== "false" ? "ace/theme/github_dark" : "ace/theme/tomorrow");
295 editor.session.setMode("ace/mode/json");
296 editor.setValue(this.getScopeConfigJson());
297 editor.clearSelection();
298 this.editor = editor;
299 requestAnimationFrame(() => this.editor?.resize());
300 },
301
302 async ensureScopeLoaded() {
303 if (this.scope === "project") return;
304 if (settingsStore.settings) return;
305
306 try {
307 const response = await API.callJsonApi("settings_get", null);
308 if (response?.settings) {
309 settingsStore.settings = response.settings;
310 settingsStore.additional = response.additional || null;
311 }
312 } catch (error) {
313 console.error("Failed to load settings for MCP manager:", error);
314 void toastFrontendError("Failed to load settings for MCP manager", "MCP Servers");
315 }
316 },
317
318 async openGlobalConfig() {
319 this.configureGlobalScope();
320 await openModal("settings/mcp/client/mcp-servers.html");
321 },
322
323 async openProjectConfig(projectModel) {
324 if (!projectModel?.name) return;
325 this.configureProjectScope(projectModel.name, projectModel, false);
326 await openModal("settings/mcp/client/mcp-servers.html");
327 },
328
329 async openFromComposer(projectName = "") {
330 const normalizedProject = String(projectName || "").trim();
331 if (normalizedProject) {
332 try {
333 const response = await API.callJsonApi("projects", {
334 action: "load",
335 name: normalizedProject,
336 });
337 if (!response?.ok) throw new Error(response?.error || "Project load failed");
338 this.configureProjectScope(normalizedProject, response.data, true);
339 } catch (error) {
340 console.error("Failed to load project MCP config:", error);
341 void toastFrontendError("Failed to load project MCP config", "MCP Servers");
342 return;
343 }
344 } else {
345 this.configureGlobalScope();
346 await this.ensureScopeLoaded();
347 }
348 await openModal("settings/mcp/client/mcp-servers.html");
349 },
350
351 configureGlobalScope() {
352 this.scope = "global";
353 this.projectName = "";
354 this.projectModel = null;
355 this.standaloneProject = false;
356 },
357
358 configureProjectScope(projectName, projectModel, standaloneProject = false) {
359 this.scope = "project";
360 this.projectName = projectName;
361 this.projectModel = projectModel || null;
362 this.standaloneProject = !!standaloneProject;
363 },
364
365 resetScope() {
366 this.configureGlobalScope();
367 },
368
369 get scopeTitle() {
370 if (this.scope === "project") return `Project MCP servers`;
371 return "Global MCP servers";
372 },
373
374 get scopeSubtitle() {
375 if (this.scope === "project") {
376 return this.projectName ? `Project: ${this.projectName}` : "Project scope";
377 }
378 return "Available to every chat unless a project overrides a server.";
379 },
380
381 getStatusPayload() {
382 return this.scope === "project" && this.projectName
383 ? { project_name: this.projectName }
384 : null;
385 },
386
387 getApplyPayload() {
388 const payload = { mcp_servers: this.getEditorValue() };
389 if (this.scope === "project" && this.projectName) payload.project_name = this.projectName;
390 return payload;
391 },
392
393 getScopeConfigJson() {
394 if (this.scope === "project") {
395 return this.projectModel?.mcp_servers || EMPTY_CONFIG;
396 }
397 return settingsStore.settings?.mcp_servers
398 ?? settingsStore.settings?.mcpServers
399 ?? EMPTY_CONFIG;
400 },
401
402 setScopeConfigJson(value) {
403 if (this.scope === "project") {
404 if (this.projectModel) this.projectModel.mcp_servers = value;
405 return;
406 }
407 if (settingsStore.settings) settingsStore.settings.mcp_servers = value;
408 },
409
410 getEditorValue() {
411 return this.editor?.getValue() ?? this.getScopeConfigJson();
412 },
413
414 setEditorValue(value) {
415 if (this.editor) {
416 this.editor.setValue(value);
417 this.editor.clearSelection();
418 this.editor.navigateFileStart();
419 requestAnimationFrame(() => this.editor?.resize());
420 }
421 this.setScopeConfigJson(value);
422 },
423
424 getConfigObject() {
425 return parseJsonConfig(this.getEditorValue());
426 },
427
428 get configuredServers() {
429 try {
430 const config = this.getConfigObject();
431 const servers = config.mcpServers;
432 if (Array.isArray(servers)) {
433 return servers.map((server, index) => ({
434 name: server?.name || `server_${index + 1}`,
435 config: server || {},
436 }));
437 }
438 if (servers && typeof servers === "object") {
439 return Object.entries(servers).map(([name, config]) => ({
440 name,
441 config: config || {},
442 }));
443 }
444 } catch {
445 return [];
446 }
447 return [];
448 },
449
450 get filteredConfiguredServers() {
451 return this.configuredServers.filter((entry) => matchesSearchQuery(this.serverSearch, [
452 entry.name,
453 this.configModeLabel(entry.config),
454 this.configSummary(entry.config),
455 entry.config?.description,
456 ]));
457 },
458
459 get filteredServers() {
460 return this.servers.filter((server) => matchesSearchQuery(this.serverSearch, [
461 server.name,
462 server.scope,
463 server.type,
464 server.description,
465 server.error,
466 this.statusLabel(server),
467 ]));
468 },
469
470 get serverSearchActive() {
471 return !!String(this.serverSearch || "").trim();
472 },
473
474 get configuredServersCountLabel() {
475 const total = this.configuredServers.length;
476 if (!this.serverSearchActive) return `${total} total`;
477 return `${this.filteredConfiguredServers.length} of ${total}`;
478 },
479
480 get visibleServersCountLabel() {
481 if (this.loading) return "Loading";
482 const total = this.servers.length;
483 if (!this.serverSearchActive) return `${total} visible`;
484 return `${this.filteredServers.length} of ${total}`;
485 },
486
487 clearServerSearch() {
488 this.serverSearch = "";
489 },
490
491 get serverDetailTools() {
492 return Array.isArray(this.serverDetail?.tools) ? this.serverDetail.tools : [];
493 },
494
495 get filteredServerDetailTools() {
496 return this.serverDetailTools.filter((tool) => matchesSearchQuery(this.toolSearch, [
497 tool.name,
498 tool.description,
499 JSON.stringify(tool.input_schema || {}),
500 ]));
501 },
502
503 get serverDetailToolsCountLabel() {
504 const total = this.serverDetailTools.length;
505 const query = String(this.toolSearch || "").trim();
506 if (!query) return `${total} tools`;
507 return `${this.filteredServerDetailTools.length} of ${total}`;
508 },
509
510 clearToolSearch() {
511 this.toolSearch = "";
512 },
513
514 countServersInConfig(configText) {
515 try {
516 const config = parseJsonConfig(configText || EMPTY_CONFIG);
517 if (Array.isArray(config.mcpServers)) return config.mcpServers.length;
518 return Object.keys(config.mcpServers || {}).length;
519 } catch {
520 return 0;
521 }
522 },
523
524 formatJson() {
525 try {
526 this.setEditorValue(stringifyConfig(this.getConfigObject()));
527 void toastFrontendSuccess("MCP JSON reformatted", "MCP Servers");
528 } catch (error) {
529 console.error("Failed to format JSON:", error);
530 void toastFrontendError(`Invalid JSON: ${error.message}`, "MCP Servers");
531 }
532 },
533
534 setActiveView(view) {
535 this.activeView = view || "visual";
536 if (this.activeView === "raw") {
537 requestAnimationFrame(() => this.editor?.resize());
538 }
539 },
540
541 setFormMode(mode) {
542 this.serverForm.mode = mode === "local" ? "local" : "remote";
543 this.scanResult = null;
544 },
545
546 resetForm() {
547 this.serverForm = createEmptyForm();
548 this.advancedOpen = false;
549 this.scanResult = null;
550 },
551
552 buildServerFromForm() {
553 const form = this.serverForm;
554 const name = normalizeName(form.name || (form.mode === "remote" ? deriveNameFromUrl(form.url) : deriveNameFromCommand(form.command, form.argsText)));
555 if (!name) throw new Error("Name is required");
556
557 const server = {
558 name,
559 disabled: !!form.disabled,
560 };
561
562 if (form.description.trim()) server.description = form.description.trim();
563
564 if (form.init_timeout !== "" && form.init_timeout !== null) {
565 const timeout = Number(form.init_timeout);
566 if (Number.isFinite(timeout) && timeout > 0) server.init_timeout = timeout;
567 }
568 if (form.tool_timeout !== "" && form.tool_timeout !== null) {
569 const timeout = Number(form.tool_timeout);
570 if (Number.isFinite(timeout) && timeout > 0) server.tool_timeout = timeout;
571 }
572
573 if (form.mode === "remote") {
574 if (!form.url.trim()) throw new Error("Remote MCP server URL is required");
575 server.url = form.url.trim();
576 server.type = form.type || "streamable-http";
577 server.verify = form.verify !== false;
578 const headers = parseKeyValueText(form.headersText);
579 if (Object.keys(headers).length) server.headers = headers;
580 } else {
581 if (!form.command.trim()) throw new Error("Local command is required");
582 const parts = getLocalCommandParts(form);
583 if (!parts.command) throw new Error("Local command is required");
584 server.type = "stdio";
585 server.command = parts.command;
586 if (parts.args.length) server.args = parts.args;
587 const env = parseKeyValueText(form.envText);
588 if (Object.keys(env).length) server.env = env;
589 }
590
591 return server;
592 },
593
594 async ensureScanFramework() {
595 try {
596 const cfg = await loadScanChecks();
597 this.scanChecksMeta = cfg.checks || {};
598 if (Object.keys(this.scanChecks).length === 0) {
599 const checks = {};
600 for (const key of Object.keys(this.scanChecksMeta)) checks[key] = true;
601 this.scanChecks = checks;
602 }
603 return cfg;
604 } catch (error) {
605 console.error("Failed to load MCP scanner framework:", error);
606 void toastFrontendError(`Failed to load MCP scanner: ${error.message || error}`, SCAN_TITLE);
607 return null;
608 }
609 },
610
611 resetScanState() {
612 scanPollGeneration++;
613 this.scanOptions = createDefaultScanOptions();
614 this.scanPrompt = "";
615 this.scanOutput = "";
616 this.scanCtxId = "";
617 this.scanTargetJson = "";
618 this.scanServer = null;
619 this.agentScanning = false;
620 this.scanLoading = false;
621 this.scanResult = null;
622 },
623
624 prepareScanTarget() {
625 let server;
626 try {
627 server = this.buildServerFromForm();
628 } catch (error) {
629 void toastFrontendError(error.message || String(error), SCAN_TITLE);
630 return false;
631 }
632
633 this.scanServer = server;
634 this.scanTargetJson = JSON.stringify(server, null, 2);
635 this.scanResult = null;
636 this.scanOutput = "";
637 this.scanCtxId = "";
638 return true;
639 },
640
641 async openScanModal() {
642 if (!this.prepareScanTarget()) return;
643 await this.ensureScanFramework();
644 await this.buildScanPrompt();
645 await openModal("settings/mcp/client/mcp-server-scan.html");
646 },
647
648 async onScanModalOpen() {
649 await this.ensureScanFramework();
650 if (!this.scanServer) this.prepareScanTarget();
651 await this.buildScanPrompt();
652 },
653
654 async buildScanPrompt() {
655 if (!this.scanServer) return;
656 try {
657 const [cfg, template] = await Promise.all([loadScanChecks(), loadScanTemplate()]);
658 const ratings = cfg.ratings || {};
659 const checks = cfg.checks || {};
660 const selected = Object.entries(this.scanChecks)
661 .filter(([, enabled]) => enabled)
662 .map(([key]) => checks[key])
663 .filter(Boolean);
664
665 const inspectionSummary = this.scanResult
666 ? JSON.stringify({
667 risk_level: this.scanResult.risk_level,
668 warnings: this.scanResult.warnings || [],
669 inspected_tools: this.scanResult.inspected_tools || [],
670 }, null, 2)
671 : "No deterministic config inspection has been run in this modal yet.";
672
673 let prompt = template;
674 prompt = prompt.replace(/\{\{SERVER_JSON\}\}/g, this.scanTargetJson || JSON.stringify(this.scanServer, null, 2));
675 prompt = prompt.replace(/\{\{CONFIG_SCOPE\}\}/g, this.scope === "project" && this.projectName ? `project: ${this.projectName}` : "global draft");
676 prompt = prompt.replace(/\{\{RUNTIME_INSPECTION\}\}/g, this.scanOptions.inspectRuntime ? "requested" : "not requested");
677 prompt = prompt.replace(/\{\{ALLOW_LOCAL_EXECUTION\}\}/g, this.scanOptions.allowLocalExecution ? "yes" : "no");
678 prompt = prompt.replace(/\{\{ALLOW_REMOTE_NETWORK\}\}/g, this.scanOptions.allowRemoteNetwork ? "yes" : "no");
679 prompt = prompt.replace(/\{\{INSPECTION_SUMMARY\}\}/g, inspectionSummary);
680 prompt = prompt.replace(
681 /\{\{SELECTED_CHECKS\}\}/g,
682 selected.length ? selected.map((check) => `- ${check.label}`).join("\n") : "- (no checks selected)",
683 );
684 prompt = prompt.replace(
685 /\{\{CHECK_DETAILS\}\}/g,
686 selected.length
687 ? selected.map((check) => `**${check.label}**: ${check.detail}\n${formatCriteria(ratings, check.criteria)}`).join("\n\n")
688 : "(no checks selected)",
689 );
690 prompt = prompt.replace(/\{\{STATUS_LEGEND\}\}/g, formatStatusLegend(ratings));
691 prompt = prompt.replace(/\{\{RATING_ICONS\}\}/g, formatRatingIcons(ratings));
692 prompt = prompt.replace(/\{\{RATING_PASS\}\}/g, ratings.pass?.icon || "PASS");
693 prompt = prompt.replace(/\{\{RATING_WARNING\}\}/g, ratings.warning?.icon || "WARN");
694 prompt = prompt.replace(/\{\{RATING_FAIL\}\}/g, ratings.fail?.icon || "FAIL");
695 this.scanPrompt = prompt;
696 } catch (error) {
697 console.error("Failed to build MCP scan prompt:", error);
698 void toastFrontendError(`Failed to build scan prompt: ${error.message || error}`, SCAN_TITLE);
699 }
700 },
701
702 async runConfigInspection() {
703 if (!this.scanServer && !this.prepareScanTarget()) return;
704 this.scanLoading = true;
705 this.scanResult = null;
706 try {
707 const response = await API.callJsonApi("mcp_server_scan", {
708 server: this.scanServer,
709 inspect_runtime: !!this.scanOptions.inspectRuntime,
710 allow_local_execution: !!this.scanOptions.allowLocalExecution,
711 allow_remote_network: !!this.scanOptions.allowRemoteNetwork,
712 });
713 if (!response?.success) throw new Error(response?.error || "Scan failed");
714 this.scanResult = response;
715 await this.buildScanPrompt();
716 } catch (error) {
717 console.error("MCP scan failed:", error);
718 void toastFrontendError(`MCP scan failed: ${error.message || error}`, SCAN_TITLE);
719 } finally {
720 this.scanLoading = false;
721 }
722 },
723
724 async copyScanPrompt() {
725 try {
726 await navigator.clipboard.writeText(this.scanPrompt || "");
727 } catch {
728 void toastFrontendError("Failed to copy the scan prompt", SCAN_TITLE);
729 }
730 },
731
732 async runAgentScan() {
733 if (this.agentScanning) return;
734 if (!this.scanServer && !this.prepareScanTarget()) return;
735 await this.buildScanPrompt();
736
737 const prompt = String(this.scanPrompt || "").trim();
738 if (!prompt) {
739 void toastFrontendError("Scan prompt is empty", SCAN_TITLE);
740 return;
741 }
742
743 const gen = ++scanPollGeneration;
744 this.scanOutput = "";
745
746 let ctxId = "";
747 try {
748 const resp = await API.callJsonApi("/chat_create", {});
749 if (!resp?.ok || !resp.ctxid) throw new Error(resp?.message || "Failed to create scan chat");
750 ctxId = resp.ctxid;
751 this.scanCtxId = ctxId;
752 await API.callJsonApi("/message_queue_add", { context: ctxId, text: prompt });
753 this.agentScanning = true;
754 await API.callJsonApi("/message_queue_send", { context: ctxId });
755 void this.pollAgentScan(gen, ctxId);
756 } catch (error) {
757 this.agentScanning = false;
758 console.error("MCP agent scan failed:", error);
759 void toastFrontendError(`Scan failed: ${error.message || error}`, SCAN_TITLE);
760 }
761 },
762
763 async pollAgentScan(gen, ctxId) {
764 let started = false;
765 const deadline = Date.now() + SCAN_MAX_POLL_MS;
766 while (gen === scanPollGeneration) {
767 if (Date.now() >= deadline) {
768 this.agentScanning = false;
769 void toastFrontendError("Scan timed out while waiting for Agent Zero", SCAN_TITLE);
770 return;
771 }
772 await sleep(SCAN_POLL_INTERVAL_MS);
773 try {
774 const snap = await API.callJsonApi("/poll", {
775 context: ctxId,
776 log_from: 0,
777 notifications_from: 0,
778 timezone: getUserTimezone(),
779 });
780
781 if (snap.logs?.length) {
782 const last = snap.logs
783 .filter((log) => log.type === "response" && log.no > 0)
784 .pop();
785 if (last) this.scanOutput = last.content || "";
786 }
787
788 if (snap.log_progress_active) started = true;
789 if (started && !snap.log_progress_active) {
790 this.agentScanning = false;
791 return;
792 }
793 if (snap.deselect_chat) return;
794 } catch (error) {
795 if (gen === scanPollGeneration) console.error("MCP scan poll error:", error);
796 }
797 }
798 },
799
800 openScanChatInNewWindow() {
801 if (!this.scanCtxId) return;
802 const url = new URL(window.location.href);
803 url.searchParams.set("ctxid", this.scanCtxId);
804 window.open(url.toString(), "_blank");
805 },
806
807 scanCleanup() {
808 scanPollGeneration++;
809 this.agentScanning = false;
810 },
811
812 addServerFromForm() {
813 let server;
814 try {
815 server = this.buildServerFromForm();
816 } catch (error) {
817 void toastFrontendError(error.message || String(error), "MCP Servers");
818 return;
819 }
820
821 try {
822 const config = this.getConfigObject();
823 if (Array.isArray(config.mcpServers)) {
824 const index = config.mcpServers.findIndex((item) => normalizeName(item?.name || "") === server.name);
825 if (index >= 0) config.mcpServers.splice(index, 1, server);
826 else config.mcpServers.push(server);
827 } else {
828 const stored = { ...server };
829 delete stored.name;
830 config.mcpServers[server.name] = stored;
831 }
832 this.setEditorValue(stringifyConfig(config));
833 this.resetForm();
834 void toastFrontendSuccess("MCP server added to draft config", "MCP Servers");
835 requestAnimationFrame(() => globalThis.scrollModal?.("mcp-configured-servers"));
836 } catch (error) {
837 console.error("Failed to add MCP server:", error);
838 void toastFrontendError(`Failed to add MCP server: ${error.message || error}`, "MCP Servers");
839 }
840 },
841
842 editConfigServer(name) {
843 const entry = this.configuredServers.find((item) => item.name === name);
844 if (!entry) return;
845 const cfg = entry.config || {};
846 const isRemote = !!(cfg.url || cfg.serverUrl);
847 this.serverForm = {
848 ...createEmptyForm(),
849 mode: isRemote ? "remote" : "local",
850 name,
851 description: cfg.description || "",
852 url: cfg.url || cfg.serverUrl || "",
853 type: cfg.type || "streamable-http",
854 command: cfg.command || "",
855 argsText: formatArgsText(cfg.args),
856 headersText: formatKeyValueText(cfg.headers),
857 envText: formatKeyValueText(cfg.env),
858 init_timeout: cfg.init_timeout || "",
859 tool_timeout: cfg.tool_timeout || "",
860 verify: cfg.verify !== false,
861 disabled: !!cfg.disabled,
862 };
863 this.activeView = "visual";
864 this.scanResult = null;
865 requestAnimationFrame(() => globalThis.scrollModal?.("mcp-add-server"));
866 },
867
868 async removeConfigServer(name) {
869 try {
870 const config = this.getConfigObject();
871 const normalized = normalizeName(name);
872 let removed = false;
873
874 if (Array.isArray(config.mcpServers)) {
875 const nextServers = config.mcpServers.filter((server) => normalizeName(server?.name || "") !== normalized);
876 removed = nextServers.length !== config.mcpServers.length;
877 config.mcpServers = nextServers;
878 } else if (config.mcpServers && typeof config.mcpServers === "object") {
879 const key = Object.keys(config.mcpServers).find((serverName) => normalizeName(serverName) === normalized);
880 if (key) {
881 delete config.mcpServers[key];
882 removed = true;
883 }
884 }
885
886 if (!removed) {
887 void toastFrontendWarning("MCP server is no longer in this config.", "MCP Servers");
888 await this.loadStatus({ silent: true });
889 return;
890 }
891
892 this.setEditorValue(stringifyConfig(config));
893 if (normalizeName(this.serverForm.name) === normalized) this.resetForm();
894 await this.applyNow({ successMessage: "MCP server removed" });
895 } catch (error) {
896 void toastFrontendError(`Failed to remove MCP server: ${error.message || error}`, "MCP Servers");
897 }
898 },
899
900 toggleConfigServer(name) {
901 try {
902 const config = this.getConfigObject();
903 if (Array.isArray(config.mcpServers)) {
904 const server = config.mcpServers.find((item) => normalizeName(item?.name || "") === normalizeName(name));
905 if (server) server.disabled = !server.disabled;
906 } else if (config.mcpServers[name]) {
907 config.mcpServers[name].disabled = !config.mcpServers[name].disabled;
908 }
909 this.setEditorValue(stringifyConfig(config));
910 } catch (error) {
911 void toastFrontendError(`Failed to update MCP server: ${error.message || error}`, "MCP Servers");
912 }
913 },
914
915 getServerConfigRef(config, name) {
916 const normalized = normalizeName(name);
917 if (Array.isArray(config.mcpServers)) {
918 const server = config.mcpServers.find((item) => normalizeName(item?.name || "") === normalized);
919 return server ? { server } : null;
920 }
921 if (config.mcpServers && typeof config.mcpServers === "object") {
922 const key = Object.keys(config.mcpServers).find((serverName) => normalizeName(serverName) === normalized);
923 if (key) return { server: config.mcpServers[key] };
924 }
925 return null;
926 },
927
928 getDisabledToolsForServer(name) {
929 try {
930 const ref = this.getServerConfigRef(this.getConfigObject(), name);
931 const disabled = ref?.server?.disabled_tools;
932 return Array.isArray(disabled) ? disabled.map((toolName) => String(toolName)) : [];
933 } catch {
934 return [];
935 }
936 },
937
938 canConfigureServerTools(name) {
939 try {
940 return !!this.getServerConfigRef(this.getConfigObject(), name);
941 } catch {
942 return false;
943 }
944 },
945
946 isServerToolEnabled(serverName, toolName) {
947 const disabled = this.getDisabledToolsForServer(serverName);
948 return !disabled.includes(String(toolName || ""));
949 },
950
951 toggleServerTool(serverName, toolName, enabled) {
952 const normalizedTool = String(toolName || "").trim();
953 if (!serverName || !normalizedTool) return;
954
955 try {
956 const config = this.getConfigObject();
957 const ref = this.getServerConfigRef(config, serverName);
958 if (!ref?.server) {
959 void toastFrontendWarning("Add this inherited server to the current config before changing its tools.", "MCP Servers");
960 return;
961 }
962
963 const disabled = Array.isArray(ref.server.disabled_tools)
964 ? ref.server.disabled_tools.map((item) => String(item)).filter(Boolean)
965 : [];
966 const nextDisabled = new Set(disabled);
967 if (enabled) nextDisabled.delete(normalizedTool);
968 else nextDisabled.add(normalizedTool);
969
970 const disabledTools = [...nextDisabled].sort((a, b) => a.localeCompare(b));
971 if (disabledTools.length) ref.server.disabled_tools = disabledTools;
972 else delete ref.server.disabled_tools;
973
974 this.setEditorValue(stringifyConfig(config));
975 if (this.serverDetail?.name === serverName && Array.isArray(this.serverDetail.tools)) {
976 this.serverDetail.tools = this.serverDetail.tools.map((tool) => (
977 tool.name === normalizedTool
978 ? { ...tool, disabled: !enabled }
979 : tool
980 ));
981 }
982 } catch (error) {
983 void toastFrontendError(`Failed to update MCP tool: ${error.message || error}`, "MCP Servers");
984 }
985 },
986
987 async startStatusCheck() {
988 this.statusCheck = true;
989 while (this.statusCheck) {
990 await sleep(STATUS_INTERVAL_MS);
991 if (this.statusCheck) await this.loadStatus({ silent: true });
992 }
993 },
994
995 async loadStatus(options = {}) {
996 try {
997 const resp = await API.callJsonApi("mcp_servers_status", this.getStatusPayload());
998 if (resp?.success) {
999 this.servers = resp.status || [];
1000 this.servers.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
1001 } else if (!options.silent) {
1002 void toastFrontendWarning(resp?.error || "Unable to load MCP status", "MCP Servers");
1003 }
1004 } catch (error) {
1005 if (!options.silent) {
1006 console.error("Failed to load MCP status:", error);
1007 void toastFrontendError("Failed to load MCP status", "MCP Servers");
1008 }
1009 }
1010 },
1011
1012 stopStatusCheck() {
1013 this.statusCheck = false;
1014 },
1015
1016 async applyNow(options = {}) {
1017 if (this.applying) return;
1018 try {
1019 const formatted = stringifyConfig(this.getConfigObject());
1020 this.setEditorValue(formatted);
1021 } catch (error) {
1022 void toastFrontendError(`Invalid JSON: ${error.message || error}`, "MCP Servers");
1023 return;
1024 }
1025
1026 this.applying = true;
1027 try {
1028 const resp = await API.callJsonApi("mcp_servers_apply", this.getApplyPayload());
1029 if (!resp?.success) throw new Error(resp?.error || "Apply failed");
1030 this.setScopeConfigJson(resp.mcp_servers || this.getEditorValue());
1031 this.servers = resp.status || [];
1032 this.servers.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
1033 if (options.successMessage !== false) {
1034 void toastFrontendSuccess(options.successMessage || "MCP servers applied", "MCP Servers");
1035 }
1036 await sleep(100);
1037 if (options.scrollToStatus !== false && globalThis.scrollModal) {
1038 globalThis.scrollModal("mcp-servers-status");
1039 }
1040 } catch (error) {
1041 console.error("Failed to apply MCP servers:", error);
1042 void toastFrontendError(`Failed to apply MCP servers: ${error.message || error}`, "MCP Servers");
1043 } finally {
1044 this.applying = false;
1045 }
1046 },
1047
1048 async getServerLog(serverName) {
1049 this.serverLog = "";
1050 const payload = { server_name: serverName, ...(this.getStatusPayload() || {}) };
1051 const resp = await API.callJsonApi("mcp_server_get_log", payload);
1052 if (resp?.success) {
1053 this.serverLog = resp.log;
1054 openModal("settings/mcp/client/mcp-servers-log.html");
1055 }
1056 },
1057
1058 async onToolCountClick(serverName) {
1059 const payload = { server_name: serverName, ...(this.getStatusPayload() || {}) };
1060 const resp = await API.callJsonApi("mcp_server_get_detail", payload);
1061 if (resp?.success) {
1062 this.serverDetail = resp.detail;
1063 this.toolSearch = "";
1064 openModal("settings/mcp/client/mcp-server-tools.html");
1065 }
1066 },
1067
1068 statusLabel(server) {
1069 if (!server.connected) return "Unavailable";
1070 if (server.error) return "Needs attention";
1071 if ((server.tool_count || 0) > 0) return "Ready";
1072 return "Connected";
1073 },
1074
1075 statusClass(server) {
1076 if (!server.connected || server.error) return "danger";
1077 if ((server.tool_count || 0) > 0) return "ok";
1078 return "idle";
1079 },
1080
1081 configModeLabel(config) {
1082 if (config?.disabled) return "Disabled";
1083 if (config?.url || config?.serverUrl) return "Remote";
1084 return "Local";
1085 },
1086
1087 configSummary(config) {
1088 if (config?.url || config?.serverUrl) return config.url || config.serverUrl;
1089 const args = Array.isArray(config?.args) && config.args.length ? ` ${config.args.join(" ")}` : "";
1090 return `${config?.command || "command"}${args}`;
1091 },
1092
1093 get scanWarnings() {
1094 return this.scanResult?.warnings || [];
1095 },
1096
1097 get scanRiskLabel() {
1098 const risk = this.scanResult?.risk_level || "";
1099 if (risk === "ok") return "No major issues found";
1100 if (risk === "warning") return "Review warnings";
1101 if (risk === "error") return "Action needed";
1102 return "";
1103 },
1104
1105 get renderedScanOutput() {
1106 return this.scanOutput ? marked.parse(this.scanOutput, { breaks: true }) : "";
1107 },
1108
1109 onClose() {
1110 try {
1111 this.setScopeConfigJson(this.getEditorValue());
1112 } catch {}
1113 this.stopStatusCheck();
1114 if (this.editor) {
1115 try { this.editor.destroy(); } catch {}
1116 this.editor = null;
1117 }
1118 this.servers = [];
1119 this.loading = true;
1120 this.applying = false;
1121 this.activeView = "visual";
1122 this.serverSearch = "";
1123 this.toolSearch = "";
1124 this.serverDetail = null;
1125 this.resetForm();
1126 this.resetScanState();
1127 this.resetScope();
1128 },
1129 };
1130
1131 const store = createStore("mcpServersStore", model);
1132
1133 export { store };