feat: expand mcp transports and skill execution

This commit is contained in:
lpf
2026-03-08 11:08:41 +08:00
parent db86b3471d
commit f043de5384
21 changed files with 1447 additions and 84 deletions

View File

@@ -312,6 +312,9 @@ const resources = {
configMCPInstallDoneTitle: 'MCP package installed',
configMCPInstallDoneMessage: 'Installed {{package}} and resolved binary {{bin}}.',
configMCPInstallDoneFallback: 'MCP package installed.',
configMCPArgsEnterHint: 'Type one argument and press Enter',
configMCPCommandMissing: 'MCP command is unavailable.',
configMCPInstallSuggested: 'Suggested package: {{pkg}}',
configMCPDiscoveredTools: 'Discovered MCP Tools',
configMCPDiscoveredToolsCount: '{{count}} discovered',
configNoMCPDiscoveredTools: 'No MCP tools discovered yet.',
@@ -495,6 +498,7 @@ const resources = {
dingtalk: 'DingTalk',
filesystem: 'Filesystem',
working_dir: 'Working Directory',
url: 'URL',
timeout: 'Timeout',
auto_install_missing: 'Auto-install Missing',
sandbox: 'Sandbox',
@@ -833,6 +837,9 @@ const resources = {
configMCPInstallDoneTitle: 'MCP 包安装完成',
configMCPInstallDoneMessage: '已安装 {{package}},并解析到可执行文件 {{bin}}。',
configMCPInstallDoneFallback: 'MCP 包已安装。',
configMCPArgsEnterHint: '输入一个参数后按回车添加',
configMCPCommandMissing: 'MCP 命令不可用。',
configMCPInstallSuggested: '建议安装包:{{pkg}}',
configMCPDiscoveredTools: '已发现的 MCP 工具',
configMCPDiscoveredToolsCount: '已发现 {{count}} 个',
configNoMCPDiscoveredTools: '暂未发现 MCP 工具。',
@@ -1016,6 +1023,7 @@ const resources = {
dingtalk: '钉钉',
filesystem: '文件系统',
working_dir: '工作目录',
url: '地址',
timeout: '超时',
auto_install_missing: '自动安装缺失依赖',
sandbox: '沙箱',

View File

@@ -23,6 +23,8 @@ const MCP: React.FC = () => {
const ui = useUI();
const [newMCPServerName, setNewMCPServerName] = useState('');
const [mcpTools, setMcpTools] = useState<Array<{ name: string; description?: string; mcp?: { server?: string; remote_tool?: string } }>>([]);
const [mcpServerChecks, setMcpServerChecks] = useState<Array<{ name: string; status?: string; message?: string; package?: string; installer?: string; installable?: boolean; resolved?: string }>>([]);
const [argInputs, setArgInputs] = useState<Record<string, string>>({});
const [baseline, setBaseline] = useState<any>(null);
const currentPayload = useMemo(() => cfg || {}, [cfg]);
@@ -49,9 +51,13 @@ const MCP: React.FC = () => {
const data = await r.json();
if (!cancelled) {
setMcpTools(Array.isArray(data?.mcp_tools) ? data.mcp_tools : []);
setMcpServerChecks(Array.isArray(data?.mcp_server_checks) ? data.mcp_server_checks : []);
}
} catch {
if (!cancelled) setMcpTools([]);
if (!cancelled) {
setMcpTools([]);
setMcpServerChecks([]);
}
}
}
@@ -67,6 +73,23 @@ const MCP: React.FC = () => {
setCfg((v) => setPath(v, `tools.mcp.servers.${name}.${field}`, value));
}
function addMCPArg(name: string, 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]: '' }));
}
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 addMCPServer() {
const name = newMCPServerName.trim();
if (!name) return;
@@ -83,9 +106,11 @@ const MCP: React.FC = () => {
next.tools.mcp.servers[name] = {
enabled: true,
transport: 'stdio',
url: '',
command: '',
args: [],
env: {},
permission: 'workspace',
working_dir: '',
description: '',
package: '',
@@ -94,6 +119,7 @@ const MCP: React.FC = () => {
return next;
});
setNewMCPServerName('');
setArgInputs((prev) => ({ ...prev, [name]: '' }));
}
async function removeMCPServer(name: string) {
@@ -111,21 +137,32 @@ const MCP: React.FC = () => {
}
return next;
});
setArgInputs((prev) => {
const next = { ...prev };
delete next[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 || '';
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() };
}
return '';
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 defaultPkg = inferMCPPackage(server);
const inferred = inferMCPInstallSpec(server);
const defaultPkg = inferred.packageName;
const pkg = await ui.promptDialog({
title: t('configMCPInstallTitle'),
message: t('configMCPInstallMessage', { name }),
@@ -141,7 +178,7 @@ const MCP: React.FC = () => {
const r = await fetch(`/webui/api/mcp/install${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ package: packageName }),
body: JSON.stringify({ package: packageName, installer: inferred.installer }),
});
const text = await r.text();
if (!r.ok) {
@@ -172,6 +209,14 @@ 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() {
try {
const payload = cfg;
@@ -255,22 +300,86 @@ const MCP: React.FC = () => {
</div>
</div>
<div className="space-y-2">
{Object.entries((((cfg as any)?.tools?.mcp?.servers) || {}) as Record<string, any>).map(([name, server]) => (
{Object.entries((((cfg as any)?.tools?.mcp?.servers) || {}) as Record<string, any>).map(([name, server]) => {
const transport = String(server?.transport || 'stdio');
const isStdio = transport === 'stdio';
const usesURL = transport === 'http' || transport === 'streamable_http' || transport === 'sse';
return (
<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>
<select value={transport} onChange={(e)=>updateMCPServerField(name, 'transport', e.target.value)} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800">
<option value="stdio">stdio</option>
<option value="http">http</option>
<option value="streamable_http">streamable_http</option>
<option value="sse">sse</option>
</select>
{isStdio && (
<>
<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" />
<select value={String(server?.permission || 'workspace')} onChange={(e)=>updateMCPServerField(name, 'permission', e.target.value)} className="md:col-span-1 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800">
<option value="workspace">workspace</option>
<option value="full">full</option>
</select>
<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" />
<div className="md:col-span-2 rounded-lg bg-zinc-950/70 border border-zinc-800 p-2 space-y-2">
<div className="flex flex-wrap gap-2">
{(Array.isArray(server?.args) ? server.args : []).map((arg: any, index: number) => (
<span key={`${name}-arg-${index}`} className="inline-flex items-center gap-2 rounded-md bg-zinc-800 px-2 py-1 text-[11px] text-zinc-200">
<span className="font-mono">{String(arg)}</span>
<button type="button" onClick={() => removeMCPArg(name, index)} className="text-zinc-400 hover:text-zinc-100">x</button>
</span>
))}
</div>
<input
value={argInputs[name] || ''}
onChange={(e) => 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"
/>
</div>
<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" />
</>
)}
{usesURL && (
<input value={String(server?.url || '')} onChange={(e)=>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" />
)}
<input value={String(server?.description || '')} onChange={(e)=>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 && (
<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>
{(() => {
const check = mcpServerChecks.find((item) => item.name === name);
if (!check || check.status === 'ok' || check.status === 'disabled' || check.status === 'not_applicable') return null;
return (
<div className="md:col-span-12 rounded-lg border border-amber-800/60 bg-amber-950/30 px-3 py-2 text-xs text-amber-100 flex items-center justify-between gap-3 flex-wrap">
<div>
<div>{check.message || t('configMCPCommandMissing')}</div>
{check.package && (
<div className="text-amber-300/80">{t('configMCPInstallSuggested', { pkg: check.package })} {check.installer ? `(${check.installer})` : ''}</div>
)}
</div>
{check.installable && (
<button onClick={() => installMCPServerCheckPackage(check)} className="px-2 py-1 rounded bg-amber-700 hover:bg-amber-600 text-white">
{t('install')}
</button>
)}
</div>
);
})()}
</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>
)}

View File

@@ -390,6 +390,9 @@ const SubagentProfiles: React.FC = () => {
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded"
placeholder="read_file, list_files, memory_search"
/>
<div className="mt-1 text-[11px] text-zinc-500">
<span className="font-mono text-zinc-400">skill_exec</span> is inherited automatically and does not need to be listed here.
</div>
{groups.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{groups.map((g) => (

View File

@@ -96,6 +96,13 @@ type RegistrySubagent = {
prompt_file_found?: boolean;
memory_namespace?: string;
tool_allowlist?: string[];
inherited_tools?: string[];
effective_tools?: string[];
tool_visibility?: {
mode?: string;
inherited_tool_count?: number;
effective_tool_count?: number;
};
routing_keywords?: string[];
};
@@ -539,6 +546,7 @@ const Subagents: React.FC = () => {
const localMainStats = taskStats[normalizeTitle(localRoot.agent_id, 'main')] || { total: 0, running: 0, failed: 0, waiting: 0, latestStatus: '', latestUpdated: 0, active: [] };
const localMainTask = recentTaskByAgent[normalizeTitle(localRoot.agent_id, 'main')];
const localMainRegistry = registryItems.find((item) => item.agent_id === localRoot.agent_id);
localBranchStats.running += localMainStats.running;
localBranchStats.failed += localMainStats.failed;
const localMainCard: GraphCardSpec = {
@@ -557,8 +565,10 @@ const Subagents: React.FC = () => {
`children=${localChildren.length + remoteClusters.length}`,
`total=${localMainStats.total} running=${localMainStats.running}`,
`waiting=${localMainStats.waiting} failed=${localMainStats.failed}`,
`notify=${normalizeTitle(registryItems.find((item) => item.agent_id === localRoot.agent_id)?.notify_main_policy, 'final_only')}`,
`notify=${normalizeTitle(localMainRegistry?.notify_main_policy, 'final_only')}`,
`transport=${normalizeTitle(localRoot.transport, 'local')} type=${normalizeTitle(localRoot.type, 'router')}`,
`tools=${normalizeTitle(localMainRegistry?.tool_visibility?.mode, 'allowlist')} visible=${localMainRegistry?.tool_visibility?.effective_tool_count ?? 0} inherited=${localMainRegistry?.tool_visibility?.inherited_tool_count ?? 0}`,
(localMainRegistry?.inherited_tools || []).length ? `inherits: ${(localMainRegistry?.inherited_tools || []).join(', ')}` : 'inherits: -',
localMainStats.active[0] ? `task: ${localMainStats.active[0].title}` : t('noLiveTasks'),
],
accent: localMainStats.running > 0 ? 'bg-emerald-500' : localMainStats.latestStatus === 'failed' ? 'bg-red-500' : 'bg-amber-400',
@@ -575,6 +585,7 @@ const Subagents: React.FC = () => {
const childY = childStartY;
const stats = taskStats[normalizeTitle(child.agent_id, '')] || { total: 0, running: 0, failed: 0, waiting: 0, latestStatus: '', latestUpdated: 0, active: [] };
const task = recentTaskByAgent[normalizeTitle(child.agent_id, '')];
const childRegistry = registryItems.find((item) => item.agent_id === child.agent_id);
localBranchStats.running += stats.running;
localBranchStats.failed += stats.failed;
cards.push({
@@ -592,8 +603,10 @@ const Subagents: React.FC = () => {
meta: [
`total=${stats.total} running=${stats.running}`,
`waiting=${stats.waiting} failed=${stats.failed}`,
`notify=${normalizeTitle(registryItems.find((item) => item.agent_id === child.agent_id)?.notify_main_policy, 'final_only')}`,
`notify=${normalizeTitle(childRegistry?.notify_main_policy, 'final_only')}`,
`transport=${normalizeTitle(child.transport, 'local')} type=${normalizeTitle(child.type, 'worker')}`,
`tools=${normalizeTitle(childRegistry?.tool_visibility?.mode, 'allowlist')} visible=${childRegistry?.tool_visibility?.effective_tool_count ?? 0} inherited=${childRegistry?.tool_visibility?.inherited_tool_count ?? 0}`,
(childRegistry?.inherited_tools || []).length ? `inherits: ${(childRegistry?.inherited_tools || []).join(', ')}` : 'inherits: -',
stats.active[0] ? `task: ${stats.active[0].title}` : task ? `last: ${summarizeTask(task.task, task.label)}` : t('noLiveTasks'),
],
accent: stats.running > 0 ? 'bg-emerald-500' : stats.latestStatus === 'failed' ? 'bg-red-500' : 'bg-sky-400',