Files
qiming/qimingclaw/crates/agent-electron-client/src/main/services/packages/mcp.ts
2026-06-10 09:24:02 +08:00

1509 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MCP Proxy Manager (Electron)
*
* 使用 qiming-mcp-stdio-proxy 纯 Node.js stdio 聚合代理(应用内集成)。
* Agent 引擎直接 spawn proxy 进程stdio 直通),无需 HTTP 中间层。
*
* proxy 同时支持两种上游传输:
* - stdio: spawn 子进程(临时 server
* - bridge: 连接 PersistentMcpBridge持久化 server如 chrome-devtools-mcp
*
* Electron 侧负责:
* - 从应用内集成资源加载 qiming-mcp-stdio-proxy
* - 管理 mcpServers 配置(持久化到 SQLite
* - 管理 PersistentMcpBridge 生命周期
* - 提供 getAgentMcpConfig() 供 Agent 引擎初始化时注入
*/
import * as path from "path";
import * as fs from "fs";
import * as os from "os";
import * as crypto from "crypto";
import log from "electron-log";
import { app } from "electron";
import { t } from "../i18n";
import { getPerfLogger } from "../../bootstrap/logConfig";
import {
getAppEnv,
getUvBinPath,
getNodeBinPath,
getNodeBinPathWithFallback,
} from "../system/dependencies";
import {
getAppPaths,
getBundledMcpProxyDir,
isInstalledLocally,
} from "./packageLocator";
import { resolveNpmPackageEntry } from "../utils/spawnNoWindow";
import { APP_DATA_DIR_NAME } from "../constants";
import { isWindows } from "../system/shellEnv";
import { persistentMcpBridge } from "./persistentMcpBridge";
import { applyDigitalEmployeeMcpExecutionPolicy } from "../digitalEmployee/skillExecutionPolicy";
type PerfValue = string | number | boolean | null | undefined;
function formatPerfValue(value: PerfValue): string {
if (value === null) return "null";
if (typeof value === "string") {
return /\s/.test(value) ? JSON.stringify(value) : value;
}
return String(value);
}
function formatPerfFields(fields: Record<string, PerfValue>): string {
const entries = Object.entries(fields).filter(
([, value]) => value !== undefined,
);
if (entries.length === 0) return "";
return ` ${entries.map(([key, value]) => `${key}=${formatPerfValue(value)}`).join(" ")}`;
}
function logMcpPerfSummary(
event: string,
fields: Record<string, PerfValue>,
level: "info" | "warn" = "info",
): void {
const suffix = formatPerfFields(fields);
const mainMessage = `[McpProxy][perf] ${event}${suffix}`;
const perfMessage = `[PERF] mcp.${event}${suffix}`;
if (level === "warn") {
log.warn(mainMessage);
getPerfLogger().warn(perfMessage);
return;
}
log.info(mainMessage);
getPerfLogger().info(perfMessage);
}
// ========== Shared Helpers ==========
/**
* Returns the directory containing the app-internal `uv` binary.
* Priority: bundled resources/uv/bin → ~/.qimingclaw/bin
* Returns empty string if uv not found anywhere.
*/
export function getUvBinDir(): string {
const p = getUvBinPath();
if (p && fs.existsSync(p)) return path.dirname(p);
const appBin = path.join(app.getPath("home"), APP_DATA_DIR_NAME, "bin");
const uvName = isWindows() ? "uv.exe" : "uv";
if (fs.existsSync(path.join(appBin, uvName))) return appBin;
return "";
}
/**
* Resolves `uvx`/`uv` commands to app-internal binaries.
* - If `uvx`: always rewrite to `<uvBinDir>/uv tool run ...`
* (uv >= 0.10 dropped the uvx multicall — invoking the binary as `uvx` no longer
* behaves as `uv tool run`; it shows the full `uv` CLI instead)
* - If `uv`: resolve to `<uvBinDir>/uv`
*/
export function resolveUvCommand(
command: string,
args: string[],
uvBinDir?: string,
): { command: string; args: string[] } {
if (typeof command !== "string") return { command, args };
const dir = uvBinDir ?? getUvBinDir();
if (!dir) return { command, args };
const base = path.basename(command).replace(/\.(exe)?$/i, "");
if (base === "uvx") {
// Always use `uv tool run` — the uvx multicall binary is broken in uv >= 0.10
const uvName = isWindows() ? "uv.exe" : "uv";
const uvPath = path.join(dir, uvName);
if (fs.existsSync(uvPath)) {
return { command: uvPath, args: ["tool", "run", ...args] };
}
return { command, args };
}
if (base === "uv") {
const uvName = isWindows() ? "uv.exe" : "uv";
const uvPath = path.join(dir, uvName);
if (fs.existsSync(uvPath)) {
return { command: uvPath, args };
}
}
return { command, args };
}
/**
* Resolve uvx/uv commands inside a `mcp-proxy convert --config '{...}'` bridge entry.
* Only rewrites inner command paths (uvx → uv tool run); does NOT inject env.
* Kept for backward compatibility with context_servers that may still use bridge entries.
*/
export function resolveBridgeEntry(
command: string,
args: string[],
uvBinDir?: string,
): { command: string; args: string[] } {
const dir = uvBinDir ?? getUvBinDir();
const idx = args.indexOf("--config");
if (idx < 0 || idx + 1 >= args.length) return { command, args };
const configStr = args[idx + 1];
if (typeof configStr !== "string") return { command, args };
let parsed: {
mcpServers?: Record<
string,
{ command?: string; args?: string[]; env?: Record<string, string> }
>;
};
try {
parsed = JSON.parse(configStr);
} catch {
return { command, args };
}
const inner = parsed?.mcpServers;
if (!inner || typeof inner !== "object") return { command, args };
let changed = false;
for (const [, srv] of Object.entries(inner)) {
if (!srv || typeof srv.command !== "string") continue;
const resolved = resolveUvCommand(srv.command, srv.args || [], dir);
if (resolved.command !== srv.command) {
srv.command = resolved.command;
srv.args = resolved.args;
changed = true;
}
}
if (!changed) return { command, args };
const newConfigStr = JSON.stringify(parsed);
const newArgs = [...args];
newArgs[idx + 1] = newConfigStr;
return { command, args: newArgs };
}
/**
* Extract real MCP servers from a bridge entry (mcp-proxy convert --config '{...}').
*
* Bridge entries are a legacy format where the command is "mcp-proxy" and the
* actual MCP server config is embedded in the --config JSON argument. This function
* extracts the inner server configs and resolves uvx/uv commands to app-internal paths.
*
* @param command - The command string (e.g., "mcp-proxy" or "/path/to/mcp-proxy")
* @param args - Arguments array, should contain "--config" followed by JSON string
* @param env - Optional external environment variables to merge with server-specific env.
* Note: Server-specific env takes precedence over external env for the same keys.
* @param uvBinDir - Optional uv binary directory override for resolving uvx commands
* @returns Extracted MCP servers config, or null if not a valid bridge entry
*
* @example
* ```typescript
* const result = extractRealMcpServers(
* 'mcp-proxy',
* ['convert', '--config', '{"mcpServers":{"fetch":{"command":"uvx","args":["mcp-server-fetch"]}}}'],
* { MY_VAR: 'value' }
* );
* // Returns: { fetch: { command: '/path/to/uv', args: ['tool', 'run', 'mcp-server-fetch'], env: { MY_VAR: 'value' } } }
* ```
*/
export function extractRealMcpServers(
command: string,
args: string[],
env?: Record<string, string>,
uvBinDir?: string,
): Record<string, McpServerEntry> | null {
// Must be a bridge entry
if (command !== "mcp-proxy" && path.basename(command) !== "mcp-proxy")
return null;
const dir = uvBinDir ?? getUvBinDir();
const idx = args.indexOf("--config");
if (idx < 0 || idx + 1 >= args.length) return null;
const configStr = args[idx + 1];
if (typeof configStr !== "string") return null;
let parsed: {
mcpServers?: Record<
string,
{
command?: string;
args?: string[];
env?: Record<string, string>;
url?: string;
transport?: string;
headers?: Record<string, string>;
authToken?: string;
}
>;
};
try {
parsed = JSON.parse(configStr);
} catch {
return null;
}
const inner = parsed?.mcpServers;
if (!inner || typeof inner !== "object") return null;
// 解析 --allow-tools / --deny-tools / -H从 convert 模式的参数中提取)
let allowTools: string[] | undefined;
let denyTools: string[] | undefined;
const extraHeaders: Record<string, string> = {};
for (let i = 0; i < args.length; i++) {
if (args[i] === "--allow-tools" && i + 1 < args.length) {
allowTools = args[i + 1]
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else if (args[i] === "--deny-tools" && i + 1 < args.length) {
denyTools = args[i + 1]
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else if (
(args[i] === "-H" || args[i] === "--header") &&
i + 1 < args.length
) {
// Parse header format: "Key=Value" or "Key: Value"
const headerArg = args[i + 1];
const separatorIndex = headerArg.indexOf("=");
if (separatorIndex > 0) {
const key = headerArg.substring(0, separatorIndex).trim();
const value = headerArg.substring(separatorIndex + 1).trim();
extraHeaders[key] = value;
} else {
// Also support colon format: "Key: Value"
const colonIndex = headerArg.indexOf(":");
if (colonIndex > 0) {
const key = headerArg.substring(0, colonIndex).trim();
const value = headerArg.substring(colonIndex + 1).trim();
extraHeaders[key] = value;
}
}
}
}
// Build base environment variables for child MCP servers
// Use full getAppEnv() to ensure Windows system variables (SystemRoot, COMSPEC, etc.)
// and app-internal tool paths (NODE_PATH, NPM_CONFIG_*, UV_*) are available
const appEnv = getAppEnv();
const result: Record<string, McpServerEntry> = {};
for (const [name, srv] of Object.entries(inner)) {
if (!srv) continue;
// URL-based entry (SSE / Streamable HTTP) — pass through as RemoteMcpServerEntry
// Transport detection is handled by the proxy via detectProtocol() at runtime
if (typeof srv.url === "string") {
const transport: "sse" | "streamable-http" | undefined =
srv.transport === "sse"
? "sse"
: srv.transport === "streamable-http"
? "streamable-http"
: undefined;
// 合并 headers srv.headers < extraHeadersextraHeaders 优先, 可覆盖)
const mergedHeaders = {
...srv.headers,
...extraHeaders,
};
result[name] = {
url: srv.url,
...(transport ? { transport } : {}),
...(Object.keys(mergedHeaders).length > 0
? { headers: mergedHeaders }
: {}),
...(srv.authToken ? { authToken: srv.authToken } : {}),
// 继承 bridge 入口的 allowTools/denyTools 限制
...(allowTools ? { allowTools } : {}),
...(denyTools ? { denyTools } : {}),
};
continue;
}
// stdio entry — resolve command/args/env
if (typeof srv.command !== "string") continue;
const resolved = resolveUvCommand(srv.command, srv.args || [], dir);
// Env merge: appEnv as foundation, external env overrides, server-specific env takes precedence
result[name] = {
command: resolved.command,
args: resolved.args,
env: { ...appEnv, ...env, ...(srv.env || {}) },
...(allowTools ? { allowTools } : {}),
...(denyTools ? { denyTools } : {}),
};
}
return Object.keys(result).length > 0 ? result : null;
}
/**
* Apply resolveUvCommand + inject full app env for all server entries.
* Filters out mcp-proxy bridge entries.
*
* Uses getAppEnv() to ensure all necessary environment variables are available,
* including Windows system variables (SystemRoot, COMSPEC, TEMP, etc.)
* and app-internal tool paths (NODE_PATH, NPM_CONFIG_*, UV_*).
*/
export function resolveServersConfig(
servers: Record<string, McpServerEntry>,
): Record<string, McpServerEntry> {
const appEnv = getAppEnv();
const dir = getUvBinDir();
const result: Record<string, McpServerEntry> = {};
for (const [name, entry] of Object.entries(servers)) {
// 远程类型直接透传,无需 env / uv 解析
if (isRemoteEntry(entry)) {
result[name] = entry;
continue;
}
if (entry.command === "mcp-proxy") continue;
if (typeof entry.command !== "string") continue;
const resolved = resolveUvCommand(entry.command, entry.args || [], dir);
result[name] = {
command: resolved.command,
args: resolved.args,
env: { ...appEnv, ...(entry.env || {}) },
...(entry.persistent ? { persistent: true } : {}),
...(entry.allowTools ? { allowTools: entry.allowTools } : {}),
...(entry.denyTools ? { denyTools: entry.denyTools } : {}),
};
}
return result;
}
// ========== Types ==========
const CHROME_DEVTOOLS_MCP_NAME = "chrome-devtools";
const CHROME_DEVTOOLS_MCP_PACKAGE = "chrome-devtools-mcp";
function findLocalChromeDevtoolsMcpBin(): string | null {
const binName = isWindows()
? `${CHROME_DEVTOOLS_MCP_PACKAGE}.cmd`
: CHROME_DEVTOOLS_MCP_PACKAGE;
const candidates = [
path.join(
os.homedir(),
APP_DATA_DIR_NAME,
"node_modules",
".bin",
binName,
),
path.join(process.cwd(), "node_modules", ".bin", binName),
];
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
}
function shouldEnableDefaultChromeDevtoolsMcp(): boolean {
return (
process.env.QIMINGCLAW_ENABLE_CHROME_DEVTOOLS_MCP === "1" ||
!!findLocalChromeDevtoolsMcpBin()
);
}
function getDefaultMcpServers(): Record<string, McpServerEntry> {
if (!shouldEnableDefaultChromeDevtoolsMcp()) return {};
const localBin = findLocalChromeDevtoolsMcpBin();
return {
[CHROME_DEVTOOLS_MCP_NAME]: localBin
? {
command: localBin,
args: [],
persistent: true,
}
: {
command: "npx",
args: ["-y", `${CHROME_DEVTOOLS_MCP_PACKAGE}@latest`],
persistent: true,
},
};
}
function isLegacyDefaultChromeDevtoolsEntry(
name: string,
entry: McpServerEntry,
): boolean {
return (
name === CHROME_DEVTOOLS_MCP_NAME &&
!isRemoteEntry(entry) &&
entry.command === "npx" &&
Array.isArray(entry.args) &&
entry.args.length === 2 &&
entry.args[0] === "-y" &&
entry.args[1] === `${CHROME_DEVTOOLS_MCP_PACKAGE}@latest`
);
}
/** 默认 mcpServers 配置 */
export const DEFAULT_MCP_PROXY_CONFIG: McpServersConfig = {
mcpServers: getDefaultMcpServers(),
};
/**
* 为 MCP 服务器配置注入完整应用环境变量
*/
function injectBaseEnvToMcpServers(
servers: Record<string, McpServerEntry>,
): Record<string, McpServerEntry> {
const appEnv = getAppEnv();
const result: Record<string, McpServerEntry> = {};
for (const [name, entry] of Object.entries(servers)) {
// 远程类型无需 env 注入
if (isRemoteEntry(entry)) {
result[name] = entry;
continue;
}
result[name] = {
...entry,
env: { ...appEnv, ...(entry.env || {}) },
};
}
return result;
}
/** stdio 类型 MCP Server 配置 */
export interface StdioMcpServerEntry {
command: string;
args: string[];
env?: Record<string, string>;
/** 标记为持久化 server生命周期由 PersistentMcpBridge 管理,而非跟随 ACP session */
persistent?: boolean;
/** 工具白名单(只暴露指定工具) */
allowTools?: string[];
/** 工具黑名单(排除指定工具) */
denyTools?: string[];
}
/** 远程类型 MCP Server 配置 (Streamable HTTP / SSE) */
export interface RemoteMcpServerEntry {
url: string;
transport?: "streamable-http" | "sse";
headers?: Record<string, string>;
authToken?: string;
/** 工具白名单(只暴露指定工具) */
allowTools?: string[];
/** 工具黑名单(排除指定工具) */
denyTools?: string[];
}
/** 单个 MCP Server 的配置mcpServers 格式) */
export type McpServerEntry = StdioMcpServerEntry | RemoteMcpServerEntry;
/** 判断是否为远程类型(有 url 字段) */
export function isRemoteEntry(
entry: McpServerEntry,
): entry is RemoteMcpServerEntry {
return "url" in entry;
}
export function pruneDisabledDefaultMcpServers(
servers: Record<string, McpServerEntry> = {},
): Record<string, McpServerEntry> {
if (shouldEnableDefaultChromeDevtoolsMcp()) return servers;
const result: Record<string, McpServerEntry> = {};
for (const [name, entry] of Object.entries(servers)) {
if (isLegacyDefaultChromeDevtoolsEntry(name, entry)) {
log.info(
"[McpProxy] Default chrome-devtools MCP disabled (not installed locally; set QIMINGCLAW_ENABLE_CHROME_DEVTOOLS_MCP=1 to force npx)",
);
continue;
}
result[name] = entry;
}
return result;
}
/** mcpServers 配置(传给 qiming-mcp-stdio-proxy 的 JSON */
export interface McpServersConfig {
mcpServers: Record<string, McpServerEntry>;
/** 工具白名单(只允许指定的工具) */
allowTools?: string[];
/** 工具黑名单(排除指定的工具) */
denyTools?: string[];
}
/** MCP Proxy 运行状态语义变更running = "binary 可用" 而非 "进程在运行" */
export interface McpProxyStatus {
running: boolean;
serverCount?: number;
serverNames?: string[];
error?: string;
}
// ========== MCP Proxy Manager ==========
class McpProxyManager {
private config: McpServersConfig = JSON.parse(
JSON.stringify(DEFAULT_MCP_PROXY_CONFIG),
);
/** Cached script path — set by start(), avoids fs I/O on every getStatus() poll */
private cachedScriptPath: string | null = null;
/** Track whether PersistentMcpBridge has been started (lazy initialization) */
private bridgeStarted = false;
private lastError: string | null = null;
// --- Proxy log tail ---
private logTailTimer: ReturnType<typeof setInterval> | null = null;
private logTailOffset = 0;
private logTailPath: string | null = null;
/** Base log file path (without date suffix) for computing dated file names */
private logTailBasePath: string | null = null;
/**
* Compute today's dated log file path from base path.
* e.g. /logs/mcp-proxy.log → /logs/mcp-proxy-2026-03-09.log
*/
private getDatedLogPath(basePath: string): string {
const d = new Date();
const pad2 = (n: number) => String(n).padStart(2, "0");
const dateStr = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
const ext = path.extname(basePath);
const base = basePath.slice(0, basePath.length - ext.length);
return `${base}-${dateStr}${ext}`;
}
/**
* Switch the watcher to today's dated log file if the date has changed.
*/
private switchLogTailFile(): void {
if (!this.logTailBasePath) return;
const datedPath = this.getDatedLogPath(this.logTailBasePath);
if (datedPath === this.logTailPath) return;
// Date changed or first start — switch to new file
if (this.logTailPath) {
fs.unwatchFile(this.logTailPath);
}
this.logTailPath = datedPath;
this.logTailOffset = 0;
// Skip to end of existing file
try {
const stat = fs.statSync(datedPath);
this.logTailOffset = stat.size;
} catch {
// File doesn't exist yet
}
fs.watchFile(datedPath, { interval: 2000 }, () => {
this.readLogTailLines();
});
}
/**
* Read new lines from the current log file and forward to electron-log.
*/
private readLogTailLines(): void {
if (!this.logTailPath) return;
try {
const stat = fs.statSync(this.logTailPath);
if (stat.size <= this.logTailOffset) return;
const fd = fs.openSync(this.logTailPath, "r");
try {
const buf = Buffer.alloc(stat.size - this.logTailOffset);
fs.readSync(fd, buf, 0, buf.length, this.logTailOffset);
this.logTailOffset = stat.size;
const text = buf.toString("utf-8");
for (const line of text.split("\n")) {
if (line.trim()) {
log.info(line);
}
}
} finally {
fs.closeSync(fd);
}
} catch {
// File may not exist yet or was rotated — ignore
}
}
/**
* Start tailing the proxy log file and forwarding new lines to electron-log.
* Handles date-based log rotation via a periodic check (every 60s).
*/
private startLogTail(logBasePath: string): void {
this.stopLogTail();
this.logTailBasePath = logBasePath;
this.logTailOffset = 0;
// Watch today's file immediately
this.switchLogTailFile();
// Periodically check for date rollover (every 60s)
this.logTailTimer = setInterval(() => {
this.switchLogTailFile();
}, 60_000);
}
/**
* Stop tailing the proxy log file.
*/
private stopLogTail(): void {
if (this.logTailTimer) {
clearInterval(this.logTailTimer);
this.logTailTimer = null;
}
if (this.logTailPath) {
fs.unwatchFile(this.logTailPath);
this.logTailPath = null;
}
this.logTailBasePath = null;
this.logTailOffset = 0;
}
/**
* 解析 qiming-mcp-stdio-proxy 脚本路径disk lookup不使用缓存
*
* 优先级:
* 1. QIMING_MCP_PROXY_LOCAL_PATH 环境变量(开发调试,可选)
* 2. 应用内集成版本resources/qiming-mcp-stdio-proxy
*
* 开发和生产模式统一使用 resources/ 目录,确保行为一致
*/
private resolveProxyScriptPath(): string | null {
const pkgName = "qiming-mcp-stdio-proxy";
// 1. 开发模式:优先使用环境变量指定的本地路径(可选)
const localDevPath = process.env.QIMING_MCP_PROXY_LOCAL_PATH;
if (localDevPath) {
const entry = resolveNpmPackageEntry(localDevPath, pkgName);
if (entry) {
log.info(
`[McpProxy] 🔍 resolveProxyScriptPath: using local dev build: ${entry}`,
);
return entry;
}
log.warn(
`[McpProxy] 🔍 QIMING_MCP_PROXY_LOCAL_PATH=${localDevPath}: no valid entry, trying other paths`,
);
}
// 2. 应用内集成版本bundled resources- 开发和生产统一
const bundledDir = getBundledMcpProxyDir();
if (bundledDir) {
const entry = resolveNpmPackageEntry(bundledDir, pkgName);
if (entry) {
log.info(
`[McpProxy] 🔍 resolveProxyScriptPath: using bundled app resources: ${entry}`,
);
return entry;
}
}
log.warn(
`[McpProxy] 🔍 resolveProxyScriptPath: ${pkgName} not found; run npm run prepare:mcp-proxy`,
);
return null;
}
/**
* 获取脚本路径(仅返回缓存值,需先调用 start() 填充)
*/
private getProxyScriptPath(): string | null {
return this.cachedScriptPath;
}
/**
* 从当前配置中提取标记为 persistent 的 servers
*/
getPersistentServers(): Record<string, StdioMcpServerEntry> {
const result: Record<string, StdioMcpServerEntry> = {};
for (const [name, entry] of Object.entries(this.config.mcpServers)) {
if (!isRemoteEntry(entry) && entry.persistent) result[name] = entry;
}
return result;
}
/**
* 从当前配置中提取所有 stdio 类型 servers包括 persistent 和临时)
*/
getAllStdioServers(): Record<string, StdioMcpServerEntry> {
const result: Record<string, StdioMcpServerEntry> = {};
for (const [name, entry] of Object.entries(this.config.mcpServers)) {
if (!isRemoteEntry(entry)) result[name] = entry;
}
return result;
}
/**
* start() → 验证 binary 可用性并刷新缓存 + 启动 PersistentMcpBridge持久化 servers
* proxy 进程的生命周期由 Agent 引擎管理(通过 getAgentMcpConfig 注入)
*/
async start(): Promise<{ success: boolean; error?: string }> {
// 优先从应用 node_modulesnpm 依赖)或 ~/.qimingclaw/node_modules 解析
this.cachedScriptPath = this.resolveProxyScriptPath();
if (!this.cachedScriptPath) {
this.cachedScriptPath = null;
if (!isInstalledLocally("qiming-mcp-stdio-proxy")) {
const err = t("Claw.MCP.notInstalled");
this.lastError = err;
return { success: false, error: err };
}
const err = t("Claw.MCP.entryNotFound");
this.lastError = err;
return { success: false, error: err };
}
log.info("[McpProxy] qiming-mcp-stdio-proxy ready:", this.cachedScriptPath);
// Start tailing proxy log file so its output appears in main.log
const proxyLogFile = path.join(
app.getPath("home"),
APP_DATA_DIR_NAME,
"logs",
"mcp-proxy.log",
);
this.startLogTail(proxyLogFile);
// 验证内置 Node.js 资源存在
const nodeBinPath = getNodeBinPath();
if (!nodeBinPath) {
log.warn("[McpProxy] ⚠️ Bundled Node.js resources not found");
log.warn(
"[McpProxy] Run the following command to download Node.js resources:",
);
log.warn("[McpProxy] npm run prepare:node");
log.warn("[McpProxy] Or run full preparation:");
log.warn("[McpProxy] npm run prepare:all");
}
this.lastError = null;
return { success: true };
}
/**
* stop() → 清除缓存状态 + 停止 PersistentMcpBridge
*/
async stop(): Promise<{ success: boolean }> {
this.stopLogTail();
this.cachedScriptPath = null;
this.bridgeStarted = false;
this.lastError = null;
try {
await persistentMcpBridge.stop();
// 重置 bridge 配置缓存,确保下次启动会重新加载
lastBridgeConfig = null;
} catch (e) {
log.warn("[McpProxy] PersistentMcpBridge stop error:", e);
}
return { success: true };
}
/**
* markBridgeStarted() → 外部调用方(如 syncMcpConfigToProxyAndReload直接启动了 bridge 后,
* 通过此方法同步 bridgeStarted 标志,防止 ensureBridgeStarted() 在同一请求内再次重启 bridge。
*/
markBridgeStarted(): void {
this.bridgeStarted = true;
}
/**
* ensureBridgeStarted() → 启动 PersistentMcpBridge已启动则直接命中缓存返回
*
* 设计原则bridge 只管理 persistent 服务(如 chrome-devtools动态 MCP 不进 bridge
* 由 mcp-proxy 按需 spawngetAgentMcpConfig 对无 bridge URL 的 server 降级为 stdio
*
* 缓存逻辑bridgeStarted 标志为 true 时,直接 return不重复启动。
*/
async ensureBridgeStarted(): Promise<void> {
const startedAt = Date.now();
if (this.bridgeStarted) {
// 缓存命中bridge 已在运行,无需重复启动
logMcpPerfSummary("ensureBridge.summary", {
branch: "cache_hit",
elapsedMs: Date.now() - startedAt,
});
return;
}
// 只将 persistent 标记的 server 纳入 bridge如 chrome-devtools
// 动态 MCP 不进 bridge走 mcp-proxy stdio 按需 spawn
const persistentServers = this.getPersistentServers();
if (Object.keys(persistentServers).length > 0) {
try {
const resolvedServers = resolveServersConfig(
persistentServers,
) as Record<string, StdioMcpServerEntry>;
await persistentMcpBridge.start(resolvedServers);
this.bridgeStarted = true;
log.info(
"[McpProxy] PersistentMcpBridge started (persistent servers):",
Object.keys(resolvedServers).join(", "),
);
logMcpPerfSummary("ensureBridge.summary", {
branch: "started",
persistentCount: Object.keys(resolvedServers).length,
elapsedMs: Date.now() - startedAt,
});
} catch (e) {
log.error("[McpProxy] PersistentMcpBridge start failed:", e);
logMcpPerfSummary(
"ensureBridge.summary",
{
branch: "failed",
persistentCount: Object.keys(persistentServers).length,
elapsedMs: Date.now() - startedAt,
error: e instanceof Error ? e.message : String(e),
},
"warn",
);
throw e;
}
} else {
this.bridgeStarted = true;
logMcpPerfSummary("ensureBridge.summary", {
branch: "no_persistent",
elapsedMs: Date.now() - startedAt,
});
}
}
/**
* restart() → 仅验证 binary 可用性
*/
async restart(): Promise<{ success: boolean; error?: string }> {
return this.start();
}
/**
* 获取运行状态(使用缓存路径,避免频繁磁盘 I/O
*/
getStatus(): McpProxyStatus {
const serverNames = Object.keys(this.config.mcpServers || {});
return {
running: !!this.cachedScriptPath,
serverCount: serverNames.length,
serverNames,
error:
!this.cachedScriptPath && this.lastError ? this.lastError : undefined,
};
}
/**
* 获取当前 mcpServers 配置
*/
getConfig(): McpServersConfig {
return this.config;
}
/**
* 设置 mcpServers 配置
*/
setConfig(config: McpServersConfig): void {
this.config = config;
}
/**
* 添加一个 MCP Server 到配置
*/
addServer(id: string, entry: McpServerEntry): void {
this.config.mcpServers[id] = entry;
}
/**
* 移除一个 MCP Server 从配置
*/
removeServer(id: string): void {
delete this.config.mcpServers[id];
}
/**
* 获取 Agent 引擎需要的 MCP 配置
*
* 所有 server 统一通过 qiming-mcp-stdio-proxy 聚合:
* - persistent server如 chrome-devtools→ { url }bridge URL长连接 PersistentMcpBridge
* - 动态 MCP server → { command, args, env }stdiomcp-proxy 按需 spawn
* - 远程 serverurl 类型)→ 直接透传
*
* 所有平台统一使用 process.execPath (Electron Node.js) + ELECTRON_RUN_AS_NODE=1
* 避免依赖系统 PATH 中的 node。
*
* @param projectId - 可选的项目/会话标识,用于区分不同会话的日志文件
*/
getAgentMcpConfig(
projectId?: string,
): Record<
string,
{ command: string; args: string[]; env?: Record<string, string> }
> | null {
const policyConfig = applyDigitalEmployeeMcpExecutionPolicy(this.config) as McpServersConfig;
const servers = policyConfig.mcpServers;
if (!servers || Object.keys(servers).length === 0) {
return null;
}
const scriptPath = this.getProxyScriptPath();
// 构建统一的 proxy 配置(混合 stdio、bridge 和远程类型)
const proxyServers: Record<
string,
{
command?: string;
args?: string[];
env?: Record<string, string>;
url?: string;
transport?: string;
headers?: Record<string, string>;
authToken?: string;
allowTools?: string[];
denyTools?: string[];
}
> = {};
// 1. 远程 server → 直接透传url/transport/headers/authToken
// 2. stdio server
// - persistent server如 chrome-devtools→ 优先使用 bridge URL已在 PersistentMcpBridge 中运行)
// - 动态 MCP server → bridge 中没有注册,降级到 stdio 配置(由 mcp-proxy 按需 spawn
const globalAllowTools = policyConfig.allowTools;
const globalDenyTools = policyConfig.denyTools;
for (const [name, entry] of Object.entries(servers)) {
const effectiveAllowTools =
entry.allowTools && entry.allowTools.length > 0
? entry.allowTools
: globalAllowTools && globalAllowTools.length > 0
? globalAllowTools
: undefined;
const effectiveDenyTools =
entry.denyTools && entry.denyTools.length > 0
? entry.denyTools
: globalDenyTools && globalDenyTools.length > 0
? globalDenyTools
: undefined;
if (isRemoteEntry(entry)) {
proxyServers[name] = {
...entry,
...(effectiveAllowTools ? { allowTools: effectiveAllowTools } : {}),
...(effectiveDenyTools ? { denyTools: effectiveDenyTools } : {}),
};
continue;
}
// 尝试获取 bridge URLpersistent server 有,动态 MCP 没有 → 降级 stdio
if (persistentMcpBridge.isRunning()) {
const url = persistentMcpBridge.getBridgeUrl(name);
if (url) {
proxyServers[name] = {
url,
...(effectiveAllowTools ? { allowTools: effectiveAllowTools } : {}),
...(effectiveDenyTools ? { denyTools: effectiveDenyTools } : {}),
};
continue;
}
}
// 降级stdio 配置bridge 未运行或 server 未就绪)
const resolved = resolveServersConfig({ [name]: entry });
if (resolved[name]) {
proxyServers[name] = {
...(resolved[name] as (typeof proxyServers)[string]),
...(effectiveAllowTools ? { allowTools: effectiveAllowTools } : {}),
...(effectiveDenyTools ? { denyTools: effectiveDenyTools } : {}),
};
}
}
if (Object.keys(proxyServers).length === 0) {
return null;
}
// 有 proxy 脚本 → 每个服务独立 proxy 入口(拆分模式,避免一个服务失败阻塞整体)
if (scriptPath) {
// 使用应用内置的 Node.jsresources/node/<platform-arch>/bin/node
// 开发环境下可回退到系统 node仅 macOS/Linux
const nodeBinPath = getNodeBinPathWithFallback();
if (!nodeBinPath) {
log.error("[McpProxy] Node.js not found, MCP proxy cannot start");
log.error(
'[McpProxy] Run "npm run prepare:node" to download Node.js resources',
);
return null;
}
const configDir = path.join(os.tmpdir(), "qiming-mcp-configs");
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
const logsDir = path.join(app.getPath("home"), APP_DATA_DIR_NAME, "logs");
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const result: Record<
string,
{ command: string; args: string[]; env?: Record<string, string> }
> = {};
// 生成项目标识的安全文件名部分
const safeProjectId = projectId
? projectId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 32)
: "shared";
// 为每个项目创建独立的日志目录 mcp-proxy/{projectId}/
const projectLogDir = path.join(logsDir, "mcp-proxy", safeProjectId);
if (!fs.existsSync(projectLogDir)) {
fs.mkdirSync(projectLogDir, { recursive: true });
}
for (const [name, entry] of Object.entries(proxyServers)) {
// 每个服务生成独立的配置文件
const singleConfig = { mcpServers: { [name]: entry } };
const configJson = JSON.stringify(singleConfig);
const configHash = crypto
.createHash("md5")
.update(configJson)
.digest("hex")
.slice(0, 16);
// 文件名包含服务名,便于调试识别
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
const configFileName = `mcp-config-${safeName}-${configHash}.json`;
const configFilePath = path.join(configDir, configFileName);
fs.writeFileSync(configFilePath, configJson, "utf-8");
// 每个服务独立的日志文件,按项目分目录存放
const proxyEnvOverrides: Record<string, string> = {
MCP_PROXY_LOG_FILE: path.join(projectLogDir, `${safeName}.log`),
};
// 构建该服务的 proxy 启动参数
const proxyArgs = [scriptPath, "--config-file", configFilePath];
// 应用该服务的工具过滤(如果有)
if (entry.allowTools && entry.allowTools.length > 0) {
proxyArgs.push("--allow-tools", entry.allowTools.join(","));
} else if (entry.denyTools && entry.denyTools.length > 0) {
proxyArgs.push("--deny-tools", entry.denyTools.join(","));
}
result[name] = {
command: nodeBinPath,
args: proxyArgs,
env: proxyEnvOverrides,
};
}
log.info(
`[McpProxy] Built ${Object.keys(result).length} per-server MCP proxy config(s)`,
);
return result;
}
// fallback: 无 proxy 脚本时直接返回各 server 的 stdio 配置(仅临时 server
const result: Record<
string,
{ command: string; args: string[]; env?: Record<string, string> }
> = {};
for (const [name, entry] of Object.entries(proxyServers)) {
if (entry.command) {
result[name] = {
command: entry.command,
args: entry.args || [],
env: entry.env,
};
}
}
return Object.keys(result).length > 0 ? result : null;
}
/**
* 发现指定 MCP 服务器的工具列表
* 临时启动 MCP 服务器,调用 tools/list然后关闭
*/
async discoverTools(serverId: string): Promise<string[]> {
// 直接从 SQLite 读取最新配置,不依赖 this.config 内存快照
const { getDb } = await import("../../db");
const db = getDb();
const saved = db
?.prepare("SELECT value FROM settings WHERE key = ?")
.get("mcp_local_config") as { value: string } | undefined;
let servers: Record<string, McpServerEntry> = {};
if (saved) {
try {
const config = JSON.parse(saved.value);
servers = config?.mcpServers ?? {};
} catch {
// 解析失败时 servers 保持为空
}
}
const entry = servers[serverId];
if (!entry) {
throw new Error(`MCP server not found: ${serverId}`);
}
// 远程类型暂不支持工具发现(需要 MCP SDK 支持)
if (isRemoteEntry(entry)) {
throw new Error(
"Tool discovery not supported for remote MCP servers yet",
);
}
// 解析命令和环境变量
const resolved = resolveServersConfig({ [serverId]: entry });
const resolvedEntry = resolved[serverId];
if (!resolvedEntry || isRemoteEntry(resolvedEntry)) {
throw new Error("Failed to resolve MCP server config");
}
// 临时启动 MCP 服务器进程
const { spawn } = await import("child_process");
let proc: any = null;
try {
proc = spawn(resolvedEntry.command, resolvedEntry.args, {
env: { ...process.env, ...resolvedEntry.env },
stdio: ["pipe", "pipe", "pipe"],
});
// 发送 MCP 初始化请求
const initRequest = {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "qiming-agent", version: "1.0.0" },
},
};
proc.stdin.write(JSON.stringify(initRequest) + "\n");
// 等待初始化响应
await this.waitForMcpResponse(proc, 1, 5000);
// 发送 tools/list 请求
const toolsRequest = {
jsonrpc: "2.0",
id: 2,
method: "tools/list",
params: {},
};
proc.stdin.write(JSON.stringify(toolsRequest) + "\n");
// 等待 tools/list 响应
const response = await this.waitForMcpResponse(proc, 2, 5000);
// 验证响应格式
if (!response?.result?.tools || !Array.isArray(response.result.tools)) {
log.warn("[McpProxy] Invalid tools response:", response);
return [];
}
// 解析工具列表,过滤无效项
return response.result.tools
.filter((t: any) => t && typeof t.name === "string")
.map((t: { name: string }) => t.name);
} finally {
// 确保进程被正确清理
if (proc && !proc.killed) {
try {
proc.kill("SIGTERM");
// 如果 SIGTERM 失败5 秒后强制 SIGKILL
const killTimer = setTimeout(() => {
if (proc && !proc.killed) {
proc.kill("SIGKILL");
}
}, 5000);
// 进程退出时清理定时器
proc.once("exit", () => {
clearTimeout(killTimer);
});
} catch (e) {
log.warn("[McpProxy] Failed to kill MCP discovery process:", e);
}
}
}
}
/**
* 等待 MCP 响应(辅助方法)
*/
private async waitForMcpResponse(
proc: any,
requestId: number,
timeoutMs: number,
): Promise<any> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("MCP response timeout"));
}, timeoutMs);
let buffer = "";
const onData = (chunk: Buffer) => {
buffer += chunk.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (msg.id === requestId) {
clearTimeout(timeout);
proc.stdout.off("data", onData);
if (msg.error) {
reject(new Error(msg.error.message || "MCP error"));
} else {
resolve(msg);
}
}
} catch {
// 忽略非 JSON 行
}
}
};
proc.stdout.on("data", onData);
proc.on("error", (err: Error) => {
clearTimeout(timeout);
reject(err);
});
});
}
/**
* 清理(退出时调用)— 停止 PersistentMcpBridge + kill 子进程
*/
async cleanup(): Promise<void> {
this.stopLogTail();
try {
await persistentMcpBridge.stop();
} catch (e) {
log.warn("[McpProxy] PersistentMcpBridge cleanup error:", e);
}
}
}
// ========== Exports ==========
export const mcpProxyManager = new McpProxyManager();
/** 上次用于启动 PersistentMcpBridge 的配置(用于避免不必要的重启) */
let lastBridgeConfig: Record<string, StdioMcpServerEntry> | null = null;
/**
* syncMcpConfigToProxyAndReload 的锁
* 防止并行执行导致 MCP 安装冲突Windows 上 npx 并行安装可能报错)
* 使用 Promise 链式实现,简洁且可靠
*/
let syncMcpLock: Promise<void> = Promise.resolve();
async function withSyncMcpLock<T>(fn: () => Promise<T>): Promise<T> {
const requestedAt = Date.now();
const previousLock = syncMcpLock;
let releaseLock: () => void;
const currentLock = new Promise<void>((resolve) => {
releaseLock = resolve;
});
syncMcpLock = currentLock;
await previousLock; // 等待前一个锁释放
const lockWaitMs = Date.now() - requestedAt;
const runStartedAt = Date.now();
try {
return await fn();
} finally {
const runMs = Date.now() - runStartedAt;
const totalMs = Date.now() - requestedAt;
logMcpPerfSummary("sync.lock.summary", {
lockWaitMs,
runMs,
totalMs,
});
releaseLock!();
}
}
/** 比较两个 stdio 配置是否相等(忽略 env 中的临时变量) */
function configsEqual(
a: Record<string, StdioMcpServerEntry>,
b: Record<string, StdioMcpServerEntry> | null,
): boolean {
if (!b) return false;
const aKeys = Object.keys(a).sort();
const bKeys = Object.keys(b).sort();
if (aKeys.length !== bKeys.length) return false;
if (aKeys.join(",") !== bKeys.join(",")) return false;
for (const key of aKeys) {
const entryA = a[key];
const entryB = b[key];
if (entryA.command !== entryB.command) return false;
if (JSON.stringify(entryA.args) !== JSON.stringify(entryB.args))
return false;
// persistent 标志必须一致
if (entryA.persistent !== entryB.persistent) return false;
}
return true;
}
/**
* 将 mcpServers 配置同步到 MCP Proxy 配置并持久化,同时动态重启 PersistentMcpBridge。
*
* 设计原则:
* - chrome-devtools 等默认服务DEFAULT_MCP_PROXY_CONFIG始终保留必须运行
* - 动态 MCP server 根据传入的 mcpServers 增删:传入为空时仅保留默认服务
* - 配置未变化时configsEqual跳过 bridge 重启,避免无谓抖动
*
* 调用时机:每次 ACP 下发 context_servers 时(包括空列表,代表"清空动态 MCP"
*/
export async function syncMcpConfigToProxyAndReload(
mcpServers: Record<string, McpServerEntry>,
): Promise<void> {
await withSyncMcpLock(async () => {
const syncStartedAt = Date.now();
// 注意mcpServers 可以为空(用户删除了所有动态 MCP).此时应重置为仅默认服务,
// 不在这里提前返回,让后续逻辑重置 bridge 到仅含 chrome-devtools 的状态。
// 提取真实服务(过滤旧桥接项 command==='mcp-proxy')
const extractStartedAt = Date.now();
const realOnly: Record<string, McpServerEntry> = {};
for (const [name, entry] of Object.entries(mcpServers || {})) {
if (!entry) continue;
if (isRemoteEntry(entry)) {
// 远程类型直接透传
realOnly[name] = entry;
} else {
if (entry.command === "mcp-proxy") continue;
if (typeof entry.command !== "string") continue;
realOnly[name] = {
command: entry.command,
args: Array.isArray(entry.args) ? entry.args : [],
env: entry.env,
...(entry.allowTools ? { allowTools: entry.allowTools } : {}),
...(entry.denyTools ? { denyTools: entry.denyTools } : {}),
};
}
}
const extractMs = Date.now() - extractStartedAt;
// realOnly 为空时(用户删除了所有动态 MCP不提前返回
// 继续执行以确保 bridge 仅运行默认服务chrome-devtools
// 以可用的默认服务为基础,再叠加动态 MCP。默认 chrome-devtools
// 只有本地已安装或显式启用时才注入,避免 npx @latest 在启动时阻塞 60s。
const merged: Record<string, McpServerEntry> = {
...getDefaultMcpServers(),
...realOnly,
};
const prunedMerged = pruneDisabledDefaultMcpServers(merged);
// 为所有 MCP 服务器注入基础环境变量(包括 PATH
const prepareStartedAt = Date.now();
const mergedWithEnv = injectBaseEnvToMcpServers(prunedMerged);
// Bridge 只管理 persistent 服务(如 chrome-devtools动态 MCP 不进 bridge。
// 变更检测和重启均只针对 persistent servers避免动态 MCP 变化时重启 chrome-devtools。
const persistentOnly = Object.fromEntries(
Object.entries(mergedWithEnv).filter(
([, e]) => !isRemoteEntry(e) && (e as StdioMcpServerEntry).persistent,
),
) as Record<string, StdioMcpServerEntry>;
const resolvedPersistent = resolveServersConfig(persistentOnly) as Record<
string,
StdioMcpServerEntry
>;
const prepareMs = Date.now() - prepareStartedAt;
// 更新内存配置(动态 MCP + 默认服务一并写入,供 getAgentMcpConfig 使用)
const setConfigStartedAt = Date.now();
const existing = mcpProxyManager.getConfig();
mcpProxyManager.setConfig({
mcpServers: mergedWithEnv,
allowTools: existing.allowTools,
denyTools: existing.denyTools,
});
const setConfigMs = Date.now() - setConfigStartedAt;
// Persistent servers 未变化 → 跳过 DB 写入和 bridge 重启(动态 MCP 变化不影响 bridge
if (configsEqual(resolvedPersistent, lastBridgeConfig)) {
log.info(
"[McpProxy] ✅ Persistent bridge config unchanged, skipping restart (dynamic MCP via stdio)",
);
mcpProxyManager.markBridgeStarted();
logMcpPerfSummary("sync.summary", {
branch: "persistent_unchanged",
inputCount: Object.keys(mcpServers || {}).length,
dynamicCount: Object.keys(realOnly).length,
mergedCount: Object.keys(mergedWithEnv).length,
persistentCount: Object.keys(resolvedPersistent).length,
extractMs,
prepareMs,
setConfigMs,
totalMs: Date.now() - syncStartedAt,
});
return;
}
// Persistent servers 有变化(如 chrome-devtools 配置变更)→ 持久化并重启 bridge
log.info(
"[McpProxy] Syncing MCP config — full:",
Object.keys(mergedWithEnv).join(", "),
);
const persistStartedAt = Date.now();
try {
const { getDb } = await import("../../db");
const db = getDb();
if (db) {
db.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
).run("mcp_proxy_config", JSON.stringify(mcpProxyManager.getConfig()));
log.info("[McpProxy] MCP config persisted");
}
} catch (e) {
log.warn("[McpProxy] Failed to persist MCP config:", e);
}
const persistMs = Date.now() - persistStartedAt;
// 只重启 persistent serverschrome-devtools动态 MCP 不进 bridge走 mcp-proxy stdio
const bridgeRestartStartedAt = Date.now();
try {
log.info(
"[McpProxy] 🔄 Persistent bridge config changed, restarting:",
Object.keys(resolvedPersistent).join(", "),
);
await persistentMcpBridge.start(resolvedPersistent);
const bridgeRestartMs = Date.now() - bridgeRestartStartedAt;
lastBridgeConfig = resolvedPersistent;
mcpProxyManager.markBridgeStarted();
log.info("[McpProxy] PersistentMcpBridge restarted with updated config");
logMcpPerfSummary("sync.summary", {
branch: "persistent_restarted",
inputCount: Object.keys(mcpServers || {}).length,
dynamicCount: Object.keys(realOnly).length,
mergedCount: Object.keys(mergedWithEnv).length,
persistentCount: Object.keys(resolvedPersistent).length,
extractMs,
prepareMs,
setConfigMs,
persistMs,
bridgeRestartMs,
totalMs: Date.now() - syncStartedAt,
});
} catch (e) {
const bridgeRestartMs = Date.now() - bridgeRestartStartedAt;
lastBridgeConfig = null;
log.warn("[McpProxy] PersistentMcpBridge restart after sync failed:", e);
logMcpPerfSummary(
"sync.summary",
{
branch: "persistent_restart_failed",
inputCount: Object.keys(mcpServers || {}).length,
dynamicCount: Object.keys(realOnly).length,
mergedCount: Object.keys(mergedWithEnv).length,
persistentCount: Object.keys(resolvedPersistent).length,
extractMs,
prepareMs,
setConfigMs,
persistMs,
bridgeRestartMs,
totalMs: Date.now() - syncStartedAt,
error: e instanceof Error ? e.message : String(e),
},
"warn",
);
}
});
}
/**
* 发现指定 MCP 服务器的工具列表
*/
export async function discoverMcpTools(serverId: string): Promise<string[]> {
return mcpProxyManager.discoverTools(serverId);
}