1509 lines
50 KiB
TypeScript
1509 lines
50 KiB
TypeScript
/**
|
||
* 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 < extraHeaders(extraHeaders 优先, 可覆盖)
|
||
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_modules(npm 依赖)或 ~/.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 按需 spawn(getAgentMcpConfig 对无 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 }(stdio,mcp-proxy 按需 spawn)
|
||
* - 远程 server(url 类型)→ 直接透传
|
||
*
|
||
* 所有平台统一使用 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 URL(persistent 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.js(resources/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 servers(chrome-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);
|
||
}
|