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:
@@ -48,6 +48,7 @@ import {
|
|||||||
handleCompress
|
handleCompress
|
||||||
} from './tool-handlers.js';
|
} from './tool-handlers.js';
|
||||||
import { browserOpen, browserScreenshot, browserEvaluate, browserExtract, browserClick, browserType, browserScroll, browserClose } from './browser.js';
|
import { browserOpen, browserScreenshot, browserEvaluate, browserExtract, browserClick, browserType, browserScroll, browserClose } from './browser.js';
|
||||||
|
import { startServer, stopServer, stopAllServers, callTool, getAllTools, getServerStatuses, refreshTools } from './mcp-manager.js';
|
||||||
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
|
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
|
||||||
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js';
|
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js';
|
||||||
|
|
||||||
@@ -408,4 +409,29 @@ export async function setupIPC(): Promise<void> {
|
|||||||
try { incrementSkillUsage(id, success, durationMs); return { success: true }; }
|
try { incrementSkillUsage(id, success, durationMs); return { success: true }; }
|
||||||
catch (err) { return { success: false, error: (err as Error).message }; }
|
catch (err) { return { success: false, error: (err as Error).message }; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── MCP IPC ──
|
||||||
|
ipcMain.handle('mcp:startServer', async (_, config) => {
|
||||||
|
return startServer(config);
|
||||||
|
});
|
||||||
|
ipcMain.handle('mcp:stopServer', (_, name: string) => {
|
||||||
|
stopServer(name);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
ipcMain.handle('mcp:stopAll', () => {
|
||||||
|
stopAllServers();
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
ipcMain.handle('mcp:callTool', async (_, serverName: string, toolName: string, args: Record<string, unknown>) => {
|
||||||
|
return callTool(serverName, toolName, args);
|
||||||
|
});
|
||||||
|
ipcMain.handle('mcp:getTools', () => {
|
||||||
|
return getAllTools();
|
||||||
|
});
|
||||||
|
ipcMain.handle('mcp:getStatuses', () => {
|
||||||
|
return getServerStatuses();
|
||||||
|
});
|
||||||
|
ipcMain.handle('mcp:refreshTools', async (_, name: string) => {
|
||||||
|
return refreshTools(name);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createMenu } from './menu.js';
|
|||||||
import { showNotification } from './utils.js';
|
import { showNotification } from './utils.js';
|
||||||
import { ensureWorkspaceDir, killAllProcesses } from './workspace.js';
|
import { ensureWorkspaceDir, killAllProcesses } from './workspace.js';
|
||||||
import { browserClose } from './browser.js';
|
import { browserClose } from './browser.js';
|
||||||
|
import { stopAllServers } from './mcp-manager.js';
|
||||||
|
|
||||||
// ── 全局错误处理:写入文件 + 弹窗提示 ──
|
// ── 全局错误处理:写入文件 + 弹窗提示 ──
|
||||||
const ERROR_LOG = path.join(app.getPath('userData'), 'startup-error.log');
|
const ERROR_LOG = path.join(app.getPath('userData'), 'startup-error.log');
|
||||||
@@ -197,6 +198,8 @@ app.on('before-quit', () => {
|
|||||||
isQuitting = true;
|
isQuitting = true;
|
||||||
// 清理浏览器
|
// 清理浏览器
|
||||||
browserClose();
|
browserClose();
|
||||||
|
// 清理 MCP 服务器
|
||||||
|
stopAllServers();
|
||||||
// 清理所有工作空间进程
|
// 清理所有工作空间进程
|
||||||
killAllProcesses();
|
killAllProcesses();
|
||||||
// 通知渲染进程释放显存
|
// 通知渲染进程释放显存
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
/**
|
||||||
|
* MCP Manager - Model Context Protocol 主进程管理器
|
||||||
|
* 通过 stdio JSON-RPC 2.0 与 MCP Server 通信
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { mainWindow } from './main.js';
|
||||||
|
|
||||||
|
function sendLog(level: 'info' | 'success' | 'warn' | 'error', message: string, detail?: string): void {
|
||||||
|
mainWindow?.webContents.send('main:log', { level, message, detail });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 类型定义 ───
|
||||||
|
|
||||||
|
interface JSONRPCRequest {
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
id: number;
|
||||||
|
method: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JSONRPCResponse {
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
id: number;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { code: number; message: string; data?: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPTool {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema: {
|
||||||
|
type: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPServerInstance {
|
||||||
|
config: { name: string; command: string; args: string[]; enabled: boolean; description?: string };
|
||||||
|
process: ChildProcess;
|
||||||
|
requestId: number;
|
||||||
|
pendingRequests: Map<number, { resolve: (value: unknown) => void; reject: (reason: Error) => void; timer: ReturnType<typeof setTimeout> }>;
|
||||||
|
buffer: string;
|
||||||
|
tools: MCPTool[];
|
||||||
|
initialized: boolean;
|
||||||
|
capabilities: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 状态 ───
|
||||||
|
|
||||||
|
const servers = new Map<string, MCPServerInstance>();
|
||||||
|
const MCP_TIMEOUT = 30000; // 30 秒超时
|
||||||
|
|
||||||
|
// ─── JSON-RPC 通信 ───
|
||||||
|
|
||||||
|
function sendRequest(server: MCPServerInstance, method: string, params?: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = ++server.requestId;
|
||||||
|
const request: JSONRPCRequest = { jsonrpc: '2.0', id, method, params };
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
server.pendingRequests.delete(id);
|
||||||
|
reject(new Error(`MCP 请求超时: ${server.config.name} / ${method} (${MCP_TIMEOUT}ms)`));
|
||||||
|
}, MCP_TIMEOUT);
|
||||||
|
|
||||||
|
server.pendingRequests.set(id, { resolve, reject, timer });
|
||||||
|
|
||||||
|
const line = JSON.stringify(request) + '\n';
|
||||||
|
try {
|
||||||
|
server.process.stdin!.write(line);
|
||||||
|
} catch (err) {
|
||||||
|
server.pendingRequests.delete(id);
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error(`MCP 写入失败: ${(err as Error).message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResponse(server: MCPServerInstance, response: JSONRPCResponse): void {
|
||||||
|
const pending = server.pendingRequests.get(response.id);
|
||||||
|
if (!pending) return;
|
||||||
|
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
server.pendingRequests.delete(response.id);
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
pending.reject(new Error(`MCP 错误 [${response.error.code}]: ${response.error.message}`));
|
||||||
|
} else {
|
||||||
|
pending.resolve(response.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processData(server: MCPServerInstance, data: Buffer): void {
|
||||||
|
server.buffer += data.toString('utf-8');
|
||||||
|
const lines = server.buffer.split('\n');
|
||||||
|
server.buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line);
|
||||||
|
if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
|
||||||
|
handleResponse(server, msg as JSONRPCResponse);
|
||||||
|
}
|
||||||
|
// 忽略通知(无 id 的消息)
|
||||||
|
} catch {
|
||||||
|
// 非 JSON 行,忽略(有些 server 会输出启动日志)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 服务器生命周期 ───
|
||||||
|
|
||||||
|
export async function startServer(config: { name: string; command: string; args: string[]; enabled: boolean; description?: string }): Promise<{ success: boolean; error?: string }> {
|
||||||
|
// 已在运行则先停止
|
||||||
|
if (servers.has(config.name)) {
|
||||||
|
stopServer(config.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendLog('info', `🔌 MCP 启动: ${config.name}`, `${config.command} ${config.args.join(' ')}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proc = spawn(config.command, config.args, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env },
|
||||||
|
cwd: process.cwd()
|
||||||
|
});
|
||||||
|
|
||||||
|
const server: MCPServerInstance = {
|
||||||
|
config,
|
||||||
|
process: proc,
|
||||||
|
requestId: 0,
|
||||||
|
pendingRequests: new Map(),
|
||||||
|
buffer: '',
|
||||||
|
tools: [],
|
||||||
|
initialized: false,
|
||||||
|
capabilities: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
servers.set(config.name, server);
|
||||||
|
|
||||||
|
proc.stdout.on('data', (data: Buffer) => processData(server, data));
|
||||||
|
|
||||||
|
proc.stderr.on('data', (data: Buffer) => {
|
||||||
|
const msg = data.toString('utf-8').trim();
|
||||||
|
if (msg) sendLog('info', `🔌 MCP [${config.name}] stderr`, msg.slice(0, 200));
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
sendLog(code === 0 ? 'info' : 'warn', `🔌 MCP 关闭: ${config.name}`, `exit code: ${code}`);
|
||||||
|
cleanupServer(server);
|
||||||
|
servers.delete(config.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
sendLog('error', `🔌 MCP 启动失败: ${config.name}`, err.message);
|
||||||
|
cleanupServer(server);
|
||||||
|
servers.delete(config.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// MCP initialize 握手
|
||||||
|
try {
|
||||||
|
const initResult = await sendRequest(server, 'initialize', {
|
||||||
|
protocolVersion: '2024-11-05',
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
clientInfo: { name: 'Metona Ollama', version: '5.0.0' }
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
server.capabilities = (initResult.capabilities as Record<string, unknown>) || {};
|
||||||
|
server.initialized = true;
|
||||||
|
|
||||||
|
// 发送 initialized 通知
|
||||||
|
const notification = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
|
||||||
|
server.process.stdin!.write(notification);
|
||||||
|
|
||||||
|
sendLog('success', `🔌 MCP 已连接: ${config.name}`, `协议 ${(initResult.protocolVersion as string) || 'unknown'}`);
|
||||||
|
|
||||||
|
// 获取工具列表
|
||||||
|
await refreshTools(config.name);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
sendLog('error', `🔌 MCP 握手失败: ${config.name}`, (err as Error).message);
|
||||||
|
stopServer(config.name);
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
sendLog('error', `🔌 MCP spawn 失败: ${config.name}`, (err as Error).message);
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupServer(server: MCPServerInstance): void {
|
||||||
|
for (const [, pending] of server.pendingRequests) {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error('MCP 服务器已关闭'));
|
||||||
|
}
|
||||||
|
server.pendingRequests.clear();
|
||||||
|
try { server.process.kill('SIGTERM'); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopServer(name: string): void {
|
||||||
|
const server = servers.get(name);
|
||||||
|
if (!server) return;
|
||||||
|
cleanupServer(server);
|
||||||
|
servers.delete(name);
|
||||||
|
sendLog('info', `🔌 MCP 已停止: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopAllServers(): void {
|
||||||
|
for (const [name] of servers) {
|
||||||
|
stopServer(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 工具操作 ───
|
||||||
|
|
||||||
|
export async function refreshTools(name: string): Promise<MCPTool[]> {
|
||||||
|
const server = servers.get(name);
|
||||||
|
if (!server || !server.initialized) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendRequest(server, 'tools/list') as { tools?: MCPTool[] };
|
||||||
|
server.tools = result.tools || [];
|
||||||
|
sendLog('success', `🔌 MCP [${name}] ${server.tools.length} 个工具`,
|
||||||
|
server.tools.map(t => t.name).join(', '));
|
||||||
|
return server.tools;
|
||||||
|
} catch (err) {
|
||||||
|
sendLog('error', `🔌 MCP [${name}] 获取工具失败`, (err as Error).message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callTool(serverName: string, toolName: string, args: Record<string, unknown>): Promise<{ success: boolean; result?: unknown; error?: string }> {
|
||||||
|
const server = servers.get(serverName);
|
||||||
|
if (!server || !server.initialized) {
|
||||||
|
return { success: false, error: `MCP 服务器 ${serverName} 未运行` };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendRequest(server, 'tools/call', { name: toolName, arguments: args });
|
||||||
|
return { success: true, result };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 查询 ───
|
||||||
|
|
||||||
|
export interface MCPToolInfo {
|
||||||
|
serverName: string;
|
||||||
|
name: string;
|
||||||
|
fullName: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: MCPTool['inputSchema'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有已连接服务器的工具列表 */
|
||||||
|
export function getAllTools(): MCPToolInfo[] {
|
||||||
|
const tools: MCPToolInfo[] = [];
|
||||||
|
for (const [serverName, server] of servers) {
|
||||||
|
if (!server.initialized) continue;
|
||||||
|
for (const tool of server.tools) {
|
||||||
|
tools.push({
|
||||||
|
serverName,
|
||||||
|
name: tool.name,
|
||||||
|
fullName: `mcp_${serverName}_${tool.name}`,
|
||||||
|
description: `[MCP:${serverName}] ${tool.description || ''}`,
|
||||||
|
inputSchema: tool.inputSchema
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取服务器状态 */
|
||||||
|
export function getServerStatuses(): Array<{ name: string; running: boolean; toolCount: number; error?: string }> {
|
||||||
|
const statuses: Array<{ name: string; running: boolean; toolCount: number; error?: string }> = [];
|
||||||
|
for (const [name, server] of servers) {
|
||||||
|
statuses.push({
|
||||||
|
name,
|
||||||
|
running: server.initialized,
|
||||||
|
toolCount: server.tools.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查服务器是否在运行 */
|
||||||
|
export function isServerRunning(name: string): boolean {
|
||||||
|
const server = servers.get(name);
|
||||||
|
return !!server && server.initialized;
|
||||||
|
}
|
||||||
@@ -103,5 +103,14 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
|
|||||||
},
|
},
|
||||||
/** 终止当前 AI 命令 */
|
/** 终止当前 AI 命令 */
|
||||||
cmdKill: () => ipcRenderer.invoke('cmd:kill')
|
cmdKill: () => ipcRenderer.invoke('cmd:kill')
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
startServer: (config: { name: string; command: string; args: string[]; enabled: boolean; description?: string }) => ipcRenderer.invoke('mcp:startServer', config),
|
||||||
|
stopServer: (name: string) => ipcRenderer.invoke('mcp:stopServer', name),
|
||||||
|
stopAll: () => ipcRenderer.invoke('mcp:stopAll'),
|
||||||
|
callTool: (serverName: string, toolName: string, args: Record<string, unknown>) => ipcRenderer.invoke('mcp:callTool', serverName, toolName, args),
|
||||||
|
getTools: () => ipcRenderer.invoke('mcp:getTools'),
|
||||||
|
getStatuses: () => ipcRenderer.invoke('mcp:getStatuses'),
|
||||||
|
refreshTools: (name: string) => ipcRenderer.invoke('mcp:refreshTools', name)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -151,6 +151,78 @@ export function initSettingsModal(): void {
|
|||||||
logSetting('检查间隔', `${ms / 60000} 分钟`);
|
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 定时任务 ──
|
// ── v5.0 Cron 定时任务 ──
|
||||||
document.querySelector('#btnAddCronTask')!.addEventListener('click', async () => {
|
document.querySelector('#btnAddCronTask')!.addEventListener('click', async () => {
|
||||||
const name = (document.querySelector('#inputCronName') as HTMLInputElement).value.trim();
|
const name = (document.querySelector('#inputCronName') as HTMLInputElement).value.trim();
|
||||||
|
|||||||
@@ -347,6 +347,16 @@
|
|||||||
<button class="btn btn-outline" id="btnDoctor" style="margin-bottom:8px;">🩺 一键诊断</button>
|
<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 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>
|
||||||
|
<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">
|
<div class="setting-group">
|
||||||
<label class="setting-label">⏰ 定时任务 (Cron)</label>
|
<label class="setting-label">⏰ 定时任务 (Cron)</label>
|
||||||
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { initMemoryManager } from './services/memory-manager.js';
|
|||||||
import { restoreHeartbeat } from './components/settings-modal.js';
|
import { restoreHeartbeat } from './components/settings-modal.js';
|
||||||
import { restoreCronTasks } from './services/cron-manager.js';
|
import { restoreCronTasks } from './services/cron-manager.js';
|
||||||
import { setToolEnabled, setRunCommandMode, refreshMCPTools } from './services/tool-registry.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 { initToolConfirmModal } from './components/tool-confirm-modal.js';
|
||||||
import { initWorkspacePanel } from './components/workspace-panel.js';
|
import { initWorkspacePanel } from './components/workspace-panel.js';
|
||||||
import { initLogPanel, addLog } from './services/log-service.js';
|
import { initLogPanel, addLog } from './services/log-service.js';
|
||||||
@@ -362,6 +363,7 @@ async function init(): Promise<void> {
|
|||||||
if (toggleTC) toggleTC.checked = toolCallingEnabled;
|
if (toggleTC) toggleTC.checked = toolCallingEnabled;
|
||||||
|
|
||||||
// ── MCP 工具注册 ──
|
// ── MCP 工具注册 ──
|
||||||
|
await startAllMCPServers();
|
||||||
await refreshMCPTools();
|
await refreshMCPTools();
|
||||||
|
|
||||||
// ── Agent 记忆设置 ──
|
// ── Agent 记忆设置 ──
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* MCP Client - Model Context Protocol 客户端 (v4.3)
|
* MCP Client - Model Context Protocol 渲染端客户端
|
||||||
* 轻量级实现:管理 MCP Server 配置,提供工具注册框架
|
* 管理 MCP Server 配置,通过 IPC 调用主进程进行协议通信
|
||||||
*
|
|
||||||
* 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 }
|
|
||||||
* ]
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { state, KEYS } from '../state/state.js';
|
import { state, KEYS } from '../state/state.js';
|
||||||
import { logInfo, logWarn } from './log-service.js';
|
import { logInfo, logWarn, logSuccess, logError } from './log-service.js';
|
||||||
import type { ToolDefinition } from '../types.js';
|
import type { ToolDefinition, ToolResult } from '../types.js';
|
||||||
|
|
||||||
export interface MCPServerConfig {
|
export interface MCPServerConfig {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -21,7 +15,8 @@ export interface MCPServerConfig {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取所有 MCP 服务器配置 */
|
// ─── 配置管理 ───
|
||||||
|
|
||||||
export async function getMCPServers(): Promise<MCPServerConfig[]> {
|
export async function getMCPServers(): Promise<MCPServerConfig[]> {
|
||||||
const db = state.get<any>(KEYS.DB);
|
const db = state.get<any>(KEYS.DB);
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
@@ -32,13 +27,11 @@ export async function getMCPServers(): Promise<MCPServerConfig[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存 MCP 服务器配置 */
|
|
||||||
export async function saveMCPServers(servers: MCPServerConfig[]): Promise<void> {
|
export async function saveMCPServers(servers: MCPServerConfig[]): Promise<void> {
|
||||||
const db = state.get<any>(KEYS.DB);
|
const db = state.get<any>(KEYS.DB);
|
||||||
if (db) await db.saveSetting('mcp_servers', servers);
|
if (db) await db.saveSetting('mcp_servers', servers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 添加 MCP 服务器 */
|
|
||||||
export async function addMCPServer(config: MCPServerConfig): Promise<void> {
|
export async function addMCPServer(config: MCPServerConfig): Promise<void> {
|
||||||
const servers = await getMCPServers();
|
const servers = await getMCPServers();
|
||||||
const existing = servers.findIndex(s => s.name === config.name);
|
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}`);
|
logInfo(`MCP Server 已${existing >= 0 ? '更新' : '添加'}: ${config.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除 MCP 服务器 */
|
|
||||||
export async function removeMCPServer(name: string): Promise<void> {
|
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();
|
const servers = await getMCPServers();
|
||||||
await saveMCPServers(servers.filter(s => s.name !== name));
|
await saveMCPServers(servers.filter(s => s.name !== name));
|
||||||
logInfo(`MCP Server 已删除: ${name}`);
|
logInfo(`MCP Server 已删除: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 切换 MCP 服务器启用状态 */
|
|
||||||
export async function toggleMCPServer(name: string): Promise<boolean> {
|
export async function toggleMCPServer(name: string): Promise<boolean> {
|
||||||
const servers = await getMCPServers();
|
const servers = await getMCPServers();
|
||||||
const server = servers.find(s => s.name === name);
|
const server = servers.find(s => s.name === name);
|
||||||
@@ -69,22 +62,108 @@ export async function toggleMCPServer(name: string): Promise<boolean> {
|
|||||||
return server.enabled;
|
return server.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取已启用的 MCP 服务器 */
|
|
||||||
export async function getEnabledMCPServers(): Promise<MCPServerConfig[]> {
|
export async function getEnabledMCPServers(): Promise<MCPServerConfig[]> {
|
||||||
const servers = await getMCPServers();
|
const servers = await getMCPServers();
|
||||||
return servers.filter(s => s.enabled);
|
return servers.filter(s => s.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ─── MCP 服务器生命周期 ───
|
||||||
* 获取 MCP 工具定义(供 Agent 使用)
|
|
||||||
* 目前返回占位工具定义,实际 MCP 协议通信需通过主进程实现
|
/** 启动所有已启用的 MCP 服务器 */
|
||||||
*/
|
export async function startAllMCPServers(): Promise<void> {
|
||||||
export async function getMCPToolDefinitions(): Promise<ToolDefinition[]> {
|
const bridge = (window as any).metonaDesktop;
|
||||||
const enabled = await getEnabledMCPServers();
|
if (!bridge?.mcp) return;
|
||||||
// MCP 工具通过主进程的 MCP bridge 动态注册
|
|
||||||
// 这里返回空数组,工具定义由 MCP bridge 在 IPC 层注入
|
const configs = await getEnabledMCPServers();
|
||||||
if (enabled.length > 0) {
|
for (const config of configs) {
|
||||||
logInfo(`MCP: ${enabled.length} 个服务器已启用`, enabled.map(s => s.name).join(', '));
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 工具定义(供 Agent 使用)───
|
||||||
|
|
||||||
|
/** 从主进程获取所有 MCP 工具并转换为 ToolDefinition 格式 */
|
||||||
|
export async function getMCPToolDefinitions(): Promise<ToolDefinition[]> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -595,7 +595,13 @@ export async function refreshMCPTools(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function executeTool(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
|
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}`);
|
logError(`工具未启用: ${toolName}`);
|
||||||
return { success: false, error: `工具 ${toolName} 未启用` };
|
return { success: false, error: `工具 ${toolName} 未启用` };
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+9
@@ -240,6 +240,15 @@ export interface MetonaDesktopAPI {
|
|||||||
/** 终止当前 AI 命令 */
|
/** 终止当前 AI 命令 */
|
||||||
cmdKill: () => Promise<{ killed: boolean }>;
|
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 {
|
export interface AppInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user