From 0f8dc4e8070f0da5abe579abff2ff59d7249d9ad Mon Sep 17 00:00:00 2001 From: lpf Date: Mon, 9 Mar 2026 16:40:09 +0800 Subject: [PATCH] feat(webui): redesign mcp management flow --- webui/src/pages/MCP.tsx | 750 ++++++++++++++++++++++++++-------------- 1 file changed, 492 insertions(+), 258 deletions(-) diff --git a/webui/src/pages/MCP.tsx b/webui/src/pages/MCP.tsx index 05f9b1c..1d8523e 100644 --- a/webui/src/pages/MCP.tsx +++ b/webui/src/pages/MCP.tsx @@ -1,48 +1,59 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { RefreshCw, Save } from 'lucide-react'; +import { AnimatePresence, motion } from 'motion/react'; +import { Package, Pencil, Plus, RefreshCw, Save, Trash2, Wrench, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; -function setPath(obj: any, path: string, value: any) { - const keys = path.split('.'); - const next = JSON.parse(JSON.stringify(obj || {})); - let cur = next; - for (let i = 0; i < keys.length - 1; i++) { - const k = keys[i]; - if (typeof cur[k] !== 'object' || cur[k] === null) cur[k] = {}; - cur = cur[k]; - } - cur[keys[keys.length - 1]] = value; - return next; -} +type MCPDraftServer = { + enabled: boolean; + transport: string; + url: string; + command: string; + args: string[]; + env: Record; + permission: string; + working_dir: string; + description: string; + package: string; + installer?: string; +}; + +const emptyDraftServer = (): MCPDraftServer => ({ + enabled: true, + transport: 'stdio', + url: '', + command: '', + args: [], + env: {}, + permission: 'workspace', + working_dir: '', + description: '', + package: '', + installer: '', +}); + +const cloneDeep = (value: T): T => JSON.parse(JSON.stringify(value)); const MCP: React.FC = () => { const { t } = useTranslation(); const { cfg, setCfg, q, loadConfig, setConfigEditing } = useAppContext(); const ui = useUI(); - const [newMCPServerName, setNewMCPServerName] = useState(''); const [mcpTools, setMcpTools] = useState>([]); const [mcpServerChecks, setMcpServerChecks] = useState>([]); - const [argInputs, setArgInputs] = useState>({}); - const [baseline, setBaseline] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [editingName, setEditingName] = useState(null); + const [draftName, setDraftName] = useState(''); + const [draft, setDraft] = useState(emptyDraftServer()); + const [draftArgInput, setDraftArgInput] = useState(''); - const currentPayload = useMemo(() => cfg || {}, [cfg]); - const isDirty = useMemo(() => { - if (baseline == null) return false; - return JSON.stringify(baseline) !== JSON.stringify(currentPayload); - }, [baseline, currentPayload]); + const servers = useMemo(() => ((((cfg as any)?.tools?.mcp?.servers) || {}) as Record), [cfg]); + const serverEntries = useMemo(() => Object.entries(servers), [servers]); useEffect(() => { - if (baseline == null && cfg && Object.keys(cfg).length > 0) { - setBaseline(JSON.parse(JSON.stringify(cfg))); - } - }, [cfg, baseline]); - - useEffect(() => { - setConfigEditing(isDirty); + setConfigEditing(false); return () => setConfigEditing(false); - }, [isDirty, setConfigEditing]); + }, [setConfigEditing]); async function refreshMCPTools(cancelled = false) { try { @@ -69,60 +80,163 @@ const MCP: React.FC = () => { }; }, [q]); - function updateMCPServerField(name: string, field: string, value: any) { - setCfg((v) => setPath(v, `tools.mcp.servers.${name}.${field}`, value)); + function openCreateModal() { + setEditingName(null); + setDraftName(''); + setDraft(emptyDraftServer()); + setDraftArgInput(''); + setModalOpen(true); } - function addMCPArg(name: string, rawValue: string) { + function openEditModal(name: string, server: any) { + setEditingName(name); + setDraftName(name); + setDraft({ + enabled: Boolean(server?.enabled), + transport: String(server?.transport || 'stdio'), + url: String(server?.url || ''), + command: String(server?.command || ''), + args: Array.isArray(server?.args) ? server.args.map((x: any) => String(x)) : [], + env: typeof server?.env === 'object' && server?.env ? cloneDeep(server.env) : {}, + permission: String(server?.permission || 'workspace'), + working_dir: String(server?.working_dir || ''), + description: String(server?.description || ''), + package: String(server?.package || ''), + installer: String(server?.installer || ''), + }); + setDraftArgInput(''); + setModalOpen(true); + } + + function closeModal() { + setModalOpen(false); + setEditingName(null); + setDraftName(''); + setDraft(emptyDraftServer()); + setDraftArgInput(''); + } + + function updateDraftField(field: K, value: MCPDraftServer[K]) { + setDraft((prev) => ({ ...prev, [field]: value })); + } + + function addDraftArg(rawValue: string) { const value = rawValue.trim(); if (!value) return; - const current = ((((cfg as any)?.tools?.mcp?.servers?.[name]?.args) || []) as any[]) - .map((x) => String(x).trim()) - .filter(Boolean); - updateMCPServerField(name, 'args', [...current, value]); - setArgInputs((prev) => ({ ...prev, [name]: '' })); + setDraft((prev) => ({ ...prev, args: [...prev.args.map((x) => x.trim()).filter(Boolean), value] })); + setDraftArgInput(''); } - function removeMCPArg(name: string, index: number) { - const current = ((((cfg as any)?.tools?.mcp?.servers?.[name]?.args) || []) as any[]) - .map((x) => String(x).trim()) - .filter(Boolean); - updateMCPServerField(name, 'args', current.filter((_, i) => i !== index)); + function removeDraftArg(index: number) { + setDraft((prev) => ({ ...prev, args: prev.args.filter((_, i) => i !== index) })); } - function addMCPServer() { - const name = newMCPServerName.trim(); - if (!name) return; - setCfg((v) => { - const next = JSON.parse(JSON.stringify(v || {})); - if (!next.tools || typeof next.tools !== 'object') next.tools = {}; - if (!next.tools.mcp || typeof next.tools.mcp !== 'object') { - next.tools.mcp = { enabled: true, request_timeout_sec: 20, servers: {} }; - } - if (!next.tools.mcp.servers || typeof next.tools.mcp.servers !== 'object' || Array.isArray(next.tools.mcp.servers)) { - next.tools.mcp.servers = {}; - } - if (!next.tools.mcp.servers[name]) { - next.tools.mcp.servers[name] = { - enabled: true, - transport: 'stdio', - url: '', - command: '', - args: [], - env: {}, - permission: 'workspace', - working_dir: '', - description: '', - package: '', - }; - } - return next; - }); - setNewMCPServerName(''); - setArgInputs((prev) => ({ ...prev, [name]: '' })); + function inferMCPInstallSpec(server: MCPDraftServer): { installer: string; packageName: string } { + if (typeof server.installer === 'string' && server.installer.trim() && typeof server.package === 'string' && server.package.trim()) { + return { installer: server.installer.trim(), packageName: server.package.trim() }; + } + if (typeof server.package === 'string' && server.package.trim()) { + return { installer: 'npm', packageName: server.package.trim() }; + } + const command = String(server.command || '').trim().split('/').pop() || ''; + const args = Array.isArray(server.args) ? server.args.map((x) => String(x).trim()).filter(Boolean) : []; + const pkg = args.find((arg) => !arg.startsWith('-')) || ''; + if (command === 'npx') return { installer: 'npm', packageName: pkg }; + if (command === 'uvx') return { installer: 'uv', packageName: pkg }; + if (command === 'bunx') return { installer: 'bun', packageName: pkg }; + return { installer: 'npm', packageName: '' }; } - async function removeMCPServer(name: string) { + async function persistConfig(nextCfg: any) { + const submit = async (payload: any, confirmRisky: boolean) => { + const body = confirmRisky ? { ...payload, confirm_risky: true } : payload; + return ui.withLoading(async () => { + const r = await fetch(`/webui/api/config${q}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const text = await r.text(); + let data: any = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = null; + } + return { ok: r.ok, text, data }; + }, t('saving')); + }; + + let result = await submit(nextCfg, false); + if (!result.ok && result.data?.requires_confirm) { + const changedFields = Array.isArray(result.data?.changed_fields) ? result.data.changed_fields.join(', ') : ''; + const ok = await ui.confirmDialog({ + title: t('configRiskyChangeConfirmTitle'), + message: t('configRiskyChangeConfirmMessage', { fields: changedFields || '-' }), + danger: true, + confirmText: t('saveChanges'), + }); + if (!ok) return false; + result = await submit(nextCfg, true); + } + + if (!result.ok) { + throw new Error(result.data?.error || result.text || 'save failed'); + } + + setCfg(nextCfg); + await loadConfig(true); + await refreshMCPTools(); + return true; + } + + async function saveServer() { + const name = draftName.trim(); + if (!name) { + await ui.notify({ title: t('requestFailed'), message: t('configNewMCPServerName') }); + return; + } + if (editingName !== name && servers[name]) { + await ui.notify({ title: t('requestFailed'), message: `${name} already exists` }); + return; + } + + const next = cloneDeep(cfg || {}); + if (!next.tools || typeof next.tools !== 'object') next.tools = {}; + if (!next.tools.mcp || typeof next.tools.mcp !== 'object') { + next.tools.mcp = { enabled: true, request_timeout_sec: 20, servers: {} }; + } + if (!next.tools.mcp.servers || typeof next.tools.mcp.servers !== 'object' || Array.isArray(next.tools.mcp.servers)) { + next.tools.mcp.servers = {}; + } + if (editingName && editingName !== name) { + delete next.tools.mcp.servers[editingName]; + } + next.tools.mcp.servers[name] = { + enabled: draft.enabled, + transport: draft.transport, + url: draft.url, + command: draft.command, + args: draft.args.map((x) => x.trim()).filter(Boolean), + env: draft.env, + permission: draft.permission, + working_dir: draft.working_dir, + description: draft.description, + package: draft.package, + installer: draft.installer, + }; + + try { + const ok = await persistConfig(next); + if (!ok) return; + await ui.notify({ title: t('saved'), message: t('configSaved') }); + closeModal(); + } catch (e) { + await ui.notify({ title: t('requestFailed'), message: `${t('saveConfigFailed')}: ${e}` }); + } + } + + async function removeServer(name: string) { const ok = await ui.confirmDialog({ title: t('configDeleteMCPServerConfirmTitle'), message: t('configDeleteMCPServerConfirmMessage', { name }), @@ -130,42 +244,26 @@ const MCP: React.FC = () => { confirmText: t('delete'), }); if (!ok) return; - setCfg((v) => { - const next = JSON.parse(JSON.stringify(v || {})); + try { + const next = cloneDeep(cfg || {}); if (next?.tools?.mcp?.servers && typeof next.tools.mcp.servers === 'object') { delete next.tools.mcp.servers[name]; } - return next; - }); - setArgInputs((prev) => { - const next = { ...prev }; - delete next[name]; - return next; - }); + const saved = await persistConfig(next); + if (!saved) return; + await ui.notify({ title: t('saved'), message: t('configSaved') }); + if (editingName === name) closeModal(); + } catch (e) { + await ui.notify({ title: t('requestFailed'), message: `${t('saveConfigFailed')}: ${e}` }); + } } - function inferMCPInstallSpec(server: any): { installer: string; packageName: string } { - if (typeof server?.installer === 'string' && server.installer.trim() && typeof server?.package === 'string' && server.package.trim()) { - return { installer: server.installer.trim(), packageName: server.package.trim() }; - } - if (typeof server?.package === 'string' && server.package.trim()) { - return { installer: 'npm', packageName: server.package.trim() }; - } - const command = String(server?.command || '').trim().split('/').pop() || ''; - const args = Array.isArray(server?.args) ? server.args.map((x: any) => String(x).trim()).filter(Boolean) : []; - const pkg = args.find((arg: string) => !arg.startsWith('-')) || ''; - if (command === 'npx') return { installer: 'npm', packageName: pkg }; - if (command === 'uvx') return { installer: 'uv', packageName: pkg }; - if (command === 'bunx') return { installer: 'bun', packageName: pkg }; - return { installer: 'npm', packageName: '' }; - } - - async function installMCPServerPackage(name: string, server: any) { - const inferred = inferMCPInstallSpec(server); + async function installDraftPackage() { + const inferred = inferMCPInstallSpec(draft); const defaultPkg = inferred.packageName; const pkg = await ui.promptDialog({ title: t('configMCPInstallTitle'), - message: t('configMCPInstallMessage', { name }), + message: t('configMCPInstallMessage', { name: draftName.trim() || t('configMCPServers') }), inputPlaceholder: defaultPkg || t('configMCPInstallPlaceholder'), initialValue: defaultPkg, confirmText: t('install'), @@ -191,13 +289,12 @@ const MCP: React.FC = () => { } catch { data = null; } - if (data?.bin_path) { - updateMCPServerField(name, 'command', data.bin_path); - updateMCPServerField(name, 'args', []); - updateMCPServerField(name, 'package', packageName); - } else { - updateMCPServerField(name, 'package', packageName); - } + setDraft((prev) => ({ + ...prev, + command: data?.bin_path ? String(data.bin_path) : prev.command, + args: data?.bin_path ? [] : prev.args, + package: packageName, + })); await ui.notify({ title: t('configMCPInstallDoneTitle'), message: data?.bin_path @@ -209,63 +306,51 @@ const MCP: React.FC = () => { } } - async function installMCPServerCheckPackage(check: { name: string; package?: string; installer?: string }) { - const server = (((cfg as any)?.tools?.mcp?.servers?.[check.name]) || {}) as any; - if (check.package && !String(server?.package || '').trim()) { - updateMCPServerField(check.name, 'package', check.package); - } - await installMCPServerPackage(check.name, { ...server, package: check.package || server?.package, installer: check.installer }); - } - - async function saveConfig() { + async function installCheckPackage(check: { package?: string; installer?: string }) { + const inferred = inferMCPInstallSpec({ ...draft, package: check.package || draft.package, installer: check.installer || draft.installer }); + const packageName = String(check.package || draft.package || '').trim(); + if (!packageName) return; + ui.showLoading(t('configMCPInstalling')); try { - const payload = cfg; - const submit = async (confirmRisky: boolean) => { - const body = confirmRisky ? { ...payload, confirm_risky: true } : payload; - return ui.withLoading(async () => { - const r = await fetch(`/webui/api/config${q}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const text = await r.text(); - let data: any = null; - try { - data = text ? JSON.parse(text) : null; - } catch { - data = null; - } - return { ok: r.ok, text, data }; - }, t('saving')); - }; - - let result = await submit(false); - if (!result.ok && result.data?.requires_confirm) { - const changedFields = Array.isArray(result.data?.changed_fields) ? result.data.changed_fields.join(', ') : ''; - const ok = await ui.confirmDialog({ - title: t('configRiskyChangeConfirmTitle'), - message: t('configRiskyChangeConfirmMessage', { fields: changedFields || '-' }), - danger: true, - confirmText: t('saveChanges'), - }); - if (!ok) return; - result = await submit(true); + const r = await fetch(`/webui/api/mcp/install${q}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ package: packageName, installer: check.installer || inferred.installer }), + }); + const text = await r.text(); + if (!r.ok) { + await ui.notify({ title: t('configMCPInstallFailedTitle'), message: text || t('configMCPInstallFailedMessage') }); + return; } - - if (!result.ok) { - throw new Error(result.data?.error || result.text || 'save failed'); + let data: any = null; + try { + data = JSON.parse(text); + } catch { + data = null; } - - await ui.notify({ title: t('saved'), message: t('configSaved') }); - setBaseline(JSON.parse(JSON.stringify(payload))); - setConfigEditing(false); - await loadConfig(true); - await refreshMCPTools(); - } catch (e) { - await ui.notify({ title: t('requestFailed'), message: `${t('saveConfigFailed')}: ${e}` }); + setDraft((prev) => ({ + ...prev, + command: data?.bin_path ? String(data.bin_path) : prev.command, + args: data?.bin_path ? [] : prev.args, + package: packageName, + installer: check.installer || prev.installer, + })); + await ui.notify({ + title: t('configMCPInstallDoneTitle'), + message: data?.bin_path + ? t('configMCPInstallDoneMessage', { package: packageName, bin: data.bin_path }) + : (text || t('configMCPInstallDoneFallback')), + }); + } finally { + ui.hideLoading(); } } + const activeCheck = useMemo(() => { + if (!draftName.trim()) return null; + return mcpServerChecks.find((item) => item.name === draftName.trim()) || null; + }, [draftName, mcpServerChecks]); + return (
@@ -273,117 +358,98 @@ const MCP: React.FC = () => {

{t('mcpServices')}

{t('mcpServicesHint')}

- +
+ + +
-
- -
- -
+
{t('configMCPServers')}
-
- setNewMCPServerName(e.target.value)} placeholder={t('configNewMCPServerName')} className="px-2 py-1 rounded-lg bg-zinc-900/70 border border-zinc-700 text-xs" /> - -
+
{serverEntries.length}
-
- {Object.entries((((cfg as any)?.tools?.mcp?.servers) || {}) as Record).map(([name, server]) => { +
+ {serverEntries.map(([name, server]) => { const transport = String(server?.transport || 'stdio'); - const isStdio = transport === 'stdio'; - const usesURL = transport === 'http' || transport === 'streamable_http' || transport === 'sse'; + const check = mcpServerChecks.find((item) => item.name === name); + const discoveredCount = mcpTools.filter((tool) => tool.mcp?.server === name).length; return ( -
-
{name}
- - - {isStdio && ( - <> - updateMCPServerField(name, 'command', e.target.value)} placeholder={t('configLabels.command')} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> - - updateMCPServerField(name, 'working_dir', e.target.value)} placeholder={t('configLabels.working_dir')} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> -
-
- {(Array.isArray(server?.args) ? server.args : []).map((arg: any, index: number) => ( - - {String(arg)} - - - ))} +
+
+
+
+
{name}
+ + {server?.enabled ? t('enable') : t('paused')} + + + {transport} +
- setArgInputs((prev) => ({ ...prev, [name]: e.target.value }))} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - addMCPArg(name, argInputs[name] || ''); - } - }} - onBlur={() => addMCPArg(name, argInputs[name] || '')} - placeholder={t('configMCPArgsEnterHint')} - className="w-full px-2 py-1 rounded-lg bg-zinc-900/80 border border-zinc-800" - /> -
- updateMCPServerField(name, 'package', e.target.value)} placeholder={t('configLabels.package')} className="md:col-span-1 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> - - )} - {usesURL && ( - updateMCPServerField(name, 'url', e.target.value)} placeholder={t('configLabels.url')} className="md:col-span-5 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> - )} - updateMCPServerField(name, 'description', e.target.value)} placeholder={t('configLabels.description')} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> - {isStdio && ( - - )} - - {(() => { - const check = mcpServerChecks.find((item) => item.name === name); - if (!check || check.status === 'ok' || check.status === 'disabled' || check.status === 'not_applicable') return null; - return ( -
-
-
{check.message || t('configMCPCommandMissing')}
- {check.package && ( -
{t('configMCPInstallSuggested', { pkg: check.package })} {check.installer ? `(${check.installer})` : ''}
- )} +
+ {transport === 'stdio' ? String(server?.command || '-') : String(server?.url || '-')}
- {check.installable && ( - + {server?.description && ( +
{String(server.description)}
)}
- ); - })()} -
- )})} - {Object.keys((((cfg as any)?.tools?.mcp?.servers) || {}) as Record).length === 0 && ( -
{t('configNoMCPServers')}
- )} +
+ + +
+
+ +
+
+
package
+
{String(server?.package || '-')}
+
+
+
args
+
{Array.isArray(server?.args) ? server.args.length : 0}
+
+
+
permission
+
{String(server?.permission || 'workspace')}
+
+
+
{t('configMCPDiscoveredTools')}
+
{discoveredCount}
+
+
+ + {check && check.status !== 'ok' && check.status !== 'disabled' && check.status !== 'not_applicable' && ( +
+
{check.message || t('configMCPCommandMissing')}
+ {check.package && ( +
{t('configMCPInstallSuggested', { pkg: check.package })}
+ )} +
+ )} +
+ ); + })}
+ {serverEntries.length === 0 && ( +
+ {t('configNoMCPServers')} +
+ )}
@@ -410,6 +476,174 @@ const MCP: React.FC = () => { )}
+ + + {modalOpen && ( + + + +
+
+
+ {editingName ? `${t('edit')} MCP` : `${t('add')} MCP`} +
+
{t('mcpServicesHint')}
+
+ +
+ +
+
+ + +
+ +
+ + + {draft.transport === 'stdio' && ( + + )} + {draft.transport === 'stdio' && ( + + )} +
+ + {draft.transport === 'stdio' ? ( +
+ + +
+ ) : ( + + )} + + {draft.transport === 'stdio' && ( +
+
+
+
Args
+
{t('configMCPArgsEnterHint')}
+
+ +
+ +
+ {draft.args.map((arg, index) => ( + + {arg} + + + ))} +
+ setDraftArgInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addDraftArg(draftArgInput); + } + }} + onBlur={() => addDraftArg(draftArgInput)} + placeholder={t('configMCPArgsEnterHint')} + className="h-11 w-full rounded-xl border border-zinc-800 bg-zinc-900/80 px-3" + /> +
+ )} + + {activeCheck && activeCheck.status !== 'ok' && activeCheck.status !== 'disabled' && activeCheck.status !== 'not_applicable' && ( +
+
{activeCheck.message || t('configMCPCommandMissing')}
+ {activeCheck.package && ( +
{t('configMCPInstallSuggested', { pkg: activeCheck.package })}
+ )} + {activeCheck.installable && ( + + )} +
+ )} +
+ +
+
+ {activeCheck?.resolved ? activeCheck.resolved : ''} +
+
+ {editingName && ( + + )} + + +
+
+
+
+ )} +
); };