feat: add MCP tool and web UI management

This commit is contained in:
lpf
2026-03-07 22:09:52 +08:00
parent cff379809c
commit fb5a228e7d
16 changed files with 1836 additions and 4 deletions

View File

@@ -9,6 +9,7 @@ import Config from './pages/Config';
import Cron from './pages/Cron';
import Logs from './pages/Logs';
import Skills from './pages/Skills';
import MCP from './pages/MCP';
import Memory from './pages/Memory';
import TaskAudit from './pages/TaskAudit';
import EKG from './pages/EKG';
@@ -28,6 +29,7 @@ export default function App() {
<Route path="logs" element={<Logs />} />
<Route path="log-codes" element={<LogCodes />} />
<Route path="skills" element={<Skills />} />
<Route path="mcp" element={<MCP />} />
<Route path="config" element={<Config />} />
<Route path="cron" element={<Cron />} />
<Route path="memory" element={<Memory />} />

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { LayoutDashboard, MessageSquare, Settings, Clock, Terminal, Zap, FolderOpen, ClipboardList, BrainCircuit, Hash, Bot, Boxes, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { LayoutDashboard, MessageSquare, Settings, Clock, Terminal, Zap, FolderOpen, ClipboardList, BrainCircuit, Hash, Bot, Boxes, PanelLeftClose, PanelLeftOpen, Plug } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import NavItem from './NavItem';
@@ -29,6 +29,7 @@ const Sidebar: React.FC = () => {
title: t('sidebarConfig'),
items: [
{ icon: <Settings className="w-5 h-5" />, label: t('config'), to: '/config' },
{ icon: <Plug className="w-5 h-5" />, label: t('mcpServices'), to: '/mcp' },
{ icon: <Bot className="w-5 h-5" />, label: t('subagentProfiles'), to: '/subagent-profiles' },
{ icon: <Clock className="w-5 h-5" />, label: t('cronJobs'), to: '/cron' },
],

View File

@@ -8,6 +8,8 @@ const resources = {
dashboard: 'Dashboard',
chat: 'Chat',
config: 'Config',
mcpServices: 'MCP',
mcpServicesHint: 'Manage MCP servers, install packages, and inspect discovered remote tools.',
cronJobs: 'Cron Jobs',
nodes: 'Nodes',
agentTree: 'Agent Tree',
@@ -298,6 +300,23 @@ const resources = {
configProxies: 'Proxies',
configNewProviderName: 'new provider name',
configNoCustomProviders: 'No custom providers yet.',
configMCPServers: 'MCP Servers',
configNewMCPServerName: 'new MCP server name',
configNoMCPServers: 'No MCP servers configured yet.',
configMCPInstallTitle: 'Install MCP Server Package',
configMCPInstallMessage: 'Install an npm package for MCP server "{{name}}"?',
configMCPInstallPlaceholder: '@scope/package',
configMCPInstalling: 'Installing MCP package...',
configMCPInstallFailedTitle: 'MCP install failed',
configMCPInstallFailedMessage: 'Failed to install MCP package',
configMCPInstallDoneTitle: 'MCP package installed',
configMCPInstallDoneMessage: 'Installed {{package}} and resolved binary {{bin}}.',
configMCPInstallDoneFallback: 'MCP package installed.',
configMCPDiscoveredTools: 'Discovered MCP Tools',
configMCPDiscoveredToolsCount: '{{count}} discovered',
configNoMCPDiscoveredTools: 'No MCP tools discovered yet.',
configDeleteMCPServerConfirmTitle: 'Delete MCP Server',
configDeleteMCPServerConfirmMessage: 'Delete MCP server "{{name}}" from current config?',
configNoGroups: 'No config groups found.',
configDiffPreviewCount: 'Diff Preview ({{count}} items)',
saveConfigFailed: 'Failed to save config',
@@ -388,6 +407,7 @@ const resources = {
api_base: 'API Base',
protocol: 'Protocol',
models: 'Models',
command: 'Command',
responses: 'Responses',
streaming: 'Streaming',
web_search_enabled: 'Web Search Enabled',
@@ -403,6 +423,7 @@ const resources = {
version: 'Version',
name: 'Name',
description: 'Description',
package: 'Package',
system_prompt: 'System Prompt',
tools: 'Tools',
auth: 'Authentication',
@@ -479,8 +500,14 @@ const resources = {
sandbox: 'Sandbox',
image: 'Image',
web: 'Web',
mcp: 'MCP',
search: 'Search',
max_results: 'Max Results',
request_timeout_sec: 'Request Timeout (Seconds)',
servers: 'Servers',
transport: 'Transport',
args: 'Arguments',
env: 'Environment',
proxies: 'Proxies',
cross_session_call_id: 'Cross-session Call ID',
supports_responses_compact: 'Supports Responses Compact',
@@ -502,6 +529,8 @@ const resources = {
dashboard: '仪表盘',
chat: '对话',
config: '配置',
mcpServices: 'MCP',
mcpServicesHint: '管理 MCP 服务、安装服务包,并查看已发现的远端工具。',
cronJobs: '定时任务',
nodes: '节点',
agentTree: '代理树',
@@ -792,6 +821,23 @@ const resources = {
configProxies: '代理配置',
configNewProviderName: '新 provider 名称',
configNoCustomProviders: '暂无自定义 provider。',
configMCPServers: 'MCP 服务',
configNewMCPServerName: '新的 MCP 服务名',
configNoMCPServers: '暂无 MCP 服务配置。',
configMCPInstallTitle: '安装 MCP 服务包',
configMCPInstallMessage: '是否为 MCP 服务 “{{name}}” 安装 npm 包?',
configMCPInstallPlaceholder: '@scope/package',
configMCPInstalling: '正在安装 MCP 包...',
configMCPInstallFailedTitle: 'MCP 安装失败',
configMCPInstallFailedMessage: '安装 MCP 包失败',
configMCPInstallDoneTitle: 'MCP 包安装完成',
configMCPInstallDoneMessage: '已安装 {{package}},并解析到可执行文件 {{bin}}。',
configMCPInstallDoneFallback: 'MCP 包已安装。',
configMCPDiscoveredTools: '已发现的 MCP 工具',
configMCPDiscoveredToolsCount: '已发现 {{count}} 个',
configNoMCPDiscoveredTools: '暂未发现 MCP 工具。',
configDeleteMCPServerConfirmTitle: '删除 MCP 服务',
configDeleteMCPServerConfirmMessage: '确认从当前配置中删除 MCP 服务 “{{name}}”吗?',
configNoGroups: '未找到配置分组。',
configDiffPreviewCount: '配置差异预览({{count}}项)',
saveConfigFailed: '保存配置失败',
@@ -882,6 +928,7 @@ const resources = {
api_base: 'API 基础地址',
protocol: '协议',
models: '模型列表',
command: '命令',
responses: 'Responses 配置',
streaming: '流式输出',
web_search_enabled: '启用网页搜索',
@@ -897,6 +944,7 @@ const resources = {
version: '版本',
name: '名称',
description: '描述',
package: '包名',
system_prompt: '系统提示词',
tools: '工具',
auth: '身份验证',
@@ -973,8 +1021,14 @@ const resources = {
sandbox: '沙箱',
image: '镜像',
web: 'Web',
mcp: 'MCP',
search: '搜索',
max_results: '最大结果数',
request_timeout_sec: '请求超时(秒)',
servers: '服务列表',
transport: '传输方式',
args: '参数',
env: '环境变量',
proxies: '代理集合',
cross_session_call_id: '跨会话调用 ID',
supports_responses_compact: '支持紧凑 responses',

308
webui/src/pages/MCP.tsx Normal file
View File

@@ -0,0 +1,308 @@
import React, { useEffect, useMemo, useState } from 'react';
import { RefreshCw, Save } 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;
}
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<Array<{ name: string; description?: string; mcp?: { server?: string; remote_tool?: string } }>>([]);
const [baseline, setBaseline] = useState<any>(null);
const currentPayload = useMemo(() => cfg || {}, [cfg]);
const isDirty = useMemo(() => {
if (baseline == null) return false;
return JSON.stringify(baseline) !== JSON.stringify(currentPayload);
}, [baseline, currentPayload]);
useEffect(() => {
if (baseline == null && cfg && Object.keys(cfg).length > 0) {
setBaseline(JSON.parse(JSON.stringify(cfg)));
}
}, [cfg, baseline]);
useEffect(() => {
setConfigEditing(isDirty);
return () => setConfigEditing(false);
}, [isDirty, setConfigEditing]);
async function refreshMCPTools(cancelled = false) {
try {
const r = await fetch(`/webui/api/tools${q}`);
if (!r.ok) throw new Error('Failed to load tools');
const data = await r.json();
if (!cancelled) {
setMcpTools(Array.isArray(data?.mcp_tools) ? data.mcp_tools : []);
}
} catch {
if (!cancelled) setMcpTools([]);
}
}
useEffect(() => {
let cancelled = false;
void refreshMCPTools(cancelled);
return () => {
cancelled = true;
};
}, [q]);
function updateMCPServerField(name: string, field: string, value: any) {
setCfg((v) => setPath(v, `tools.mcp.servers.${name}.${field}`, value));
}
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',
command: '',
args: [],
env: {},
working_dir: '',
description: '',
package: '',
};
}
return next;
});
setNewMCPServerName('');
}
async function removeMCPServer(name: string) {
const ok = await ui.confirmDialog({
title: t('configDeleteMCPServerConfirmTitle'),
message: t('configDeleteMCPServerConfirmMessage', { name }),
danger: true,
confirmText: t('delete'),
});
if (!ok) return;
setCfg((v) => {
const next = JSON.parse(JSON.stringify(v || {}));
if (next?.tools?.mcp?.servers && typeof next.tools.mcp.servers === 'object') {
delete next.tools.mcp.servers[name];
}
return next;
});
}
function inferMCPPackage(server: any): string {
if (typeof server?.package === 'string' && server.package.trim()) return server.package.trim();
const command = String(server?.command || '').trim();
const args = Array.isArray(server?.args) ? server.args.map((x: any) => String(x).trim()).filter(Boolean) : [];
if (command === 'npx' || command.endsWith('/npx')) {
const pkg = args.find((arg: string) => !arg.startsWith('-'));
return pkg || '';
}
return '';
}
async function installMCPServerPackage(name: string, server: any) {
const defaultPkg = inferMCPPackage(server);
const pkg = await ui.promptDialog({
title: t('configMCPInstallTitle'),
message: t('configMCPInstallMessage', { name }),
inputPlaceholder: defaultPkg || t('configMCPInstallPlaceholder'),
initialValue: defaultPkg,
confirmText: t('install'),
});
const packageName = String(pkg || '').trim();
if (!packageName) return;
ui.showLoading(t('configMCPInstalling'));
try {
const r = await fetch(`/webui/api/mcp/install${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ package: packageName }),
});
const text = await r.text();
if (!r.ok) {
await ui.notify({ title: t('configMCPInstallFailedTitle'), message: text || t('configMCPInstallFailedMessage') });
return;
}
let data: any = null;
try {
data = JSON.parse(text);
} 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);
}
await ui.notify({
title: t('configMCPInstallDoneTitle'),
message: data?.bin_path
? t('configMCPInstallDoneMessage', { package: packageName, bin: data.bin_path })
: (text || t('configMCPInstallDoneFallback')),
});
} finally {
ui.hideLoading();
}
}
async function saveConfig() {
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);
}
if (!result.ok) {
throw new Error(result.data?.error || result.text || 'save failed');
}
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}` });
}
}
return (
<div className="p-4 md:p-8 w-full space-y-6">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('mcpServices')}</h1>
<p className="text-sm text-zinc-500 mt-1">{t('mcpServicesHint')}</p>
</div>
<button
onClick={async () => { setBaseline(null); await loadConfig(true); }}
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-xl text-sm font-medium transition-colors"
>
<RefreshCw className="w-4 h-4" /> {t('reload')}
</button>
</div>
<div className="flex items-center justify-end gap-3 flex-wrap">
<button
onClick={saveConfig}
disabled={!isDirty}
className="brand-button flex items-center gap-2 px-4 py-2 text-white rounded-xl text-sm font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" /> {t('saveChanges')}
</button>
</div>
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">{t('configMCPServers')}</div>
<div className="flex items-center gap-2">
<input value={newMCPServerName} onChange={(e)=>setNewMCPServerName(e.target.value)} placeholder={t('configNewMCPServerName')} className="px-2 py-1 rounded-lg bg-zinc-900/70 border border-zinc-700 text-xs" />
<button onClick={addMCPServer} className="brand-button px-2 py-1 rounded-lg text-xs text-white">{t('add')}</button>
</div>
</div>
<div className="space-y-2">
{Object.entries((((cfg as any)?.tools?.mcp?.servers) || {}) as Record<string, any>).map(([name, server]) => (
<div key={name} className="grid grid-cols-1 md:grid-cols-12 gap-2 rounded-xl border border-zinc-800 bg-zinc-900/30 p-2 text-xs">
<div className="md:col-span-2 font-mono text-zinc-300 flex items-center">{name}</div>
<label className="md:col-span-1 flex items-center gap-2 text-zinc-300">
<input type="checkbox" checked={!!server?.enabled} onChange={(e)=>updateMCPServerField(name, 'enabled', e.target.checked)} />
{t('enable')}
</label>
<input value={String(server?.command || '')} onChange={(e)=>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" />
<input value={String(server?.working_dir || '')} onChange={(e)=>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" />
<input value={Array.isArray(server?.args) ? server.args.join(',') : ''} onChange={(e)=>updateMCPServerField(name, 'args', e.target.value.split(',').map(s=>s.trim()).filter(Boolean))} placeholder={`${t('configLabels.args')}${t('configCommaSeparatedHint')}`} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" />
<input value={String(server?.package || '')} onChange={(e)=>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" />
<input value={String(server?.description || '')} onChange={(e)=>updateMCPServerField(name, 'description', e.target.value)} placeholder={t('configLabels.description')} className="md:col-span-1 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" />
<button onClick={()=>installMCPServerPackage(name, server)} className="md:col-span-1 px-2 py-1 rounded bg-emerald-900/60 hover:bg-emerald-800 text-emerald-100">{t('install')}</button>
<button onClick={()=>removeMCPServer(name)} className="md:col-span-1 px-2 py-1 rounded bg-red-900/60 hover:bg-red-800 text-red-100">{t('delete')}</button>
</div>
))}
{Object.keys((((cfg as any)?.tools?.mcp?.servers) || {}) as Record<string, any>).length === 0 && (
<div className="text-xs text-zinc-500">{t('configNoMCPServers')}</div>
)}
</div>
</div>
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">{t('configMCPDiscoveredTools')}</div>
<div className="text-xs text-zinc-500">{t('configMCPDiscoveredToolsCount', { count: mcpTools.length })}</div>
</div>
<div className="space-y-2">
{mcpTools.map((tool) => (
<div key={tool.name} className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="font-mono text-xs text-zinc-200">{tool.name}</div>
<div className="text-[11px] text-zinc-500">
{(tool.mcp?.server || '-')}{' · '}{(tool.mcp?.remote_tool || '-')}
</div>
</div>
{tool.description && (
<div className="mt-2 text-xs text-zinc-400">{tool.description}</div>
)}
</div>
))}
{mcpTools.length === 0 && (
<div className="text-xs text-zinc-500">{t('configNoMCPDiscoveredTools')}</div>
)}
</div>
</div>
</div>
);
};
export default MCP;