工程化(从零到一): - 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证 - 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁) - 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync - 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies 安全加固: - PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额) - ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道) - Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面 - web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级) 缺陷修复(测试驱动发现): - mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试 - 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢 - 百度复合类名重复收录:class="result c-container" 被双重匹配 测试补齐(113 → 194 用例): - 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器 - 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批 体验升级: - OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡) - SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护) - MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销) - MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
538 lines
16 KiB
TypeScript
538 lines
16 KiB
TypeScript
/**
|
||
* MCP Manager Service — MCP Server 生命周期管理
|
||
*
|
||
* 负责:
|
||
* 1. MCP Server 的连接/断开/重连
|
||
* 2. 工具发现与动态注册到 ToolRegistry
|
||
* 3. Server 状态管理与健康检查
|
||
*
|
||
* 使用 @modelcontextprotocol/sdk 官方库。
|
||
*
|
||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第七章
|
||
* @see standard/开发规范.md — 优先使用第三方成熟库
|
||
*/
|
||
|
||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||
// v0.4.1: streamable HTTP 传输(MCP 当前主流远程传输方式)
|
||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||
import { nanoid } from 'nanoid';
|
||
import type Database from 'better-sqlite3';
|
||
import log from 'electron-log';
|
||
import type { ToolRegistry } from '../harness/tools/registry';
|
||
import type { IMetonaTool, ToolExecutionContext } from '../harness/types/metona-tool';
|
||
import type { MetonaToolDef } from '../harness/types';
|
||
import { MetonaToolCategory, MetonaRiskLevel } from '../harness/types';
|
||
|
||
// v0.3.0 修复: 安全解析 JSON args,防止数据库中存储了非法 JSON 导致初始化崩溃
|
||
function safeParseArgs(raw: string): string[] {
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
log.warn(`MCP args parse failed, using empty array: ${raw.slice(0, 100)}`);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// #6 修复: MCP stdio 命令安全校验
|
||
/**
|
||
* 允许的 MCP Server 启动命令白名单。
|
||
* 仅允许常见的 MCP Server 运行时,防止任意命令执行。
|
||
*/
|
||
const ALLOWED_MCP_COMMANDS = new Set([
|
||
'npx',
|
||
'node',
|
||
'npm',
|
||
'python',
|
||
'python3',
|
||
'uv',
|
||
'uvx',
|
||
'bun',
|
||
'deno',
|
||
]);
|
||
|
||
/**
|
||
* #6 修复: 校验 MCP Server 的 command 和 args,防止命令注入
|
||
*
|
||
* 1. 命令白名单:只允许已知的运行时命令
|
||
* 2. 参数注入检测:拒绝包含 shell 元字符的参数
|
||
*
|
||
* @param command MCP Server 启动命令
|
||
* @param args MCP Server 启动参数
|
||
* @throws 如果命令不在白名单或参数包含 shell 元字符
|
||
*/
|
||
function validateMcpCommand(command: string, args: string[]): void {
|
||
// 提取命令 basename(处理 /usr/bin/node、C:\node\node.exe 等路径)
|
||
const baseCmd =
|
||
command
|
||
.split(/[\\/]/)
|
||
.pop()
|
||
?.replace(/\.exe$/i, '') ?? command;
|
||
|
||
if (!ALLOWED_MCP_COMMANDS.has(baseCmd)) {
|
||
throw new Error(
|
||
`MCP command "${baseCmd}" is not in the allowed list: ${[...ALLOWED_MCP_COMMANDS].join(', ')}. ` +
|
||
`For security reasons, only standard MCP runtimes are permitted.`,
|
||
);
|
||
}
|
||
|
||
// 审查修复: 移除 {}() 字符 — StdioClientTransport 用 spawn(不经 shell),
|
||
// 这些字符无注入风险,但 MCP Server 的 args 常含 JSON 配置(如 --config {"port":3000})会被误拒。
|
||
// 保留 ; & | ` $ < > 换行 等高危字符。
|
||
const shellMetacharPattern = /[;&|`$<>\n\r]/;
|
||
for (const arg of args) {
|
||
if (shellMetacharPattern.test(arg)) {
|
||
throw new Error(
|
||
`MCP command argument contains shell metacharacters and was rejected: ${arg.slice(0, 100)}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* #6 修复 + 审查修复: 构建安全的子进程环境变量
|
||
*
|
||
* 审查修复: 原白名单方案过于激进,剥离了 MCP Server 运行所需的 npm_config_*、代理变量等,
|
||
* 导致 MCP Server 无法启动。改为黑名单方案:剔除包含敏感后缀的变量,保留其余。
|
||
*
|
||
* 注意: GITHUB_TOKEN / SLACK_BOT_TOKEN 等含 _TOKEN 后缀的变量也会被过滤。
|
||
* 如果 MCP Server 需要这些凭证,应通过 MCP Server 配置文件传递,而非环境变量。
|
||
*/
|
||
function buildSafeEnv(): Record<string, string> {
|
||
// 敏感变量后缀黑名单 — 匹配这些后缀的变量不会被传递给子进程
|
||
const SENSITIVE_SUFFIXES = [
|
||
'_API_KEY',
|
||
'_TOKEN',
|
||
'_SECRET',
|
||
'_PASSWORD',
|
||
'_PASSWD',
|
||
'_CREDENTIAL',
|
||
'_CREDENTIALS',
|
||
'_PRIVATE_KEY',
|
||
];
|
||
// 敏感变量名黑名单(精确匹配)
|
||
const SENSITIVE_KEYS = new Set([
|
||
'DEEPSEEK_API_KEY',
|
||
'AGNES_API_KEY',
|
||
'MIMO_API_KEY',
|
||
'GITEA_PASSWORD',
|
||
'DATABASE_PASSWORD',
|
||
]);
|
||
|
||
const env: Record<string, string> = {};
|
||
for (const [key, val] of Object.entries(process.env)) {
|
||
if (!val) continue;
|
||
// 跳过敏感变量名
|
||
if (SENSITIVE_KEYS.has(key)) continue;
|
||
// 跳过敏感后缀变量
|
||
if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue;
|
||
env[key] = val;
|
||
}
|
||
return env;
|
||
}
|
||
|
||
// ===== 类型定义 =====
|
||
|
||
export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
|
||
|
||
export interface MCPServerConfig {
|
||
id: string;
|
||
name: string;
|
||
/** v0.4.1: 新增 'streamable-http'(MCP 当前主流远程传输);'sse' 保留向后兼容 */
|
||
transport: 'stdio' | 'sse' | 'streamable-http';
|
||
command?: string;
|
||
args?: string[];
|
||
url?: string;
|
||
enabled: boolean;
|
||
}
|
||
|
||
export interface MCPServerState {
|
||
config: MCPServerConfig;
|
||
status: MCPServerStatus;
|
||
client: Client | null;
|
||
tools: Tool[];
|
||
error?: string;
|
||
connectedAt?: number;
|
||
}
|
||
|
||
// ===== MCP Tool Adapter =====
|
||
|
||
/**
|
||
* 将 MCP Tool 适配为 IMetonaTool 接口
|
||
*/
|
||
class MCPToolAdapter implements IMetonaTool {
|
||
readonly definition: MetonaToolDef;
|
||
|
||
constructor(
|
||
private mcpTool: Tool,
|
||
private client: Client,
|
||
private serverName: string,
|
||
) {
|
||
this.definition = {
|
||
name: `mcp_${serverName}_${mcpTool.name}`,
|
||
description: mcpTool.description ?? `MCP tool from ${serverName}`,
|
||
parameters: this.convertSchema(mcpTool.inputSchema),
|
||
category: MetonaToolCategory.MCP,
|
||
riskLevel: MetonaRiskLevel.MEDIUM,
|
||
requiresPermission: false,
|
||
timeoutMs: 30_000,
|
||
};
|
||
}
|
||
|
||
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
||
const result = await this.client.callTool({
|
||
name: this.mcpTool.name,
|
||
arguments: args,
|
||
});
|
||
return result.content;
|
||
}
|
||
|
||
/**
|
||
* 将 MCP JSON Schema 转换为 MetonaToolParams
|
||
*/
|
||
private convertSchema(schema: Record<string, unknown>): MetonaToolDef['parameters'] {
|
||
const properties: Record<
|
||
string,
|
||
{ type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string }
|
||
> = {};
|
||
const schemaProps = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||
|
||
for (const [key, prop] of Object.entries(schemaProps)) {
|
||
properties[key] = {
|
||
type: (prop.type as 'string' | 'number' | 'boolean' | 'object' | 'array') ?? 'string',
|
||
description: (prop.description as string) ?? '',
|
||
};
|
||
}
|
||
|
||
return {
|
||
type: 'object',
|
||
properties,
|
||
required: schema.required as string[] | undefined,
|
||
};
|
||
}
|
||
}
|
||
|
||
// ===== MCP Manager =====
|
||
|
||
export class MCPManager {
|
||
private servers = new Map<string, MCPServerState>();
|
||
|
||
constructor(
|
||
private getDB: () => Database.Database,
|
||
private toolRegistry: ToolRegistry,
|
||
) {}
|
||
|
||
/**
|
||
* 初始化:从数据库加载已启用的 MCP Server 并连接
|
||
*
|
||
* P1-11: 等待所有连接完成(或 30s 超时)后才返回,保证调用方广播 tools:ready
|
||
* 时 MCP 工具已实际注册完成。原实现 connectServer 不 await,工具就绪广播
|
||
* 早于工具注册,存在"广播后一段时间内 MCP 工具仍不可用"的窗口。
|
||
*
|
||
* 单个 server 连接超时(30s)用于兜底:stdio server 挂起时不阻塞整体就绪,
|
||
* 超时的 server 会显示 error 状态,用户可在设置面板查看原因。
|
||
*/
|
||
async initialize(): Promise<void> {
|
||
const db = this.getDB();
|
||
const rows = db
|
||
.prepare(
|
||
`
|
||
SELECT * FROM mcp_servers WHERE enabled = 1
|
||
`,
|
||
)
|
||
.all() as Array<{
|
||
id: string;
|
||
name: string;
|
||
transport: string;
|
||
command: string | null;
|
||
args: string | null;
|
||
url: string | null;
|
||
}>;
|
||
|
||
const connectWithTimeout = (config: MCPServerConfig): Promise<unknown> =>
|
||
Promise.race([
|
||
this.connectServer(config),
|
||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 30_000)),
|
||
]);
|
||
|
||
const settled = await Promise.allSettled(
|
||
rows.map((row) => {
|
||
const config: MCPServerConfig = {
|
||
id: row.id,
|
||
name: row.name,
|
||
// v0.4.1: 支持三种传输方式(stdio / sse / streamable-http)
|
||
transport: row.transport as MCPServerConfig['transport'],
|
||
command: row.command ?? undefined,
|
||
args: row.args ? safeParseArgs(row.args) : undefined,
|
||
url: row.url ?? undefined,
|
||
enabled: true,
|
||
};
|
||
return connectWithTimeout(config).catch((err) => {
|
||
log.warn(`MCP server "${config.name}" auto-connect failed: ${err}`);
|
||
});
|
||
}),
|
||
);
|
||
|
||
const failed = settled.filter((s) => s.status === 'rejected').length;
|
||
log.info(`MCP Manager initialized: ${rows.length} server(s) configured (${failed} failed)`);
|
||
}
|
||
|
||
/**
|
||
* 连接 MCP Server
|
||
*/
|
||
async connectServer(config: MCPServerConfig): Promise<void> {
|
||
const { name } = config;
|
||
|
||
// 断开已有连接
|
||
if (this.servers.has(name)) {
|
||
await this.disconnectServer(name);
|
||
}
|
||
|
||
this.servers.set(name, {
|
||
config,
|
||
status: 'connecting',
|
||
client: null,
|
||
tools: [],
|
||
});
|
||
|
||
try {
|
||
let transport;
|
||
|
||
if (config.transport === 'stdio' && config.command) {
|
||
// stdio 模式
|
||
const args = config.args ?? [];
|
||
// #6 修复: 命令白名单 + 参数元字符检测,防止命令注入
|
||
validateMcpCommand(config.command, args);
|
||
transport = new StdioClientTransport({
|
||
command: config.command,
|
||
args,
|
||
// #6 修复: 不透传完整 process.env,仅保留 MCP Server 运行所需的最小环境变量
|
||
env: buildSafeEnv(),
|
||
});
|
||
} else if (config.transport === 'sse' && config.url) {
|
||
// SSE 模式(远程 HTTP,旧式传输,保留向后兼容)
|
||
transport = new SSEClientTransport(new URL(config.url));
|
||
} else if (config.transport === 'streamable-http' && config.url) {
|
||
// v0.4.1: streamable HTTP 模式(MCP 当前主流远程传输)
|
||
transport = new StreamableHTTPClientTransport(new URL(config.url));
|
||
} else {
|
||
throw new Error(
|
||
`Unsupported transport "${config.transport}". ` +
|
||
`'stdio' requires 'command', 'sse'/'streamable-http' requires 'url'.`,
|
||
);
|
||
}
|
||
|
||
const client = new Client(
|
||
{ name: 'metona-ai-desktop', version: '1.0.0' },
|
||
{ capabilities: {} },
|
||
);
|
||
|
||
await client.connect(transport);
|
||
|
||
// 发现工具
|
||
const toolsResult = await client.listTools();
|
||
const tools = toolsResult.tools ?? [];
|
||
|
||
// 注册到 ToolRegistry
|
||
for (const tool of tools) {
|
||
const adapter = new MCPToolAdapter(tool, client, name);
|
||
this.toolRegistry.registerMCP(name, adapter);
|
||
}
|
||
|
||
// 更新状态
|
||
const state = this.servers.get(name)!;
|
||
state.status = 'connected';
|
||
state.client = client;
|
||
state.tools = tools;
|
||
state.connectedAt = Date.now();
|
||
state.error = undefined;
|
||
|
||
// 更新数据库
|
||
const db = this.getDB();
|
||
db.prepare(
|
||
`
|
||
UPDATE mcp_servers SET last_connected = ?, error_message = NULL WHERE name = ?
|
||
`,
|
||
).run(Date.now(), name);
|
||
|
||
log.info(`MCP server "${name}" connected: ${tools.length} tool(s)`);
|
||
} catch (error) {
|
||
const state = this.servers.get(name);
|
||
if (state) {
|
||
state.status = 'error';
|
||
state.error = (error as Error).message;
|
||
}
|
||
|
||
// 更新数据库
|
||
const db = this.getDB();
|
||
db.prepare(
|
||
`
|
||
UPDATE mcp_servers SET error_message = ? WHERE name = ?
|
||
`,
|
||
).run((error as Error).message, name);
|
||
|
||
log.error(`MCP server "${name}" connection failed:`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 断开 MCP Server
|
||
*/
|
||
async disconnectServer(name: string): Promise<void> {
|
||
const state = this.servers.get(name);
|
||
if (!state) return;
|
||
|
||
// 从 ToolRegistry 注销
|
||
this.toolRegistry.unregisterMCPTools(name);
|
||
|
||
// 关闭客户端
|
||
if (state.client) {
|
||
try {
|
||
await state.client.close();
|
||
} catch {
|
||
// 忽略关闭错误
|
||
}
|
||
}
|
||
|
||
state.status = 'disconnected';
|
||
state.client = null;
|
||
state.tools = [];
|
||
|
||
log.info(`MCP server "${name}" disconnected`);
|
||
}
|
||
|
||
/**
|
||
* 切换 Server 启用/禁用
|
||
*/
|
||
async toggleServer(name: string, enabled: boolean): Promise<void> {
|
||
const db = this.getDB();
|
||
db.prepare(
|
||
`
|
||
UPDATE mcp_servers SET enabled = ?, updated_at = ? WHERE name = ?
|
||
`,
|
||
).run(enabled ? 1 : 0, Date.now(), name);
|
||
|
||
if (enabled) {
|
||
const row = db.prepare('SELECT * FROM mcp_servers WHERE name = ?').get(name) as
|
||
| {
|
||
id: string;
|
||
name: string;
|
||
transport: string;
|
||
command: string | null;
|
||
args: string | null;
|
||
url: string | null;
|
||
}
|
||
| undefined;
|
||
if (row) {
|
||
await this.connectServer({
|
||
id: row.id,
|
||
name: row.name,
|
||
transport: row.transport as MCPServerConfig['transport'],
|
||
command: row.command ?? undefined,
|
||
args: row.args ? safeParseArgs(row.args) : undefined,
|
||
url: row.url ?? undefined,
|
||
enabled: true,
|
||
});
|
||
}
|
||
} else {
|
||
await this.disconnectServer(name);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 添加新的 MCP Server
|
||
*
|
||
* v0.4.1: 校验 transport 与对应字段匹配(stdio→command,sse/streamable-http→url)
|
||
*/
|
||
async addServer(config: Omit<MCPServerConfig, 'id'>): Promise<void> {
|
||
const db = this.getDB();
|
||
const id = `mcp_${nanoid(8)}`;
|
||
|
||
// 校验传输方式与必填字段
|
||
if (config.transport === 'stdio' && !config.command) {
|
||
throw new Error('stdio transport requires "command"');
|
||
}
|
||
if ((config.transport === 'sse' || config.transport === 'streamable-http') && !config.url) {
|
||
throw new Error(`${config.transport} transport requires "url"`);
|
||
}
|
||
|
||
db.prepare(
|
||
`
|
||
INSERT INTO mcp_servers (id, name, transport, command, args, url, enabled)
|
||
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||
`,
|
||
).run(
|
||
id,
|
||
config.name,
|
||
config.transport,
|
||
config.command ?? null,
|
||
config.args ? JSON.stringify(config.args) : null,
|
||
config.url ?? null,
|
||
);
|
||
|
||
if (config.enabled !== false) {
|
||
await this.connectServer({ ...config, id, enabled: true });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 移除 MCP Server
|
||
*/
|
||
async removeServer(name: string): Promise<void> {
|
||
await this.disconnectServer(name);
|
||
const db = this.getDB();
|
||
db.prepare('DELETE FROM mcp_servers WHERE name = ?').run(name);
|
||
this.servers.delete(name);
|
||
log.info(`MCP server "${name}" removed`);
|
||
}
|
||
|
||
/**
|
||
* 获取所有 Server 状态
|
||
*/
|
||
getServerStates(): Array<{
|
||
name: string;
|
||
status: MCPServerStatus;
|
||
toolCount: number;
|
||
error?: string;
|
||
}> {
|
||
return Array.from(this.servers.values()).map((s) => ({
|
||
name: s.config.name,
|
||
status: s.status,
|
||
toolCount: s.tools.length,
|
||
error: s.error,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 获取单个 Server 状态
|
||
*/
|
||
getServerState(name: string): {
|
||
name: string;
|
||
status: MCPServerStatus;
|
||
toolCount: number;
|
||
error?: string;
|
||
} | null {
|
||
const state = this.servers.get(name);
|
||
if (!state) return null;
|
||
return {
|
||
name: state.config.name,
|
||
status: state.status,
|
||
toolCount: state.tools.length,
|
||
error: state.error,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 关闭所有连接
|
||
*/
|
||
async shutdown(): Promise<void> {
|
||
const names = Array.from(this.servers.keys());
|
||
await Promise.allSettled(names.map((n) => this.disconnectServer(n)));
|
||
log.info('MCP Manager shut down');
|
||
}
|
||
}
|