feat(mcp): 添加 MCP 服务器管理功能

- 新增 Server MCP API 路由 (/api/mcp/*)
  - GET /servers - 获取所有服务器状态
  - POST /servers/:name/connect|disconnect|enable|disable
  - GET /tools - 获取所有 MCP 工具
  - GET /config - 获取 MCP 配置

- 新增 UI MCPPanel 组件
  - 显示服务器列表和状态指示灯
  - 支持连接/断开/启用/禁用操作
  - 展开查看服务器配置和工具列表
  - 响应式设计支持移动端

- 集成到 Web 和 Desktop
  - 添加 Plug 图标按钮到工具栏
  - 点击打开 MCP 管理面板
This commit is contained in:
2025-12-12 20:41:49 +08:00
parent 5a482f78ff
commit bad7bfcc36
11 changed files with 1225 additions and 5 deletions
+509
View File
@@ -0,0 +1,509 @@
/**
* MCPPanel Component
*
* MCP 服务器管理面板:列出服务器状态、连接/断开、启用/禁用
*/
import { useState, useEffect, useCallback } from 'react';
import {
X,
RefreshCw,
Plug,
PlugZap,
Power,
PowerOff,
ChevronDown,
ChevronRight,
Server,
Globe,
Wrench,
AlertCircle,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn';
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
import { Button } from '../primitives/Button';
import { Skeleton } from './Skeleton';
import {
listMCPServers,
connectMCPServer,
disconnectMCPServer,
enableMCPServer,
disableMCPServer,
type MCPServerStatus,
} from '../api/client.js';
interface MCPPanelProps {
onClose: () => void;
/** 是否启用响应式布局 */
responsive?: boolean;
}
// 状态颜色映射
function getStatusColor(status: MCPServerStatus['status']) {
switch (status) {
case 'connected':
return 'bg-green-500';
case 'connecting':
return 'bg-yellow-500 animate-pulse';
case 'disconnected':
return 'bg-gray-500';
case 'disabled':
return 'bg-gray-600';
case 'error':
return 'bg-red-500';
default:
return 'bg-gray-500';
}
}
// 状态文字
function getStatusText(status: MCPServerStatus['status']) {
switch (status) {
case 'connected':
return 'Connected';
case 'connecting':
return 'Connecting...';
case 'disconnected':
return 'Disconnected';
case 'disabled':
return 'Disabled';
case 'error':
return 'Error';
default:
return status;
}
}
// 类型图标
function getTypeIcon(type: 'local' | 'remote') {
return type === 'local' ? (
<Server size={14} className="text-blue-400" />
) : (
<Globe size={14} className="text-purple-400" />
);
}
export function MCPPanel({ onClose, responsive = false }: MCPPanelProps) {
// 数据状态
const [servers, setServers] = useState<MCPServerStatus[]>([]);
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
// UI 状态
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
// 加载服务器列表
const loadServers = useCallback(async (showToast = false) => {
try {
const result = await listMCPServers();
if (result.success) {
setServers(result.data);
if (showToast) {
toast.success('Servers refreshed');
}
} else {
toast.error(result.error || 'Failed to load servers');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load servers');
}
}, []);
// 初始加载
useEffect(() => {
setLoading(true);
loadServers().finally(() => setLoading(false));
}, [loadServers]);
// 刷新
const handleRefresh = async () => {
setRefreshing(true);
await loadServers(true);
setRefreshing(false);
};
// 连接服务器
const handleConnect = async (name: string) => {
setActionLoading(name);
try {
const result = await connectMCPServer(name);
if (result.success) {
toast.success(`Server "${name}" connected`);
await loadServers();
} else {
toast.error(result.error || 'Connection failed');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Connection failed');
} finally {
setActionLoading(null);
}
};
// 断开服务器
const handleDisconnect = async (name: string) => {
setActionLoading(name);
try {
const result = await disconnectMCPServer(name);
if (result.success) {
toast.success(`Server "${name}" disconnected`);
await loadServers();
} else {
toast.error(result.error || 'Disconnect failed');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Disconnect failed');
} finally {
setActionLoading(null);
}
};
// 启用服务器
const handleEnable = async (name: string) => {
setActionLoading(name);
try {
const result = await enableMCPServer(name);
if (result.success) {
toast.success(`Server "${name}" enabled`);
await loadServers();
} else {
toast.error(result.error || 'Enable failed');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Enable failed');
} finally {
setActionLoading(null);
}
};
// 禁用服务器
const handleDisable = async (name: string) => {
setActionLoading(name);
try {
const result = await disableMCPServer(name);
if (result.success) {
toast.success(`Server "${name}" disabled`);
await loadServers();
} else {
toast.error(result.error || 'Disable failed');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Disable failed');
} finally {
setActionLoading(null);
}
};
// 切换展开
const toggleExpanded = (name: string) => {
const newExpanded = new Set(expandedServers);
if (newExpanded.has(name)) {
newExpanded.delete(name);
} else {
newExpanded.add(name);
}
setExpandedServers(newExpanded);
};
// 统计
const connectedCount = servers.filter((s) => s.status === 'connected').length;
const totalToolCount = servers.reduce((sum, s) => sum + s.toolCount, 0);
// Loading 骨架屏
const LoadingSkeleton = () => (
<div className="space-y-3 p-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 p-3 bg-gray-900/50 rounded-lg">
<Skeleton className="h-3 w-3 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-8 w-20" />
</div>
))}
</div>
);
return (
<AnimatePresence>
<motion.div
variants={modalOverlay}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
className={cn(
'fixed inset-0 bg-black/50 flex z-50',
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
)}
onClick={onClose}
>
<motion.div
variants={modalContent}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={(e) => e.stopPropagation()}
className={cn(
'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col',
responsive
? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg'
: 'rounded-lg w-full max-w-2xl mx-4'
)}
>
{/* Header */}
<div
className={cn(
'flex items-center justify-between border-b border-gray-700',
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
)}
>
{responsive && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
)}
<div className={cn(responsive && 'mt-2 md:mt-0')}>
<h2 className="text-lg font-semibold flex items-center gap-2">
<Plug size={20} className="text-primary-400" />
MCP Servers
</h2>
<p className="text-xs text-gray-500">
{servers.length} servers ({connectedCount} connected, {totalToolCount} tools)
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={refreshing}
title="Refresh"
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<X size={20} />
</Button>
</div>
</div>
{/* Server List */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<LoadingSkeleton />
) : servers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<Plug size={48} className="mb-4 opacity-50" />
<p className="text-center">No MCP servers configured</p>
<p className="text-xs text-gray-600 mt-2 text-center max-w-xs">
Configure MCP servers in your .ai-assist/config.json file
</p>
</div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn('space-y-2', responsive ? 'p-4' : 'p-4')}
>
{servers.map((server) => {
const isExpanded = expandedServers.has(server.name);
const isLoading = actionLoading === server.name;
return (
<motion.div
key={server.name}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-gray-900/50 rounded-lg overflow-hidden"
>
{/* Server Header */}
<div
className={cn(
'flex items-center gap-3 p-3',
'hover:bg-gray-900/80 transition-colors cursor-pointer'
)}
onClick={() => toggleExpanded(server.name)}
>
{/* Expand Icon */}
<button className="text-gray-500 hover:text-gray-300">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
{/* Status Indicator */}
<div className={cn('w-2.5 h-2.5 rounded-full', getStatusColor(server.status))} />
{/* Type Icon */}
{getTypeIcon(server.type)}
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-200">{server.name}</span>
<span className="text-xs text-gray-500">{server.type}</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>{getStatusText(server.status)}</span>
{server.toolCount > 0 && (
<>
<span>·</span>
<span className="flex items-center gap-1">
<Wrench size={10} />
{server.toolCount} tools
</span>
</>
)}
</div>
</div>
{/* Actions */}
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
{isLoading ? (
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-primary-500" />
) : server.status === 'disabled' ? (
<Button
variant="ghost"
size="sm"
onClick={() => handleEnable(server.name)}
className="text-green-400 hover:text-green-300"
>
<Power size={14} className="mr-1" />
Enable
</Button>
) : server.status === 'connected' ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => handleDisconnect(server.name)}
className="text-yellow-400 hover:text-yellow-300"
>
<PlugZap size={14} className="mr-1" />
Disconnect
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDisable(server.name)}
className="text-red-400 hover:text-red-300"
>
<PowerOff size={14} className="mr-1" />
Disable
</Button>
</>
) : server.status === 'disconnected' || server.status === 'error' ? (
<Button
variant="ghost"
size="sm"
onClick={() => handleConnect(server.name)}
className="text-blue-400 hover:text-blue-300"
>
<PlugZap size={14} className="mr-1" />
Connect
</Button>
) : null}
</div>
</div>
{/* Expanded Content */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-4 pb-3 pt-1 border-t border-gray-700/50">
{/* Error Message */}
{server.error && (
<div className="flex items-start gap-2 p-2 bg-red-500/10 rounded text-red-400 text-xs mb-2">
<AlertCircle size={14} className="mt-0.5 flex-shrink-0" />
<span>{server.error}</span>
</div>
)}
{/* Config Info */}
{server.config && (
<div className="space-y-1 text-xs text-gray-500">
{server.config.command && (
<div>
<span className="text-gray-400">Command:</span>{' '}
<code className="font-mono bg-gray-800 px-1 rounded">
{server.config.command.join(' ')}
</code>
</div>
)}
{server.config.url && (
<div>
<span className="text-gray-400">URL:</span>{' '}
<code className="font-mono bg-gray-800 px-1 rounded">
{server.config.url}
</code>
</div>
)}
{server.config.timeout && (
<div>
<span className="text-gray-400">Timeout:</span>{' '}
{server.config.timeout}ms
</div>
)}
</div>
)}
{/* Tools List */}
{server.tools && server.tools.length > 0 && (
<div className="mt-3">
<div className="text-xs text-gray-400 mb-1">Tools:</div>
<div className="flex flex-wrap gap-1">
{server.tools.map((tool) => (
<span
key={tool.name}
className="px-2 py-0.5 bg-gray-800 rounded text-xs text-gray-300"
title={tool.description}
>
{tool.originalName}
</span>
))}
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</motion.div>
)}
</div>
{/* Footer Info */}
<div
className={cn(
'border-t border-gray-700 text-xs text-gray-500 text-center',
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
)}
>
Configure servers in <code className="font-mono bg-gray-900 px-1 rounded">.ai-assist/config.json</code>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}