feat(mcp): 完整落地 MCP 服务管理

- Dockerfile 后端 stage 加 nodejs/npm,支持 stdio MCP server
- 新增前端 MCP 服务管理 UI(MCPSettings.tsx),支持 stdio/sse/http 三种 transport
- PluginLayout 改双 tab(技能 / MCP 服务)
- i18n 补 MCP 相关 zh/en 文案
- send_file 工具的 trace_id 改可选,聊天场景退化为返回内容
- system_workflow 工具集纳入 send_file

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 04:43:08 +00:00
parent b15eeb9e74
commit c0fcbe2849
7 changed files with 480 additions and 12 deletions
@@ -0,0 +1,378 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2, Server, Loader2, Plug, ChevronDown, ChevronRight } from 'lucide-react';
import apiClient from '../../api/client';
type Transport = 'stdio' | 'sse' | 'http';
interface MCPServer {
server_id: string;
name: string;
transport: Transport;
command?: string;
args?: string[];
url?: string;
tool_prefix?: string;
env?: Record<string, string>;
}
interface MCPToolInfo {
server_id: string;
name: string;
transport: string;
tools: string[];
error?: string;
}
export function MCPSettings() {
const { t } = useTranslation();
const [servers, setServers] = useState<MCPServer[]>([]);
const [toolsByServer, setToolsByServer] = useState<Record<string, MCPToolInfo>>({});
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<string | null>(null);
// Form state
const [name, setName] = useState('');
const [transport, setTransport] = useState<Transport>('stdio');
const [command, setCommand] = useState('');
const [argsText, setArgsText] = useState('');
const [url, setUrl] = useState('');
const [toolPrefix, setToolPrefix] = useState('');
const [envText, setEnvText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const fetchServers = async () => {
setLoading(true);
try {
const response = await apiClient.get('/api/v1/resource/mcp');
setServers(response.data.servers || []);
} catch (err) {
console.error('Failed to fetch MCP servers:', err);
} finally {
setLoading(false);
}
};
const fetchToolsForAll = async () => {
try {
const response = await apiClient.get('/api/v1/resource/tool');
const mcpTools: MCPToolInfo[] = response.data.mcp_servers || [];
const map: Record<string, MCPToolInfo> = {};
mcpTools.forEach((t) => {
if (t.server_id) map[t.server_id] = t;
});
setToolsByServer(map);
} catch (err) {
console.error('Failed to fetch MCP tools:', err);
}
};
useEffect(() => {
fetchServers();
fetchToolsForAll();
}, []);
const resetForm = () => {
setName('');
setCommand('');
setArgsText('');
setUrl('');
setToolPrefix('');
setEnvText('');
};
const parseEnv = (text: string): Record<string, string> => {
const env: Record<string, string> = {};
text.split('\n').forEach((line) => {
const trimmed = line.trim();
if (!trimmed) return;
const eq = trimmed.indexOf('=');
if (eq > 0) {
env[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
}
});
return env;
};
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!name) return;
setSubmitting(true);
setMessage('');
setError('');
const payload: any = { name, transport, tool_prefix: toolPrefix || null };
if (transport === 'stdio') {
payload.command = command;
payload.args = argsText
.split(',')
.map((s) => s.trim())
.filter(Boolean);
} else {
payload.url = url;
}
const env = parseEnv(envText);
if (Object.keys(env).length > 0) payload.env = env;
try {
await apiClient.post('/api/v1/resource/mcp', payload);
setMessage(t('plugin.mcpAddSuccess'));
resetForm();
await fetchServers();
await fetchToolsForAll();
} catch (err: any) {
setError(err.response?.data?.detail || t('plugin.mcpAddFailed'));
} finally {
setSubmitting(false);
}
};
const handleDelete = async (server: MCPServer) => {
if (!confirm(t('plugin.mcpDeleteConfirm', { name: server.name }))) return;
try {
await apiClient.delete(`/api/v1/resource/mcp/${server.server_id}`);
await fetchServers();
await fetchToolsForAll();
} catch {
alert(t('plugin.mcpDeleteFailed'));
}
};
return (
<div className="max-w-4xl space-y-6">
<div>
<div className="flex items-center gap-2 mb-1">
<Plug size={16} className="text-accent" />
<h1 className="text-lg font-bold text-text-primary">{t('plugin.mcpManagement')}</h1>
</div>
<p className="text-sm text-text-muted">{t('plugin.mcpDesc')}</p>
</div>
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-border-primary flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent-light flex items-center justify-center">
<Plus size={16} className="text-accent" />
</div>
<h2 className="text-sm font-bold text-text-primary">{t('plugin.mcpAdd')}</h2>
</div>
<div className="p-6">
<form onSubmit={handleAdd} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
{t('plugin.mcpName')}
</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('plugin.mcpNamePlaceholder')}
className="w-full px-3.5 py-2.5 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent placeholder:text-text-muted/50"
/>
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
{t('plugin.mcpTransport')}
</label>
<select
value={transport}
onChange={(e) => setTransport(e.target.value as Transport)}
className="w-full px-3.5 py-2.5 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent"
>
<option value="stdio">stdio</option>
<option value="sse">sse</option>
<option value="http">http</option>
</select>
</div>
</div>
{transport === 'stdio' ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
{t('plugin.mcpCommand')}
</label>
<input
type="text"
required
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder={t('plugin.mcpCommandPlaceholder')}
className="w-full px-3.5 py-2.5 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent placeholder:text-text-muted/50"
/>
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
{t('plugin.mcpArgs')}
</label>
<input
type="text"
value={argsText}
onChange={(e) => setArgsText(e.target.value)}
placeholder={t('plugin.mcpArgsPlaceholder')}
className="w-full px-3.5 py-2.5 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent placeholder:text-text-muted/50"
/>
</div>
</div>
) : (
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
{t('plugin.mcpUrl')}
</label>
<input
type="text"
required
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t('plugin.mcpUrlPlaceholder')}
className="w-full px-3.5 py-2.5 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent placeholder:text-text-muted/50"
/>
</div>
)}
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
{t('plugin.mcpToolPrefix')}
</label>
<input
type="text"
value={toolPrefix}
onChange={(e) => setToolPrefix(e.target.value)}
placeholder={t('plugin.mcpToolPrefixPlaceholder')}
className="w-full px-3.5 py-2.5 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent placeholder:text-text-muted/50"
/>
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
{t('plugin.mcpEnv')}
</label>
<textarea
value={envText}
onChange={(e) => setEnvText(e.target.value)}
placeholder={t('plugin.mcpEnvPlaceholder')}
rows={3}
className="w-full px-3.5 py-2.5 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent placeholder:text-text-muted/50 font-mono"
/>
</div>
{message && <div className="text-xs text-success">{message}</div>}
{error && <div className="text-xs text-danger">{error}</div>}
<div className="flex justify-end">
<button
type="submit"
disabled={submitting}
className="flex items-center gap-2 px-5 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm font-medium disabled:opacity-50"
>
<Plus size={14} />
{t('plugin.mcpAddBtn')}
</button>
</div>
</form>
</div>
</div>
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-border-primary">
<h2 className="text-sm font-bold text-text-primary">
{t('plugin.mcpRegistered', { count: servers.length })}
</h2>
</div>
<div className="p-6">
{loading ? (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<Loader2 size={24} className="animate-spin mb-3" />
<span className="text-sm">{t('common.loading')}</span>
</div>
) : servers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<Server size={32} className="mb-3 opacity-40" />
<span className="text-sm">{t('plugin.mcpNoServers')}</span>
</div>
) : (
<div className="space-y-3">
{servers.map((srv) => {
const isOpen = expanded === srv.server_id;
const toolInfo = toolsByServer[srv.server_id];
return (
<div
key={srv.server_id}
className="bg-bg-secondary border border-border-secondary rounded-xl overflow-hidden hover:border-accent/30 transition-all"
>
<div className="flex items-center justify-between p-3.5">
<button
onClick={() => setExpanded(isOpen ? null : srv.server_id)}
className="flex items-center gap-3 flex-1 text-left"
>
{isOpen ? (
<ChevronDown size={14} className="text-text-muted" />
) : (
<ChevronRight size={14} className="text-text-muted" />
)}
<div className="w-8 h-8 rounded-lg bg-bg-card border border-border-primary flex items-center justify-center">
<Server size={14} className="text-text-muted" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary truncate">
{srv.name}
</span>
<span className="text-[10px] px-1.5 py-0.5 bg-accent-light text-accent rounded font-medium uppercase">
{srv.transport}
</span>
{srv.tool_prefix && (
<span className="text-[10px] px-1.5 py-0.5 bg-bg-card border border-border-primary text-text-muted rounded font-mono">
{srv.tool_prefix}
</span>
)}
</div>
<div className="text-[11px] text-text-muted truncate font-mono mt-0.5">
{srv.transport === 'stdio'
? `${srv.command || ''} ${(srv.args || []).join(' ')}`
: srv.url}
</div>
</div>
</button>
<button
onClick={() => handleDelete(srv)}
className="p-1.5 text-text-muted hover:text-danger hover:bg-danger-bg rounded-lg transition-all"
>
<Trash2 size={14} />
</button>
</div>
{isOpen && (
<div className="px-3.5 pb-3.5 pl-12 border-t border-border-primary/50 pt-3">
<div className="text-[11px] font-semibold text-text-secondary uppercase tracking-wider mb-2">
{t('plugin.mcpTools')}
</div>
{toolInfo?.error ? (
<div className="text-xs text-danger">{toolInfo.error}</div>
) : toolInfo?.tools && toolInfo.tools.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{toolInfo.tools.map((tool) => (
<span
key={tool}
className="text-[11px] px-2 py-1 bg-bg-card border border-border-primary rounded font-mono text-text-secondary"
>
{tool}
</span>
))}
</div>
) : (
<div className="text-xs text-text-muted"></div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
@@ -1,16 +1,44 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Sparkles, Plug } from 'lucide-react';
import { SkillSettings } from './SkillSettings';
import { MCPSettings } from './MCPSettings';
type PluginTab = 'skill' | 'mcp';
export function PluginLayout() {
const { t } = useTranslation();
const [tab, setTab] = useState<PluginTab>('skill');
const tabs: { key: PluginTab; label: string; icon: typeof Sparkles }[] = [
{ key: 'skill', label: t('plugin.skillTab'), icon: Sparkles },
{ key: 'mcp', label: t('plugin.mcpTab'), icon: Plug },
];
return (
<div className="flex-1 flex flex-col bg-bg-secondary overflow-hidden">
<div className="h-12 border-b border-border-primary bg-bg-card/80 backdrop-blur flex items-center px-6 shrink-0">
<span className="text-xs font-semibold text-text-primary">{t('plugin.skillManagement')}</span>
<div className="h-12 border-b border-border-primary bg-bg-card/80 backdrop-blur flex items-center px-6 shrink-0 gap-1">
{tabs.map(({ key, label, icon: Icon }) => {
const active = tab === key;
return (
<button
key={key}
onClick={() => setTab(key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
active
? 'bg-accent-light text-accent'
: 'text-text-muted hover:text-text-primary hover:bg-bg-secondary'
}`}
>
<Icon size={13} />
{label}
</button>
);
})}
</div>
<div className="flex-1 overflow-y-auto p-8">
<SkillSettings />
{tab === 'skill' && <SkillSettings />}
{tab === 'mcp' && <MCPSettings />}
</div>
</div>
);
+28 -1
View File
@@ -252,7 +252,34 @@
"skillDeleteFailed": "Failed to delete skill",
"installedSkills": "Installed Skills ({{count}})",
"noSkills": "No skills installed yet.",
"install": "Install"
"install": "Install",
"skillTab": "Skills",
"mcpTab": "MCP Servers",
"mcpManagement": "MCP Server Management",
"mcpDesc": "Manage Model Context Protocol servers to extend agent tools",
"mcpAdd": "Add MCP Server",
"mcpName": "Name",
"mcpNamePlaceholder": "e.g. filesystem",
"mcpTransport": "Transport",
"mcpCommand": "Command",
"mcpCommandPlaceholder": "npx",
"mcpArgs": "Arguments",
"mcpArgsPlaceholder": "-y, @modelcontextprotocol/server-filesystem, /data",
"mcpUrl": "URL",
"mcpUrlPlaceholder": "https://example.com/mcp",
"mcpToolPrefix": "Tool Prefix (optional)",
"mcpToolPrefixPlaceholder": "fs",
"mcpEnv": "Environment Variables (optional, KEY=VALUE per line)",
"mcpEnvPlaceholder": "API_KEY=xxx\nDEBUG=true",
"mcpDelete": "Delete",
"mcpDeleteConfirm": "Delete MCP server {{name}}?",
"mcpNoServers": "No MCP servers registered yet.",
"mcpTools": "Exposed Tools",
"mcpAddSuccess": "Added successfully",
"mcpAddFailed": "Failed to add",
"mcpDeleteFailed": "Failed to delete",
"mcpRegistered": "Registered Servers ({{count}})",
"mcpAddBtn": "Add"
},
"topbar": {
"switchToEn": "Switch to English",
+28 -1
View File
@@ -252,7 +252,34 @@
"skillDeleteFailed": "删除技能失败",
"installedSkills": "已安装技能 ({{count}})",
"noSkills": "暂无已安装的技能",
"install": "安装"
"install": "安装",
"skillTab": "技能",
"mcpTab": "MCP 服务",
"mcpManagement": "MCP 服务管理",
"mcpDesc": "管理 Model Context Protocol 服务器,扩展 agent 工具能力",
"mcpAdd": "添加 MCP 服务",
"mcpName": "服务名称",
"mcpNamePlaceholder": "例如:filesystem",
"mcpTransport": "传输协议",
"mcpCommand": "启动命令",
"mcpCommandPlaceholder": "npx",
"mcpArgs": "命令参数",
"mcpArgsPlaceholder": "-y, @modelcontextprotocol/server-filesystem, /data",
"mcpUrl": "服务地址",
"mcpUrlPlaceholder": "https://example.com/mcp",
"mcpToolPrefix": "工具前缀(可选)",
"mcpToolPrefixPlaceholder": "fs",
"mcpEnv": "环境变量(可选,每行 KEY=VALUE",
"mcpEnvPlaceholder": "API_KEY=xxx\nDEBUG=true",
"mcpDelete": "删除",
"mcpDeleteConfirm": "确定要删除 MCP 服务 {{name}} 吗?",
"mcpNoServers": "暂无已注册的 MCP 服务",
"mcpTools": "暴露的工具",
"mcpAddSuccess": "添加成功",
"mcpAddFailed": "添加失败",
"mcpDeleteFailed": "删除失败",
"mcpRegistered": "已注册服务 ({{count}})",
"mcpAddBtn": "添加"
},
"topbar": {
"switchToEn": "Switch to English",