zen-gitsync 2.17.8 → 2.17.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/package.json +2 -1
- package/src/cli/ai/agent.js +210 -58
- package/src/cli/ai/context.js +58 -0
- package/src/cli/ai/context.test.js +117 -0
- package/src/cli/ai/extensions.js +131 -0
- package/src/cli/ai/extensions.test.js +158 -0
- package/src/cli/ai/mcp.js +498 -0
- package/src/cli/ai/mcp.test.js +275 -0
- package/src/cli/ai/runtime.test.js +32 -0
- package/src/cli/ai/skills.js +207 -0
- package/src/cli/ai/skills.test.js +126 -0
- package/src/cli/ai/termui.js +1 -0
- package/src/cli/ai/tools.js +94 -3
- package/src/cli/ai/tools.test.js +82 -0
- package/src/cli/ai/transport.js +18 -4
- package/src/cli/ai/turn.js +21 -5
- package/src/config.js +27 -3
- package/src/paths.js +11 -0
- package/src/ui/public/assets/AgentView-BV15UtEF.css +1 -0
- package/src/ui/public/assets/AgentView-C-AJo7KS.js +5 -0
- package/src/ui/public/assets/AppVersionBadge-B6R6eN2N.js +2 -0
- package/src/ui/public/assets/BranchSelector-Cj3kMWON.js +1 -0
- package/src/ui/public/assets/CommitForm-DpXuFG-F.js +6 -0
- package/src/ui/public/assets/CommonDialog-BLPHDsn0.js +1 -0
- package/src/ui/public/assets/EditorView-B1OnoXXf.js +1 -0
- package/src/ui/public/assets/FileDiffViewer-BQl0JcWd.js +14 -0
- package/src/ui/public/assets/{FlowExecutionViewer-uXrzZjsW.js → FlowExecutionViewer-BgEt5_Hz.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CbLmsSF8.js +21 -0
- package/src/ui/public/assets/IconButton-aM3vaSIN.js +1 -0
- package/src/ui/public/assets/LogList-UjoVFZ-H.js +7 -0
- package/src/ui/public/assets/MindmapView-D_04v0n4.js +1 -0
- package/src/ui/public/assets/MonitorView-BD1_uzq7.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-BTYlVvzh.js +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-20mpQMwu.js +1 -0
- package/src/ui/public/assets/RemoteRepoCard-DOrm17Xd.js +1 -0
- package/src/ui/public/assets/SourceMapView-nUU9bYPh.js +3 -0
- package/src/ui/public/assets/SvgIcon-LUynX3Iq.js +1 -0
- package/src/ui/public/assets/TemplateManager-Bo3Q7J9Y.js +1 -0
- package/src/ui/public/assets/UserInputNode-D5tSXiJz.js +1 -0
- package/src/ui/public/assets/{WorkbenchView-v3QFn6Ln.css → WorkbenchView-C_Gmzvj1.css} +1 -1
- package/src/ui/public/assets/WorkbenchView-UGC334JL.js +13 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-D-D9qkRz.js +19 -0
- package/src/ui/public/assets/configStore-BLRcYJdf.js +1 -0
- package/src/ui/public/assets/{element-plus-KTmRV-so.js → element-plus-DcZ-TVp6.js} +3 -3
- package/src/ui/public/assets/flow-mindmap-D1VTkZh3.js +139 -0
- package/src/ui/public/assets/{gitStore-CpcS-MzM.js → gitStore-DMEDMZMk.js} +4 -4
- package/src/ui/public/assets/index-CW3xs-af.css +1 -0
- package/src/ui/public/assets/index-S0_gRyiO.js +26 -0
- package/src/ui/public/assets/{office-docx-BfSBpdBv.js → office-docx-C0g5Oepv.js} +5 -5
- package/src/ui/public/assets/{office-excel-S95W2mv4.js → office-excel-D-uYLKaA.js} +1 -1
- package/src/ui/public/assets/{office-pptx-DFFarGrk.js → office-pptx-De3fnc6M.js} +1 -1
- package/src/ui/public/assets/previewSandbox-DXAxqDVu.js +0 -0
- package/src/ui/public/assets/toolsStore-CpImRUhI.js +1 -0
- package/src/ui/public/assets/{useThemeObserver-B01D-Vl6.js → useThemeObserver-CG8k8U18.js} +1 -1
- package/src/ui/public/assets/{vendor-dO4B7wQf.js → vendor-BIgZEtkx.js} +27 -27
- package/src/ui/public/assets/vue-flow-CPCFgenl.js +39 -0
- package/src/ui/public/index.html +17 -17
- package/src/ui/server/index.js +4 -0
- package/src/ui/server/routes/config.js +16 -3
- package/src/ui/server/routes/exec.js +19 -11
- package/src/ui/server/routes/fileOpen.js +142 -2
- package/src/ui/server/routes/remoteRepos.js +604 -0
- package/src/ui/server/routes/remoteRepos.test.js +386 -0
- package/src/ui/server/routes/workbench/agentChat.js +86 -180
- package/src/ui/server/routes/workbench/agentChatShared.test.js +151 -0
- package/src/ui/server/routes/workbench/agentMarketplace.js +986 -0
- package/src/ui/server/routes/workbench/agentMarketplace.test.js +306 -0
- package/src/ui/server/routes/workbench/agentParity.test.js +167 -0
- package/src/ui/server/routes/workbench/agentRoutes.js +86 -1
- package/src/ui/server/routes/workbench/agentRoutes.test.js +50 -1
- package/src/ui/server/routes/workbench/jobStore.js +17 -3
- package/src/ui/server/routes/workbench/orchestratorStore.js +42 -2
- package/src/ui/server/routes/workbench/projectRegistry.js +6 -0
- package/src/ui/server/routes/workbench/projectRegistry.test.js +70 -0
- package/src/ui/server/routes/workbench/projectTool.js +198 -0
- package/src/ui/server/routes/workbench/projectTool.test.js +152 -0
- package/src/ui/server/routes/workbench/remoteCliPromptParity.test.js +177 -0
- package/src/ui/server/routes/workbench/taskRunner.js +269 -12
- package/src/ui/server/routes/workbench/taskRunner.toolcalls.test.js +307 -0
- package/src/ui/server/socket/registerUiSocketHandlers.js +18 -11
- package/src/utils/shellPath.js +253 -0
- package/src/utils/shellPath.test.js +221 -0
- package/src/ui/public/assets/AgentView-CdJaeRh0.js +0 -5
- package/src/ui/public/assets/AgentView-DAY4S5WN.css +0 -1
- package/src/ui/public/assets/AppVersionBadge-CiijSYfM.js +0 -2
- package/src/ui/public/assets/BranchSelector-BS7ajwvv.js +0 -1
- package/src/ui/public/assets/CommitForm-CEOSqXCA.js +0 -6
- package/src/ui/public/assets/CommonDialog-CAaEi-4T.js +0 -1
- package/src/ui/public/assets/EditorView-DyaFyRDD.js +0 -1
- package/src/ui/public/assets/FileDiffViewer-BvX9cQjR.js +0 -14
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DIBpUSt_.js +0 -21
- package/src/ui/public/assets/IconButton-CnoW2on6.js +0 -1
- package/src/ui/public/assets/LogList-D2espSVo.js +0 -7
- package/src/ui/public/assets/MindmapView-CZF-rZVs.js +0 -1
- package/src/ui/public/assets/MonitorView-DjLr7qOG.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-DzsU-QIO.js +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-CuuG2ucT.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-BGf8L0of.js +0 -1
- package/src/ui/public/assets/SourceMapView-CFb02m6z.js +0 -3
- package/src/ui/public/assets/SvgIcon-SRAeYjnN.js +0 -1
- package/src/ui/public/assets/TemplateManager-gedqAXl8.js +0 -1
- package/src/ui/public/assets/UserInputNode-BOCuMQW6.js +0 -1
- package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +0 -13
- package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +0 -19
- package/src/ui/public/assets/configStore-CuMElTgV.js +0 -1
- package/src/ui/public/assets/flow-mindmap-CurCIiCp.js +0 -139
- package/src/ui/public/assets/index-cApp6w0b.css +0 -1
- package/src/ui/public/assets/index-yXm9JLGv.js +0 -24
- package/src/ui/public/assets/previewSandbox-DkXYEpLP.js +0 -0
- package/src/ui/public/assets/toolsStore-DGxlRe3Y.js +0 -1
- package/src/ui/public/assets/vue-flow-CsvDD38k.js +0 -39
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// g ai 的 MCP(Model Context Protocol)客户端 — stdio 传输实现。
|
|
16
|
+
//
|
|
17
|
+
// 为什么自己实现而不是装 SDK:
|
|
18
|
+
// 本包 dependencies 一直保持精简,而 stdio 这一层的协议面很小
|
|
19
|
+
// (newline-delimited JSON-RPC + initialize / tools/list / tools/call 三个方法),
|
|
20
|
+
// 自己写≈200 行,换取零依赖 + 完全可控的超时与降级行为。
|
|
21
|
+
//
|
|
22
|
+
// 配置来源(两级,项目级覆盖全局同名 server):
|
|
23
|
+
// 1) ~/.zen-gitsync/ai/mcp.json 「g ai 智能体」全局安装(对所有项目生效)
|
|
24
|
+
// 2) <cwd>/.mcp.json 项目级安装
|
|
25
|
+
// 形状沿用 Claude 的 mcpServers,便于与生态里其他工具共享配置:
|
|
26
|
+
// { "mcpServers": { "github": { "command": "npx", "args": ["-y", "@..."], "env": {} } } }
|
|
27
|
+
//
|
|
28
|
+
// ⚠️ Windows 上 spawn 的两个坑(已实测,不要"优化"掉):
|
|
29
|
+
// 1) 带空格的参数在 shell:true 下会被 cmd 拆碎 ——
|
|
30
|
+
// spawn('node', ['C:\a b\proj'], { shell: true }) 子进程收到的是 "C:a" 和 "bproj";
|
|
31
|
+
// 2) Node >= 20.12 起,直接 spawn `npx.cmd` 这类批处理会抛 EINVAL(为修 CVE-2024-27980)。
|
|
32
|
+
// 所以这里的策略是:自己按 PATH/PATHEXT 解析出真实可执行文件 ——
|
|
33
|
+
// 解析到 .exe 就**完全不用 shell**(零转义风险),只有解析到 .cmd/.bat 才回落到
|
|
34
|
+
// `cmd.exe /d /s /c "<整行>"` + windowsVerbatimArguments,并使用 cross-spawn 那套
|
|
35
|
+
// 经过十年验证的转义规则(否则项目路径里有空格就会连不上)。
|
|
36
|
+
|
|
37
|
+
import { spawn } from 'node:child_process';
|
|
38
|
+
import { promises as fs, statSync } from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { trackChild } from '../cleanup.js';
|
|
41
|
+
import { AI_MCP_FILE } from '../../paths.js';
|
|
42
|
+
|
|
43
|
+
const IS_WIN = process.platform === 'win32';
|
|
44
|
+
|
|
45
|
+
// 工具名前缀。OpenAI function calling 允许 [A-Za-z0-9_-],用双下划线分段。
|
|
46
|
+
export const MCP_TOOL_PREFIX = 'mcp__';
|
|
47
|
+
|
|
48
|
+
// 初始化握手超时。
|
|
49
|
+
// ⚠️ 别按"握手只需要几百毫秒"来定这个值:command 是 npx -y 时,**首次**会现场下载
|
|
50
|
+
// 整个 server 包及其依赖。国内直连 registry(无代理)实测这一步能轻松吃掉 30s+,
|
|
51
|
+
// 而 25s 超时后 close() 会把下载中的子进程杀掉,npm cache 始终填不满 —— 表现就是
|
|
52
|
+
// "每次首连都超时,但隔一会儿手动 spawn 一次却能秒连"。给到 90s 才能扛过首次下载。
|
|
53
|
+
const INIT_TIMEOUT_MS = 90_000;
|
|
54
|
+
// 单次工具调用超时。MCP 工具很多是外部 API,给得宽一些。
|
|
55
|
+
const CALL_TIMEOUT_MS = 120_000;
|
|
56
|
+
// stderr 只保留尾部,避免某个话痨 server 把内存吃光(报错时用来给用户看原因)
|
|
57
|
+
const MAX_STDERR_CHARS = 4000;
|
|
58
|
+
|
|
59
|
+
// ──────────────────────────────────────────────
|
|
60
|
+
// Windows 可执行文件解析 + 转义
|
|
61
|
+
// ──────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
// 按 PATH + PATHEXT 找出 command 对应的真实文件(绝对路径)。找不到返回 null。
|
|
64
|
+
function resolveWindowsCommand(command) {
|
|
65
|
+
const exts = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
|
|
66
|
+
const hasExt = path.extname(command) !== '';
|
|
67
|
+
|
|
68
|
+
const candidates = [];
|
|
69
|
+
if (command.includes('\\') || command.includes('/')) {
|
|
70
|
+
// 显式路径:补全扩展名后原样尝试
|
|
71
|
+
for (const ext of hasExt ? [''] : exts) candidates.push(command + ext.toLowerCase());
|
|
72
|
+
} else {
|
|
73
|
+
const dirs = (process.env.PATH || '').split(';').filter(Boolean);
|
|
74
|
+
for (const dir of dirs) {
|
|
75
|
+
for (const ext of hasExt ? [''] : exts) {
|
|
76
|
+
candidates.push(path.join(dir, command + ext.toLowerCase()));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const candidate of candidates) {
|
|
82
|
+
// 同步判断即可:只在建连时调一次,且必须拿到结果才能决定 spawn 方式
|
|
83
|
+
try {
|
|
84
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
85
|
+
} catch { /* 继续找 */ }
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 单个参数的 cmd 转义(规则与 cross-spawn 一致)。
|
|
91
|
+
// 反斜杠数量的处理是关键:紧邻引号或行尾的反斜杠必须加倍,否则会被 cmd 吃掉。
|
|
92
|
+
function escapeCmdArgument(arg) {
|
|
93
|
+
let out = `${arg}`;
|
|
94
|
+
out = out.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"');
|
|
95
|
+
out = out.replace(/(?=(\\+?)?)\1$/, '$1$1');
|
|
96
|
+
out = `"${out}"`;
|
|
97
|
+
// % 会被 cmd 当环境变量展开,必须转义
|
|
98
|
+
out = out.replace(/(?=(\\+?)?)\1%/, '$1$1%');
|
|
99
|
+
// 元字符加 ^ 前缀,防止被 cmd 解释成管道/重定向
|
|
100
|
+
out = out.replace(/(?=(\\+?)?)\1(\^|&|\||<|>|\^)/g, '$1$1^$2');
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 把 {command, args} 翻译成可以交给 spawn 的 (file, args, options)。
|
|
106
|
+
*
|
|
107
|
+
* - 解析到 .exe / POSIX 可执行文件 → 直接 spawn,不经过任何 shell
|
|
108
|
+
* - 解析到 .cmd / .bat → 走 cmd.exe,整行用引号包起来配合 /s 的"去首尾引号"规则
|
|
109
|
+
*
|
|
110
|
+
* @param {string} command
|
|
111
|
+
* @param {string[]} args
|
|
112
|
+
* @param {NodeJS.ProcessEnv} env
|
|
113
|
+
* @returns {{ file: string, argv: string[], options: object }}
|
|
114
|
+
*/
|
|
115
|
+
export function buildSpawnSpec(command, args, env) {
|
|
116
|
+
const argv = (Array.isArray(args) ? args : []).map(String);
|
|
117
|
+
|
|
118
|
+
if (!IS_WIN) {
|
|
119
|
+
return { file: command, argv, options: { env, windowsHide: true } };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const resolved = resolveWindowsCommand(command);
|
|
123
|
+
// 解析不到时仍然交给 cmd 试一次 —— 可能是 PATH 之外的写法,让用户看到真实报错
|
|
124
|
+
const target = resolved || command;
|
|
125
|
+
|
|
126
|
+
if (/\.(exe|com)$/i.test(target)) {
|
|
127
|
+
return { file: target, argv, options: { env, windowsHide: true } };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// .cmd / .bat(以及解析不出来的情况):交给 cmd.exe,自己控制转义
|
|
131
|
+
const line = [target, ...argv].map(escapeCmdArgument).join(' ');
|
|
132
|
+
return {
|
|
133
|
+
file: process.env.ComSpec || process.env.comspec || 'cmd.exe',
|
|
134
|
+
argv: ['/d', '/s', '/c', `"${line}"`],
|
|
135
|
+
options: { env, windowsHide: true, windowsVerbatimArguments: true },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ──────────────────────────────────────────────
|
|
140
|
+
// 配置读取
|
|
141
|
+
// ──────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 读取一个 mcp.json 形状的配置文件,返回 mcpServers 对象。
|
|
145
|
+
* 文件不存在 / 坏了都只当"没有配置",不抛错 —— 一个坏文件不该让 g ai 起不来。
|
|
146
|
+
*/
|
|
147
|
+
async function readServersFile(file) {
|
|
148
|
+
const raw = await fs.readFile(file, 'utf8').catch(() => '');
|
|
149
|
+
if (!raw.trim()) return {};
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(raw);
|
|
152
|
+
const servers = parsed?.mcpServers;
|
|
153
|
+
if (!servers || typeof servers !== 'object') return {};
|
|
154
|
+
const out = {};
|
|
155
|
+
for (const [id, cfg] of Object.entries(servers)) {
|
|
156
|
+
if (!cfg || typeof cfg !== 'object') continue;
|
|
157
|
+
if (!cfg.command || typeof cfg.command !== 'string') continue;
|
|
158
|
+
out[id] = cfg;
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
} catch {
|
|
162
|
+
return {};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 合并全局 + 项目两处 MCP 配置,项目级覆盖同名全局 server。
|
|
168
|
+
*
|
|
169
|
+
* @param {{ cwd?: string, globalFile?: string, projectFile?: string }} [options]
|
|
170
|
+
* @returns {Promise<{ servers: Record<string, object>, sources: Array<object> }>}
|
|
171
|
+
*/
|
|
172
|
+
export async function loadMcpServers({ cwd, globalFile = AI_MCP_FILE, projectFile } = {}) {
|
|
173
|
+
const globalServers = await readServersFile(globalFile);
|
|
174
|
+
const projectPath = projectFile || (cwd ? path.join(path.resolve(cwd), '.mcp.json') : null);
|
|
175
|
+
const projectServers = projectPath ? await readServersFile(projectPath) : {};
|
|
176
|
+
return {
|
|
177
|
+
servers: { ...globalServers, ...projectServers },
|
|
178
|
+
sources: [
|
|
179
|
+
{ file: globalFile, scope: 'global', count: Object.keys(globalServers).length },
|
|
180
|
+
...(projectPath ? [{ file: projectPath, scope: 'project', count: Object.keys(projectServers).length }] : []),
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ──────────────────────────────────────────────
|
|
186
|
+
// 单个 server 的连接
|
|
187
|
+
// ──────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
// 工具名会被拼进 OpenAI 的 function name,必须收敛到安全字符集
|
|
190
|
+
function sanitizeToolName(name) {
|
|
191
|
+
return String(name || '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 48);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
class McpConnection {
|
|
195
|
+
/**
|
|
196
|
+
* @param {string} id - server 标识(配置里的键名)
|
|
197
|
+
* @param {object} config - { command, args, env, disabled? }
|
|
198
|
+
* @param {string} cwd - 工作目录(spawn 的 cwd)
|
|
199
|
+
*/
|
|
200
|
+
constructor(id, config, cwd) {
|
|
201
|
+
this.id = id;
|
|
202
|
+
this.config = config;
|
|
203
|
+
this.cwd = cwd;
|
|
204
|
+
this.child = null;
|
|
205
|
+
this.tools = [];
|
|
206
|
+
this.error = null;
|
|
207
|
+
this.stderr = '';
|
|
208
|
+
this.nextId = 1;
|
|
209
|
+
this.pending = new Map();
|
|
210
|
+
this.buffer = '';
|
|
211
|
+
this.closed = false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
get safeId() {
|
|
215
|
+
return sanitizeToolName(this.id);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 启动子进程并完成 initialize + tools/list 握手。
|
|
220
|
+
* 任何失败都写进 this.error 并返回 false,由调用方决定是否提示用户。
|
|
221
|
+
* @returns {Promise<boolean>}
|
|
222
|
+
*/
|
|
223
|
+
async start() {
|
|
224
|
+
try {
|
|
225
|
+
// PWD/OLDPWD 可能残留 POSIX 风格路径,子进程(cmd/pwsh/npx)拿它当 cwd 会炸
|
|
226
|
+
const env = { ...process.env, ...(this.config.env || {}) };
|
|
227
|
+
delete env.PWD;
|
|
228
|
+
delete env.OLDPWD;
|
|
229
|
+
|
|
230
|
+
const spec = buildSpawnSpec(this.config.command, this.config.args, env);
|
|
231
|
+
this.child = trackChild(spawn(spec.file, spec.argv, { cwd: this.cwd, stdio: ['pipe', 'pipe', 'pipe'], ...spec.options }));
|
|
232
|
+
|
|
233
|
+
// 提前挂 error 监听:ENOENT 之类的失败是异步抛的,不接住会变成 unhandled
|
|
234
|
+
const spawnFailed = new Promise((_, reject) => {
|
|
235
|
+
this.child.once('error', err => reject(new Error(`无法启动 ${this.config.command}: ${err.message}`)));
|
|
236
|
+
});
|
|
237
|
+
this.child.stdout.setEncoding('utf8');
|
|
238
|
+
this.child.stdout.on('data', chunk => this.#onData(chunk));
|
|
239
|
+
this.child.stderr.setEncoding('utf8');
|
|
240
|
+
this.child.stderr.on('data', chunk => {
|
|
241
|
+
this.stderr = (this.stderr + chunk).slice(-MAX_STDERR_CHARS);
|
|
242
|
+
});
|
|
243
|
+
this.child.on('exit', code => this.#onExit(code));
|
|
244
|
+
// 子进程先于握手退出时,让 request 立即失败而不是干等到超时
|
|
245
|
+
this.spawnFailed = spawnFailed;
|
|
246
|
+
spawnFailed.catch(() => {});
|
|
247
|
+
|
|
248
|
+
await this.#request('initialize', {
|
|
249
|
+
protocolVersion: '2024-11-05',
|
|
250
|
+
capabilities: {},
|
|
251
|
+
clientInfo: { name: 'zen-gitsync-g-ai', version: '1.0.0' },
|
|
252
|
+
}, INIT_TIMEOUT_MS);
|
|
253
|
+
this.#notify('notifications/initialized', {});
|
|
254
|
+
|
|
255
|
+
const listed = await this.#request('tools/list', {}, INIT_TIMEOUT_MS);
|
|
256
|
+
this.tools = (Array.isArray(listed?.tools) ? listed.tools : [])
|
|
257
|
+
.filter(tool => tool && typeof tool.name === 'string')
|
|
258
|
+
.map(tool => ({
|
|
259
|
+
serverId: this.id,
|
|
260
|
+
name: tool.name,
|
|
261
|
+
description: tool.description || '',
|
|
262
|
+
inputSchema: tool.inputSchema && typeof tool.inputSchema === 'object'
|
|
263
|
+
? tool.inputSchema
|
|
264
|
+
: { type: 'object', properties: {} },
|
|
265
|
+
}));
|
|
266
|
+
return true;
|
|
267
|
+
} catch (err) {
|
|
268
|
+
this.error = err.message;
|
|
269
|
+
await this.close();
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#onData(chunk) {
|
|
275
|
+
this.buffer += chunk;
|
|
276
|
+
// stdio 传输是 newline-delimited JSON(不是 LSP 的 Content-Length 分帧)
|
|
277
|
+
let index;
|
|
278
|
+
while ((index = this.buffer.indexOf('\n')) >= 0) {
|
|
279
|
+
const line = this.buffer.slice(0, index).trim();
|
|
280
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
281
|
+
if (!line) continue;
|
|
282
|
+
let message;
|
|
283
|
+
try { message = JSON.parse(line) } catch { continue }
|
|
284
|
+
if (message.id === undefined) continue; // 通知(如 notifications/message),忽略
|
|
285
|
+
const entry = this.pending.get(message.id);
|
|
286
|
+
if (!entry) continue;
|
|
287
|
+
this.pending.delete(message.id);
|
|
288
|
+
clearTimeout(entry.timer);
|
|
289
|
+
if (message.error) entry.reject(new Error(message.error.message || `MCP error ${message.error.code}`));
|
|
290
|
+
else entry.resolve(message.result);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#onExit(code) {
|
|
295
|
+
const reason = new Error(`MCP server "${this.id}" 已退出(code ${code})${this.stderr.trim() ? `: ${this.stderr.trim().split('\n').slice(-3).join(' ')}` : ''}`);
|
|
296
|
+
for (const entry of this.pending.values()) {
|
|
297
|
+
clearTimeout(entry.timer);
|
|
298
|
+
entry.reject(reason);
|
|
299
|
+
}
|
|
300
|
+
this.pending.clear();
|
|
301
|
+
if (!this.closed) this.error = reason.message;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#write(message) {
|
|
305
|
+
if (!this.child?.stdin?.writable) throw new Error(`MCP server "${this.id}" 不可写(可能已退出)`);
|
|
306
|
+
this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#notify(method, params) {
|
|
310
|
+
try { this.#write({ jsonrpc: '2.0', method, params }) } catch { /* 通知失败不影响主流程 */ }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#request(method, params, timeoutMs) {
|
|
314
|
+
const id = this.nextId++;
|
|
315
|
+
return new Promise((resolve, reject) => {
|
|
316
|
+
const timer = setTimeout(() => {
|
|
317
|
+
this.pending.delete(id);
|
|
318
|
+
reject(new Error(`MCP ${method} 超时(${Math.round(timeoutMs / 1000)}s)`));
|
|
319
|
+
}, timeoutMs);
|
|
320
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
321
|
+
try {
|
|
322
|
+
this.#write({ jsonrpc: '2.0', id, method, params });
|
|
323
|
+
} catch (err) {
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
this.pending.delete(id);
|
|
326
|
+
reject(err);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 调用一个工具,把 MCP 的 content 数组拍平成字符串回喂给模型。 */
|
|
332
|
+
async callTool(name, args) {
|
|
333
|
+
const result = await this.#request('tools/call', { name, arguments: args || {} }, CALL_TIMEOUT_MS);
|
|
334
|
+
return flattenToolResult(result);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async close() {
|
|
338
|
+
this.closed = true;
|
|
339
|
+
for (const entry of this.pending.values()) {
|
|
340
|
+
clearTimeout(entry.timer);
|
|
341
|
+
entry.reject(new Error('MCP 连接已关闭'));
|
|
342
|
+
}
|
|
343
|
+
this.pending.clear();
|
|
344
|
+
if (!this.child) return;
|
|
345
|
+
try { this.child.stdin?.end() } catch { /* noop */ }
|
|
346
|
+
const child = this.child;
|
|
347
|
+
this.child = null;
|
|
348
|
+
try { child.kill() } catch { /* noop */ }
|
|
349
|
+
// Windows 上 .cmd 是 cmd.exe 的孙子进程,kill 父进程不保证孙进程退出。
|
|
350
|
+
// 给一小段时间让 stdio 自然关闭,再兜底强杀。
|
|
351
|
+
await new Promise(resolve => {
|
|
352
|
+
if (child.exitCode !== null || child.signalCode !== null) return resolve();
|
|
353
|
+
const timer = setTimeout(() => {
|
|
354
|
+
try { child.kill('SIGKILL') } catch { /* noop */ }
|
|
355
|
+
resolve();
|
|
356
|
+
}, 400);
|
|
357
|
+
child.once('exit', () => { clearTimeout(timer); resolve(); });
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// MCP tools/call 返回 { content: [{type:'text',text} | {type:'image',...} | {type:'resource',...}], isError? }
|
|
363
|
+
function flattenToolResult(result) {
|
|
364
|
+
const parts = Array.isArray(result?.content) ? result.content : [];
|
|
365
|
+
const text = parts.map(part => {
|
|
366
|
+
if (!part || typeof part !== 'object') return '';
|
|
367
|
+
if (part.type === 'text') return part.text || '';
|
|
368
|
+
if (part.type === 'image') return `[image ${part.mimeType || ''} 已省略]`;
|
|
369
|
+
if (part.type === 'resource') return `[resource ${part.resource?.uri || ''}]`;
|
|
370
|
+
return JSON.stringify(part);
|
|
371
|
+
}).filter(Boolean).join('\n');
|
|
372
|
+
|
|
373
|
+
const body = text || (result === undefined ? '' : JSON.stringify(result));
|
|
374
|
+
if (result?.isError) return `错误: ${body || 'MCP 工具返回失败'}`;
|
|
375
|
+
// 单个工具回吐超大内容会挤占上下文,与 tools.js 的截断口径保持一致
|
|
376
|
+
return body.length > 8000 ? `${body.slice(0, 6000)}\n\n... [已截断 ${body.length - 6000} 字符]` : body;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ──────────────────────────────────────────────
|
|
380
|
+
// 管理器
|
|
381
|
+
// ──────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
export class McpManager {
|
|
384
|
+
constructor(connections = []) {
|
|
385
|
+
this.connections = connections;
|
|
386
|
+
/** @type {Map<string, { connection: McpConnection, remoteName: string }>} */
|
|
387
|
+
this.registry = new Map();
|
|
388
|
+
this.#index();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
#index() {
|
|
392
|
+
this.registry.clear();
|
|
393
|
+
for (const connection of this.connections) {
|
|
394
|
+
for (const tool of connection.tools) {
|
|
395
|
+
this.registry.set(`${MCP_TOOL_PREFIX}${connection.safeId}__${sanitizeToolName(tool.name)}`, {
|
|
396
|
+
connection,
|
|
397
|
+
remoteName: tool.name,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* 读取配置 → 逐个建连 → 汇总工具。
|
|
405
|
+
* 单个 server 失败只记录在状态里,不影响其他 server,也不影响 g ai 原有能力。
|
|
406
|
+
*
|
|
407
|
+
* @param {{ cwd?: string, globalFile?: string, servers?: object, sources?: Array<object>,
|
|
408
|
+
* onWarn?: (msg: string) => void }} [options]
|
|
409
|
+
* @returns {Promise<McpManager>}
|
|
410
|
+
*/
|
|
411
|
+
static async create({ cwd, globalFile, servers, sources, onWarn } = {}) {
|
|
412
|
+
let resolved = servers;
|
|
413
|
+
let resolvedSources = sources;
|
|
414
|
+
if (!resolved) {
|
|
415
|
+
const loaded = await loadMcpServers({ cwd, globalFile });
|
|
416
|
+
resolved = loaded.servers;
|
|
417
|
+
resolvedSources = loaded.sources;
|
|
418
|
+
}
|
|
419
|
+
const enabled = Object.entries(resolved).filter(([, cfg]) => !cfg.disabled);
|
|
420
|
+
|
|
421
|
+
const settled = await Promise.all(enabled.map(async ([id, cfg]) => {
|
|
422
|
+
const connection = new McpConnection(id, cfg, cwd || process.cwd());
|
|
423
|
+
const ok = await connection.start();
|
|
424
|
+
if (!ok && onWarn) onWarn(`MCP "${id}" 连接失败: ${connection.error}`);
|
|
425
|
+
return connection;
|
|
426
|
+
}));
|
|
427
|
+
|
|
428
|
+
const manager = new McpManager(settled);
|
|
429
|
+
manager.sources = resolvedSources;
|
|
430
|
+
return manager;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
get toolDefinitions() {
|
|
434
|
+
return this.connections.flatMap(connection => connection.tools.map(tool => ({
|
|
435
|
+
type: 'function',
|
|
436
|
+
function: {
|
|
437
|
+
name: `${MCP_TOOL_PREFIX}${connection.safeId}__${sanitizeToolName(tool.name)}`,
|
|
438
|
+
description: tool.description || `${tool.name} (MCP: ${connection.id})`,
|
|
439
|
+
parameters: tool.inputSchema,
|
|
440
|
+
},
|
|
441
|
+
})));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
get toolCount() {
|
|
445
|
+
return this.registry.size;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
has(toolName) {
|
|
449
|
+
return this.registry.has(toolName);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** 给系统提示词用的连接摘要。全部失败时返回空串。 */
|
|
453
|
+
describe({ locale } = {}) {
|
|
454
|
+
const ok = this.connections.filter(c => !c.error);
|
|
455
|
+
if (ok.length === 0) return '';
|
|
456
|
+
const zh = !String(locale || '').startsWith('en');
|
|
457
|
+
const lines = this.connections.map(connection => {
|
|
458
|
+
if (connection.error) {
|
|
459
|
+
return `- ${connection.id}: ${zh ? '连接失败' : 'failed'} — ${connection.error}`;
|
|
460
|
+
}
|
|
461
|
+
const names = connection.tools.map(t => t.name).join(', ') || (zh ? '(无工具)' : '(no tools)');
|
|
462
|
+
return `- ${connection.id}: ${connection.tools.length} ${zh ? '个工具' : 'tools'} — ${names}`;
|
|
463
|
+
});
|
|
464
|
+
return zh
|
|
465
|
+
? `\n\n# 已接入的 MCP 服务\n这些外部工具已挂到你的工具列表里(名字以 \`${MCP_TOOL_PREFIX}\` 开头),直接调用即可,不需要额外确认:\n${lines.join('\n')}`
|
|
466
|
+
: `\n\n# Connected MCP servers\nThese external tools are already in your tool list (names start with \`${MCP_TOOL_PREFIX}\`). Call them directly:\n${lines.join('\n')}`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** 连接状态(供 /mcp 之类的诊断命令使用) */
|
|
470
|
+
status() {
|
|
471
|
+
return this.connections.map(connection => ({
|
|
472
|
+
id: connection.id,
|
|
473
|
+
command: connection.config.command,
|
|
474
|
+
tools: connection.tools.length,
|
|
475
|
+
error: connection.error,
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* 调用一个 MCP 工具。未知工具名返回 null 让调用方回落到内置工具表。
|
|
481
|
+
*/
|
|
482
|
+
async call(toolName, args) {
|
|
483
|
+
const entry = this.registry.get(toolName);
|
|
484
|
+
if (!entry) return null;
|
|
485
|
+
try {
|
|
486
|
+
return await entry.connection.callTool(entry.remoteName, args);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
// 与 tools.js 的口径一致:工具错误变成字符串回喂,不中断 agent 循环
|
|
489
|
+
return `错误: MCP 工具 ${toolName} 执行失败: ${err.message}`;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async close() {
|
|
494
|
+
await Promise.all(this.connections.map(connection => connection.close()));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export default { McpManager, loadMcpServers, buildSpawnSpec, MCP_TOOL_PREFIX };
|