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
+6
View File
@@ -8,6 +8,7 @@ import {
FileBrowser,
ConfigPanel,
CommandPanel,
MCPPanel,
Toaster,
listSessions,
createSession,
@@ -21,6 +22,7 @@ export function App() {
const [showFileBrowser, setShowFileBrowser] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [showCommands, setShowCommands] = useState(false);
const [showMCP, setShowMCP] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// 初始化:加载或创建会话
@@ -92,6 +94,7 @@ export function App() {
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
onOpenConfig={() => setShowConfig(true)}
onOpenCommands={() => setShowCommands(true)}
onOpenMCP={() => setShowMCP(true)}
/>
) : (
<div className="flex-1 flex items-center justify-center h-full">
@@ -118,6 +121,9 @@ export function App() {
{/* 命令面板 */}
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} />}
{/* MCP 面板 */}
{showMCP && <MCPPanel onClose={() => setShowMCP(false)} />}
{/* Toast 通知 */}
<Toaster />
</div>
+17 -2
View File
@@ -3,7 +3,7 @@
*/
import { useEffect, useRef } from 'react';
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal } from 'lucide-react';
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
useChat,
@@ -21,6 +21,7 @@ interface ChatPageProps {
onToggleFileBrowser?: () => void;
onOpenConfig?: () => void;
onOpenCommands?: () => void;
onOpenMCP?: () => void;
}
export function ChatPage({
@@ -30,6 +31,7 @@ export function ChatPage({
onToggleFileBrowser,
onOpenConfig,
onOpenCommands,
onOpenMCP,
}: ChatPageProps) {
const {
messages,
@@ -123,8 +125,21 @@ export function ChatPage({
<ConnectionStatus />
{/* 工具栏按钮 */}
{(onOpenConfig || onToggleFileBrowser || onOpenCommands) && (
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP) && (
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
{/* MCP 按钮 */}
{onOpenMCP && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenMCP}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="MCP Servers"
>
<Plug size={20} />
</motion.button>
)}
{/* 命令按钮 */}
{onOpenCommands && (
<motion.button
+2 -1
View File
@@ -9,7 +9,7 @@ import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { createBunWebSocket } from 'hono/bun';
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter } from './routes/index.js';
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter } from './routes/index.js';
import {
handleWebSocket,
handleWebSocketMessage,
@@ -83,6 +83,7 @@ api.route('/tools', toolsRouter);
api.route('/config', configRouter);
api.route('/files', filesRouter);
api.route('/commands', commandsRouter);
api.route('/mcp', mcpRouter);
// SSE 事件流
api.get('/sessions/:id/events', handleSSE);
+1
View File
@@ -9,3 +9,4 @@ export { toolsRouter, registerTool, getRegisteredTools } from './tools.js';
export { configRouter, getConfig, setConfig } from './config.js';
export { filesRouter, setWorkingDirectory, getWorkingDirectory } from './files.js';
export { commandsRouter } from './commands.js';
export { mcpRouter } from './mcp.js';
+476
View File
@@ -0,0 +1,476 @@
/**
* MCP API Routes
*
* 提供 MCP 服务器管理的 REST API
*/
import { Hono } from 'hono';
import { getConfig } from './config.js';
// Core MCP 模块类型
interface MCPModule {
getMCPManager: () => MCPManager;
loadMCPConfig: (workdir: string) => Promise<MCPConfig>;
}
interface MCPManager {
initialize(config: MCPConfig): Promise<void>;
shutdown(): Promise<void>;
reconnect(serverName: string): Promise<void>;
setServerEnabled(serverName: string, enabled: boolean): Promise<void>;
getServerStatuses(): MCPServerStatus[];
getServerStatus(name: string): MCPServerStatus | undefined;
getTools(): MCPTool[];
getTool(name: string): MCPTool | undefined;
isInitialized(): boolean;
}
interface MCPConfig {
mcp?: Record<string, MCPServerConfig>;
tools?: Record<string, boolean>;
}
interface MCPServerConfig {
type: 'local' | 'remote';
command?: string[];
url?: string;
env?: Record<string, string>;
cwd?: string;
enabled?: boolean;
timeout?: number;
}
interface MCPServerStatus {
name: string;
type: 'local' | 'remote';
status: 'connected' | 'connecting' | 'disconnected' | 'disabled' | 'error';
toolCount: number;
error?: string;
lastConnected?: Date;
}
interface MCPTool {
server: string;
name: string;
originalName: string;
description: string;
inputSchema: Record<string, unknown>;
}
export const mcpRouter = new Hono();
// Core 模块缓存
let mcpModule: MCPModule | null = null;
let currentConfig: MCPConfig | null = null;
/**
* 初始化 MCP 模块
*/
async function initMCPModule(): Promise<MCPModule | null> {
if (mcpModule) return mcpModule;
try {
const corePath = '@ai-assistant/core';
const core = (await import(corePath)) as Record<string, unknown>;
if (
typeof core.getMCPManager !== 'function' ||
typeof core.loadMCPConfig !== 'function'
) {
console.warn('[MCP] Core module missing MCP exports');
return null;
}
mcpModule = {
getMCPManager: core.getMCPManager as () => MCPManager,
loadMCPConfig: core.loadMCPConfig as (workdir: string) => Promise<MCPConfig>,
};
// 初始化 MCP Manager
const config = getConfig();
currentConfig = await mcpModule.loadMCPConfig(config.workdir);
const manager = mcpModule.getMCPManager();
if (!manager.isInitialized() && currentConfig.mcp) {
await manager.initialize(currentConfig);
}
console.log('[MCP] MCP module initialized');
return mcpModule;
} catch (error) {
console.warn('[MCP] Failed to load MCP module:', error);
return null;
}
}
/**
* GET /mcp/servers - 获取所有服务器状态
*/
mcpRouter.get('/servers', async (c) => {
const module = await initMCPModule();
if (!module) {
return c.json(
{
success: false,
error: 'MCP module not available',
},
503
);
}
const manager = module.getMCPManager();
const statuses = manager.getServerStatuses();
// 添加配置信息
const serversWithConfig = statuses.map((status) => {
const serverConfig = currentConfig?.mcp?.[status.name];
return {
...status,
config: serverConfig
? {
type: serverConfig.type,
command: serverConfig.type === 'local' ? serverConfig.command : undefined,
url: serverConfig.type === 'remote' ? serverConfig.url : undefined,
timeout: serverConfig.timeout,
}
: undefined,
};
});
return c.json({
success: true,
data: serversWithConfig,
});
});
/**
* GET /mcp/servers/:name - 获取单个服务器详情
*/
mcpRouter.get('/servers/:name', async (c) => {
const name = c.req.param('name');
const module = await initMCPModule();
if (!module) {
return c.json(
{
success: false,
error: 'MCP module not available',
},
503
);
}
const manager = module.getMCPManager();
const status = manager.getServerStatus(name);
if (!status) {
return c.json(
{
success: false,
error: `Server not found: ${name}`,
},
404
);
}
const serverConfig = currentConfig?.mcp?.[name];
const tools = manager
.getTools()
.filter((tool) => tool.server === name);
return c.json({
success: true,
data: {
...status,
config: serverConfig
? {
type: serverConfig.type,
command: serverConfig.type === 'local' ? serverConfig.command : undefined,
url: serverConfig.type === 'remote' ? serverConfig.url : undefined,
timeout: serverConfig.timeout,
cwd: serverConfig.type === 'local' ? serverConfig.cwd : undefined,
}
: undefined,
tools: tools.map((t) => ({
name: t.name,
originalName: t.originalName,
description: t.description,
})),
},
});
});
/**
* POST /mcp/servers/:name/connect - 连接服务器
*/
mcpRouter.post('/servers/:name/connect', async (c) => {
const name = c.req.param('name');
const module = await initMCPModule();
if (!module) {
return c.json(
{
success: false,
error: 'MCP module not available',
},
503
);
}
const manager = module.getMCPManager();
try {
await manager.reconnect(name);
return c.json({
success: true,
data: {
message: `Server ${name} connected`,
status: manager.getServerStatus(name),
},
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Connection failed',
},
500
);
}
});
/**
* POST /mcp/servers/:name/disconnect - 断开服务器
*/
mcpRouter.post('/servers/:name/disconnect', async (c) => {
const name = c.req.param('name');
const module = await initMCPModule();
if (!module) {
return c.json(
{
success: false,
error: 'MCP module not available',
},
503
);
}
const manager = module.getMCPManager();
try {
// 通过禁用来断开连接
await manager.setServerEnabled(name, false);
return c.json({
success: true,
data: {
message: `Server ${name} disconnected`,
status: manager.getServerStatus(name),
},
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Disconnect failed',
},
500
);
}
});
/**
* POST /mcp/servers/:name/enable - 启用服务器
*/
mcpRouter.post('/servers/:name/enable', async (c) => {
const name = c.req.param('name');
const module = await initMCPModule();
if (!module) {
return c.json(
{
success: false,
error: 'MCP module not available',
},
503
);
}
const manager = module.getMCPManager();
try {
await manager.setServerEnabled(name, true);
return c.json({
success: true,
data: {
message: `Server ${name} enabled`,
status: manager.getServerStatus(name),
},
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Enable failed',
},
500
);
}
});
/**
* POST /mcp/servers/:name/disable - 禁用服务器
*/
mcpRouter.post('/servers/:name/disable', async (c) => {
const name = c.req.param('name');
const module = await initMCPModule();
if (!module) {
return c.json(
{
success: false,
error: 'MCP module not available',
},
503
);
}
const manager = module.getMCPManager();
try {
await manager.setServerEnabled(name, false);
return c.json({
success: true,
data: {
message: `Server ${name} disabled`,
status: manager.getServerStatus(name),
},
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Disable failed',
},
500
);
}
});
/**
* GET /mcp/tools - 获取所有 MCP 工具
*/
mcpRouter.get('/tools', async (c) => {
const module = await initMCPModule();
if (!module) {
return c.json(
{
success: false,
error: 'MCP module not available',
},
503
);
}
const manager = module.getMCPManager();
const tools = manager.getTools();
return c.json({
success: true,
data: tools.map((tool) => ({
name: tool.name,
server: tool.server,
originalName: tool.originalName,
description: tool.description,
inputSchema: tool.inputSchema,
})),
});
});
/**
* GET /mcp/tools/:name - 获取单个工具详情
*/
mcpRouter.get('/tools/:name', async (c) => {
const name = c.req.param('name');
const module = await initMCPModule();
if (!module) {
return c.json(
{
success: false,
error: 'MCP module not available',
},
503
);
}
const manager = module.getMCPManager();
const tool = manager.getTool(name);
if (!tool) {
return c.json(
{
success: false,
error: `Tool not found: ${name}`,
},
404
);
}
return c.json({
success: true,
data: {
name: tool.name,
server: tool.server,
originalName: tool.originalName,
description: tool.description,
inputSchema: tool.inputSchema,
},
});
});
/**
* GET /mcp/config - 获取 MCP 配置
*/
mcpRouter.get('/config', async (c) => {
const module = await initMCPModule();
if (!module) {
return c.json(
{
success: false,
error: 'MCP module not available',
},
503
);
}
// 返回配置(隐藏敏感信息)
const safeConfig: MCPConfig = {
mcp: {},
tools: currentConfig?.tools,
};
if (currentConfig?.mcp) {
for (const [name, config] of Object.entries(currentConfig.mcp)) {
safeConfig.mcp![name] = {
type: config.type,
command: config.type === 'local' ? config.command : undefined,
url: config.type === 'remote' ? config.url : undefined,
enabled: config.enabled,
timeout: config.timeout,
};
}
}
return c.json({
success: true,
data: safeConfig,
});
});
+109
View File
@@ -17,6 +17,9 @@ import type {
CreateCommandInput,
UpdateCommandInput,
CommandContent,
MCPServerStatus,
MCPToolInfo,
MCPConfig,
} from './types.js';
// Re-export types
@@ -37,6 +40,11 @@ export type {
CreateCommandInput,
UpdateCommandInput,
CommandContent,
MCPServerStatus,
MCPServerStatusType,
MCPToolInfo,
MCPConfig,
MCPServerConfigInfo,
} from './types.js';
// API Configuration
@@ -235,3 +243,104 @@ export async function deleteCommand(
): Promise<{ success: boolean; data?: { name: string; path: string }; error?: string }> {
return request('DELETE', `/commands/${encodeURIComponent(name)}`);
}
// ============ MCP API ============
/**
* 获取所有 MCP 服务器状态
*/
export async function listMCPServers(): Promise<{
success: boolean;
data: MCPServerStatus[];
error?: string;
}> {
return request('GET', '/mcp/servers');
}
/**
* 获取单个 MCP 服务器详情
*/
export async function getMCPServer(name: string): Promise<{
success: boolean;
data?: MCPServerStatus;
error?: string;
}> {
return request('GET', `/mcp/servers/${encodeURIComponent(name)}`);
}
/**
* 连接 MCP 服务器
*/
export async function connectMCPServer(name: string): Promise<{
success: boolean;
data?: { message: string; status: MCPServerStatus };
error?: string;
}> {
return request('POST', `/mcp/servers/${encodeURIComponent(name)}/connect`);
}
/**
* 断开 MCP 服务器
*/
export async function disconnectMCPServer(name: string): Promise<{
success: boolean;
data?: { message: string; status: MCPServerStatus };
error?: string;
}> {
return request('POST', `/mcp/servers/${encodeURIComponent(name)}/disconnect`);
}
/**
* 启用 MCP 服务器
*/
export async function enableMCPServer(name: string): Promise<{
success: boolean;
data?: { message: string; status: MCPServerStatus };
error?: string;
}> {
return request('POST', `/mcp/servers/${encodeURIComponent(name)}/enable`);
}
/**
* 禁用 MCP 服务器
*/
export async function disableMCPServer(name: string): Promise<{
success: boolean;
data?: { message: string; status: MCPServerStatus };
error?: string;
}> {
return request('POST', `/mcp/servers/${encodeURIComponent(name)}/disable`);
}
/**
* 获取所有 MCP 工具
*/
export async function listMCPTools(): Promise<{
success: boolean;
data: MCPToolInfo[];
error?: string;
}> {
return request('GET', '/mcp/tools');
}
/**
* 获取单个 MCP 工具详情
*/
export async function getMCPTool(name: string): Promise<{
success: boolean;
data?: MCPToolInfo;
error?: string;
}> {
return request('GET', `/mcp/tools/${encodeURIComponent(name)}`);
}
/**
* 获取 MCP 配置
*/
export async function getMCPConfig(): Promise<{
success: boolean;
data?: MCPConfig;
error?: string;
}> {
return request('GET', '/mcp/config');
}
+65
View File
@@ -171,3 +171,68 @@ export interface CommandContent {
source: string;
sourcePath?: string;
}
// ============ MCP 相关 ============
/** MCP 服务器状态类型 */
export type MCPServerStatusType =
| 'connected'
| 'connecting'
| 'disconnected'
| 'disabled'
| 'error';
/** MCP 服务器状态 */
export interface MCPServerStatus {
/** 服务器名称 */
name: string;
/** 服务器类型 */
type: 'local' | 'remote';
/** 当前状态 */
status: MCPServerStatusType;
/** 工具数量 */
toolCount: number;
/** 错误信息 */
error?: string;
/** 配置信息 */
config?: {
type: 'local' | 'remote';
command?: string[];
url?: string;
timeout?: number;
cwd?: string;
};
/** 工具列表(仅详情接口返回) */
tools?: MCPToolInfo[];
}
/** MCP 工具信息 */
export interface MCPToolInfo {
/** 完整工具名: {server}-{originalName} */
name: string;
/** 来源服务器名称 */
server: string;
/** MCP 服务器中的原始名称 */
originalName: string;
/** 工具描述 */
description: string;
/** 输入参数 JSON Schema */
inputSchema?: Record<string, unknown>;
}
/** MCP 配置 */
export interface MCPConfig {
/** MCP 服务器配置 */
mcp?: Record<string, MCPServerConfigInfo>;
/** 工具启用/禁用配置 */
tools?: Record<string, boolean>;
}
/** MCP 服务器配置信息 */
export interface MCPServerConfigInfo {
type: 'local' | 'remote';
command?: string[];
url?: string;
enabled?: boolean;
timeout?: number;
}
+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>
);
}
+17
View File
@@ -32,6 +32,16 @@ export {
getCommandContent,
updateCommand,
deleteCommand,
// MCP API
listMCPServers,
getMCPServer,
connectMCPServer,
disconnectMCPServer,
enableMCPServer,
disableMCPServer,
listMCPTools,
getMCPTool,
getMCPConfig,
} from './api/client.js';
// Types
@@ -54,6 +64,12 @@ export type {
CreateCommandInput,
UpdateCommandInput,
CommandContent,
// MCP types
MCPServerStatus,
MCPServerStatusType,
MCPToolInfo,
MCPConfig,
MCPServerConfigInfo,
} from './api/client.js';
// Primitives (shadcn/ui style)
@@ -69,6 +85,7 @@ export { ChatInput } from './components/ChatInput.js';
export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
export { CommandPanel } from './components/CommandPanel.js';
export { CommandEditor } from './components/CommandEditor.js';
export { MCPPanel } from './components/MCPPanel.js';
export { Sidebar } from './components/Sidebar.js';
export { FileBrowser } from './components/FileBrowser.js';
export { ConfigPanel } from './components/ConfigPanel.js';
+6
View File
@@ -10,6 +10,7 @@ import {
FileBrowser,
ConfigPanel,
CommandPanel,
MCPPanel,
Toaster,
listSessions,
createSession,
@@ -23,6 +24,7 @@ export function App() {
const [showFileBrowser, setShowFileBrowser] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [showCommands, setShowCommands] = useState(false);
const [showMCP, setShowMCP] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// 初始化:加载或创建会话
@@ -108,6 +110,7 @@ export function App() {
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
onOpenConfig={() => setShowConfig(true)}
onOpenCommands={() => setShowCommands(true)}
onOpenMCP={() => setShowMCP(true)}
/>
) : (
<div className="flex-1 flex items-center justify-center h-full">
@@ -159,6 +162,9 @@ export function App() {
{/* 命令面板 */}
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />}
{/* MCP 面板 */}
{showMCP && <MCPPanel onClose={() => setShowMCP(false)} responsive />}
{/* 移动端底部文件按钮 */}
<button
onClick={() => setShowFileBrowser(true)}
+17 -2
View File
@@ -3,7 +3,7 @@
*/
import { useEffect, useRef } from 'react';
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal } from 'lucide-react';
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
useChat,
@@ -23,6 +23,7 @@ interface ChatPageProps {
onToggleFileBrowser?: () => void;
onOpenConfig?: () => void;
onOpenCommands?: () => void;
onOpenMCP?: () => void;
}
export function ChatPage({
@@ -34,6 +35,7 @@ export function ChatPage({
onToggleFileBrowser,
onOpenConfig,
onOpenCommands,
onOpenMCP,
}: ChatPageProps) {
const {
messages,
@@ -128,8 +130,21 @@ export function ChatPage({
<ConnectionStatus />
{/* 工具栏按钮 */}
{(onOpenConfig || onToggleFileBrowser || onOpenCommands) && (
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP) && (
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
{/* MCP 按钮 */}
{onOpenMCP && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenMCP}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="MCP Servers"
>
<Plug size={20} />
</motion.button>
)}
{/* 命令按钮 */}
{onOpenCommands && (
<motion.button