feat: 实现 MCP (Model Context Protocol) 完整功能

主进程:
- 新增 mcp-manager.ts: JSON-RPC 2.0 over stdio 通信
  - 服务器生命周期管理 (spawn/initialize/handshake/stop)
  - tools/list 动态工具发现
  - tools/call 工具执行 (30 秒超时)
  - 消息分帧 (newline-delimited JSON)
  - 优雅清理 (pending 请求超时 + SIGTERM)

IPC 通道 (7 个):
- mcp:startServer / mcp:stopServer / mcp:stopAll
- mcp:callTool / mcp:getTools / mcp:getStatuses / mcp:refreshTools

渲染端:
- mcp-client.ts 完整重写: 配置管理 + 服务器生命周期 + 工具定义获取 + 工具执行
- tool-registry.ts: MCP 工具路由 (mcp_ 前缀识别 → executeMCPTool)
- settings-modal.ts: MCP 服务器管理 UI (添加/启用/禁用/删除/状态显示)
- main.ts: 启动时自动启动已启用的 MCP 服务器

数据流:
settings.mcp_servers → startAllMCPServers → initialize → tools/list → getMCPToolDefinitions → Agent Loop → mcp_ 工具调用 → callTool → JSON-RPC → MCP Server
This commit is contained in:
thzxx
2026-04-18 10:14:33 +08:00
parent d2468cd6fc
commit 22a33ae241
10 changed files with 539 additions and 27 deletions
+72
View File
@@ -151,6 +151,78 @@ export function initSettingsModal(): void {
logSetting('检查间隔', `${ms / 60000} 分钟`);
});
// ── v5.0 MCP 服务器管理 ──
async function renderMCPServerList(): Promise<void> {
const { getMCPServers, getMCPServerStatuses, toggleMCPServer, removeMCPServer } = await import('../services/mcp-client.js');
const { refreshMCPTools } = await import('../services/tool-registry.js');
const container = document.querySelector('#mcpServerList')!;
const servers = await getMCPServers();
const statuses = await getMCPServerStatuses();
if (servers.length === 0) {
container.innerHTML = '<p class="text-muted" style="margin:4px 0;font-size:11px;">未配置 MCP 服务器。MCP 允许连接外部工具服务。</p>';
return;
}
container.innerHTML = servers.map(s => {
const status = statuses.find(st => st.name === s.name);
const running = status?.running || false;
const toolCount = status?.toolCount || 0;
return `<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--border-subtle);">
<span style="width:8px;height:8px;border-radius:50%;background:${running ? 'var(--success)' : 'var(--text-muted)'};" title="${running ? '已连接' : '未连接'}"></span>
<span style="flex:1;font-weight:500;">${s.name}</span>
<span class="text-muted" style="font-size:11px;">${s.command} ${s.args.join(' ')}${running ? ` (${toolCount} 工具)` : ''}</span>
<button class="btn btn-sm btn-outline mcp-toggle" data-name="${s.name}" style="padding:2px 8px;font-size:11px;">${s.enabled ? '禁用' : '启用'}</button>
<button class="btn btn-sm btn-outline mcp-delete" data-name="${s.name}" style="padding:2px 8px;font-size:11px;color:var(--critical);">删除</button>
</div>`;
}).join('');
container.querySelectorAll('.mcp-toggle').forEach(btn => {
btn.addEventListener('click', async () => {
const name = (btn as HTMLElement).dataset.name!;
await toggleMCPServer(name);
await refreshMCPTools();
await renderMCPServerList();
});
});
container.querySelectorAll('.mcp-delete').forEach(btn => {
btn.addEventListener('click', async () => {
const name = (btn as HTMLElement).dataset.name!;
await removeMCPServer(name);
await refreshMCPTools();
await renderMCPServerList();
});
});
}
document.querySelector('#btnAddMCPServer')!.addEventListener('click', async () => {
const { addMCPServer } = await import('../services/mcp-client.js');
const { refreshMCPTools } = await import('../services/tool-registry.js');
const name = (document.querySelector('#inputMCPName') as HTMLInputElement).value.trim();
const command = (document.querySelector('#inputMCPCommand') as HTMLInputElement).value.trim();
const argsStr = (document.querySelector('#inputMCPArgs') as HTMLInputElement).value.trim();
if (!name || !command) {
showToast('请填写名称和命令', 'warning');
return;
}
const args = argsStr ? argsStr.split(/\s+/) : [];
await addMCPServer({ name, command, args, enabled: true });
await refreshMCPTools();
(document.querySelector('#inputMCPName') as HTMLInputElement).value = '';
(document.querySelector('#inputMCPCommand') as HTMLInputElement).value = '';
(document.querySelector('#inputMCPArgs') as HTMLInputElement).value = '';
showToast(`MCP 服务器已添加: ${name}`, 'success');
await renderMCPServerList();
});
// 设置面板打开时刷新 MCP 列表
document.querySelector('#btnSettings')!.addEventListener('click', () => {
setTimeout(renderMCPServerList, 100);
});
// ── v5.0 Cron 定时任务 ──
document.querySelector('#btnAddCronTask')!.addEventListener('click', async () => {
const name = (document.querySelector('#inputCronName') as HTMLInputElement).value.trim();
+10
View File
@@ -347,6 +347,16 @@
<button class="btn btn-outline" id="btnDoctor" style="margin-bottom:8px;">🩺 一键诊断</button>
<div id="doctorResults" style="display:none;margin-top:8px;padding:10px;background:var(--bg-layer);border-radius:var(--radius-control);font-size:12px;max-height:200px;overflow-y:auto;"></div>
</div>
<div class="setting-group">
<label class="setting-label">🔌 MCP 服务器</label>
<div style="display:flex;gap:8px;margin-bottom:8px;">
<input class="setting-input" id="inputMCPName" type="text" placeholder="名称" style="margin-bottom:0;width:100px;">
<input class="setting-input" id="inputMCPCommand" type="text" placeholder="命令 (如: npx)" style="margin-bottom:0;width:100px;">
<input class="setting-input" id="inputMCPArgs" type="text" placeholder="参数 (空格分隔)" style="margin-bottom:0;flex:1;">
<button class="btn btn-sm btn-outline" id="btnAddMCPServer"> 添加</button>
</div>
<div id="mcpServerList" style="font-size:12px;"></div>
</div>
<div class="setting-group">
<label class="setting-label">⏰ 定时任务 (Cron)</label>
<div style="display:flex;gap:8px;margin-bottom:8px;">
+2
View File
@@ -25,6 +25,7 @@ import { initMemoryManager } from './services/memory-manager.js';
import { restoreHeartbeat } from './components/settings-modal.js';
import { restoreCronTasks } from './services/cron-manager.js';
import { setToolEnabled, setRunCommandMode, refreshMCPTools } from './services/tool-registry.js';
import { startAllMCPServers, stopAllMCPServers } from './services/mcp-client.js';
import { initToolConfirmModal } from './components/tool-confirm-modal.js';
import { initWorkspacePanel } from './components/workspace-panel.js';
import { initLogPanel, addLog } from './services/log-service.js';
@@ -362,6 +363,7 @@ async function init(): Promise<void> {
if (toggleTC) toggleTC.checked = toolCallingEnabled;
// ── MCP 工具注册 ──
await startAllMCPServers();
await refreshMCPTools();
// ── Agent 记忆设置 ──
+105 -26
View File
@@ -1,17 +1,11 @@
/**
* MCP Client - Model Context Protocol 客户端 (v4.3)
* 轻量级实现:管理 MCP Server 配置,提供工具注册框架
*
* MCP Server 配置存储在 settings.mcp_servers 中,格式:
* [
* { "name": "filesystem", "command": "npx", "args": ["-y", "@anthropic/mcp-filesystem"], "enabled": true },
* { "name": "sqlite", "command": "npx", "args": ["-y", "@anthropic/mcp-sqlite", "./data.db"], "enabled": false }
* ]
* MCP Client - Model Context Protocol 渲染端客户端
* 管理 MCP Server 配置,通过 IPC 调用主进程进行协议通信
*/
import { state, KEYS } from '../state/state.js';
import { logInfo, logWarn } from './log-service.js';
import type { ToolDefinition } from '../types.js';
import { logInfo, logWarn, logSuccess, logError } from './log-service.js';
import type { ToolDefinition, ToolResult } from '../types.js';
export interface MCPServerConfig {
name: string;
@@ -21,7 +15,8 @@ export interface MCPServerConfig {
description?: string;
}
/** 获取所有 MCP 服务器配置 */
// ─── 配置管理 ───
export async function getMCPServers(): Promise<MCPServerConfig[]> {
const db = state.get<any>(KEYS.DB);
if (!db) return [];
@@ -32,13 +27,11 @@ export async function getMCPServers(): Promise<MCPServerConfig[]> {
}
}
/** 保存 MCP 服务器配置 */
export async function saveMCPServers(servers: MCPServerConfig[]): Promise<void> {
const db = state.get<any>(KEYS.DB);
if (db) await db.saveSetting('mcp_servers', servers);
}
/** 添加 MCP 服务器 */
export async function addMCPServer(config: MCPServerConfig): Promise<void> {
const servers = await getMCPServers();
const existing = servers.findIndex(s => s.name === config.name);
@@ -51,14 +44,14 @@ export async function addMCPServer(config: MCPServerConfig): Promise<void> {
logInfo(`MCP Server 已${existing >= 0 ? '更新' : '添加'}: ${config.name}`);
}
/** 删除 MCP 服务器 */
export async function removeMCPServer(name: string): Promise<void> {
const bridge = (window as any).metonaDesktop;
if (bridge?.mcp) await bridge.mcp.stopServer(name);
const servers = await getMCPServers();
await saveMCPServers(servers.filter(s => s.name !== name));
logInfo(`MCP Server 已删除: ${name}`);
}
/** 切换 MCP 服务器启用状态 */
export async function toggleMCPServer(name: string): Promise<boolean> {
const servers = await getMCPServers();
const server = servers.find(s => s.name === name);
@@ -69,22 +62,108 @@ export async function toggleMCPServer(name: string): Promise<boolean> {
return server.enabled;
}
/** 获取已启用的 MCP 服务器 */
export async function getEnabledMCPServers(): Promise<MCPServerConfig[]> {
const servers = await getMCPServers();
return servers.filter(s => s.enabled);
}
/**
* 获取 MCP 工具定义(供 Agent 使用)
* 目前返回占位工具定义,实际 MCP 协议通信需通过主进程实现
*/
// ─── MCP 服务器生命周期 ───
/** 启动所有已启用的 MCP 服务器 */
export async function startAllMCPServers(): Promise<void> {
const bridge = (window as any).metonaDesktop;
if (!bridge?.mcp) return;
const configs = await getEnabledMCPServers();
for (const config of configs) {
try {
const result = await bridge.mcp.startServer(config);
if (result.success) {
logSuccess(`MCP 服务器已启动: ${config.name}`);
} else {
logWarn(`MCP 服务器启动失败: ${config.name}`, result.error);
}
} catch (err) {
logError(`MCP 服务器启动异常: ${config.name}`, (err as Error).message);
}
}
}
/** 停止所有 MCP 服务器 */
export async function stopAllMCPServers(): Promise<void> {
const bridge = (window as any).metonaDesktop;
if (bridge?.mcp) await bridge.mcp.stopAll();
}
/** 获取 MCP 服务器状态 */
export async function getMCPServerStatuses(): Promise<Array<{ name: string; running: boolean; toolCount: number }>> {
const bridge = (window as any).metonaDesktop;
if (!bridge?.mcp) return [];
try {
return await bridge.mcp.getStatuses();
} catch {
return [];
}
}
// ─── 工具定义(供 Agent 使用)───
/** 从主进程获取所有 MCP 工具并转换为 ToolDefinition 格式 */
export async function getMCPToolDefinitions(): Promise<ToolDefinition[]> {
const enabled = await getEnabledMCPServers();
// MCP 工具通过主进程的 MCP bridge 动态注册
// 这里返回空数组,工具定义由 MCP bridge 在 IPC 层注入
if (enabled.length > 0) {
logInfo(`MCP: ${enabled.length} 个服务器已启用`, enabled.map(s => s.name).join(', '));
const bridge = (window as any).metonaDesktop;
if (!bridge?.mcp) return [];
try {
const mcpTools = await bridge.mcp.getTools() as Array<{
serverName: string;
name: string;
fullName: string;
description: string;
inputSchema: { type: string; properties?: Record<string, unknown>; required?: string[] };
}>;
if (mcpTools.length === 0) return [];
logInfo(`MCP 工具已注册: ${mcpTools.length}`, mcpTools.map(t => t.fullName).join(', '));
return mcpTools.map(t => ({
type: 'function' as const,
function: {
name: t.fullName,
description: t.description,
parameters: {
type: 'object' as const,
required: t.inputSchema.required || [],
properties: (t.inputSchema.properties || {}) as Record<string, { type: string; description?: string }>
}
}
}));
} catch (err) {
logError('MCP 工具获取失败', (err as Error).message);
return [];
}
}
/** 执行 MCP 工具调用 */
export async function executeMCPTool(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
const bridge = (window as any).metonaDesktop;
if (!bridge?.mcp) {
return { success: false, error: 'MCP API 不可用' };
}
// 解析工具全名: mcp_{serverName}_{toolName}
const match = toolName.match(/^mcp_(.+?)_(.+)$/);
if (!match) {
return { success: false, error: `无效的 MCP 工具名: ${toolName}` };
}
const serverName = match[1];
const actualToolName = match[2];
try {
const result = await bridge.mcp.callTool(serverName, actualToolName, args);
return result;
} catch (err) {
return { success: false, error: (err as Error).message };
}
return [];
}
+7 -1
View File
@@ -595,7 +595,13 @@ export async function refreshMCPTools(): Promise<void> {
}
export async function executeTool(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
if (!isToolEnabled(toolName)) {
// MCP 工具路由
if (toolName.startsWith('mcp_')) {
const { executeMCPTool } = await import('./mcp-client.js');
return executeMCPTool(toolName, args);
}
if (!enabledTools.has(toolName)) {
logError(`工具未启用: ${toolName}`);
return { success: false, error: `工具 ${toolName} 未启用` };
}
+9
View File
@@ -240,6 +240,15 @@ export interface MetonaDesktopAPI {
/** 终止当前 AI 命令 */
cmdKill: () => Promise<{ killed: boolean }>;
};
mcp: {
startServer: (config: { name: string; command: string; args: string[]; enabled: boolean; description?: string }) => Promise<{ success: boolean; error?: string }>;
stopServer: (name: string) => Promise<{ success: boolean }>;
stopAll: () => Promise<{ success: boolean }>;
callTool: (serverName: string, toolName: string, args: Record<string, unknown>) => Promise<{ success: boolean; result?: unknown; error?: string }>;
getTools: () => Promise<Array<{ serverName: string; name: string; fullName: string; description: string; inputSchema: { type: string; properties?: Record<string, unknown>; required?: string[] } }>>;
getStatuses: () => Promise<Array<{ name: string; running: boolean; toolCount: number }>>;
refreshTools: (name: string) => Promise<Array<{ name: string; description?: string; inputSchema: Record<string, unknown> }>>;
};
}
export interface AppInfo {