@zythegit/jenkins-config-mcp 1.6.1 → 1.6.2

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 CHANGED
@@ -24,6 +24,50 @@ MCP Server 本体是 Python 实现,但发布物是 PyInstaller 打出的单文
24
24
 
25
25
  支持的平台:Windows x64、macOS x64 / arm64、Linux x64 / arm64。
26
26
 
27
+ ## 其他客户端
28
+
29
+ 上面那段 `mcpServers` 适用于 Claude Desktop / Claude Code / Cursor 等大多数客户端。以下三家键名或格式不同,照抄会配了不生效:
30
+
31
+ **Codex CLI** — `~/.codex/config.toml`(或受信任项目的 `.codex/config.toml`),TOML 格式,表名带下划线:
32
+
33
+ ```toml
34
+ [mcp_servers.jenkins-build]
35
+ command = "npx"
36
+ args = ["-y", "@zythegit/jenkins-config-mcp"]
37
+ env = { JENKINS_MCP_ALLOW_WRITE = "1" }
38
+ ```
39
+
40
+ 也可以 `codex mcp add jenkins-build --env JENKINS_MCP_ALLOW_WRITE=1 -- npx -y @zythegit/jenkins-config-mcp`,TUI 里用 `/mcp` 看连接状态。
41
+
42
+ **OpenCode** — `~/.config/opencode/opencode.json`(或项目根的 `opencode.json`)的 `mcp` 键下,`command` 是数组,环境变量键名是 `environment` 而非 `env`:
43
+
44
+ ```json
45
+ {
46
+ "$schema": "https://opencode.ai/config.json",
47
+ "mcp": {
48
+ "jenkins-build": {
49
+ "type": "local",
50
+ "command": ["npx", "-y", "@zythegit/jenkins-config-mcp"],
51
+ "environment": { "JENKINS_MCP_ALLOW_WRITE": "1" },
52
+ "enabled": true
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ **Pi** — 内核不带 MCP,得先装适配器扩展并重启 Pi:
59
+
60
+ ```bash
61
+ pi install npm:pi-mcp-adapter
62
+ ```
63
+
64
+ 之后写项目根的 `.mcp.json` 或全局 `~/.pi/agent/mcp.json`,格式与上面那段 `mcpServers` 完全一致,直接复制。装好后用 `/mcp` 面板看连接状态。
65
+
66
+ VS Code (Copilot) 的 `.vscode/mcp.json` 顶层键是 `servers` 而不是 `mcpServers`。逐客户端的完整说明见仓库内 `docs/mcp/README.md` §3.4。
67
+
68
+ 改完配置都要重启客户端,MCP 配置不热加载。
69
+
70
+
27
71
  ## 解析优先级
28
72
 
29
73
  1. `JENKINS_MCP_BINARY` — 直接指定二进制路径
@@ -32,6 +32,12 @@ const MODULE_ARGS = ['-m', 'jenkins_config.mcp.server'];
32
32
  const DEFAULT_PACKAGE =
33
33
  'jenkins-config[mcp] @ git+https://github.com/zyTheGit/jenkins-config.git';
34
34
 
35
+ // cmd.exe 需要转义的元字符。三处转义共用一份,避免改一处漏两处
36
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
37
+
38
+ // 主动关停时记录信号:既用于去重(避免重复杀树),也用于区分关停与真失败
39
+ let shutdownSignal = null;
40
+
35
41
  // 平台 → Release 资产名,与 .github/workflows/build.yml 的产物命名保持一致
36
42
  const ASSETS = {
37
43
  'win32-x64': 'jenkins-config-mcp-win-x64.exe',
@@ -50,6 +56,21 @@ function log(message) {
50
56
  process.stderr.write(`[jenkins-config-mcp] ${message}\n`);
51
57
  }
52
58
 
59
+ /**
60
+ * 判断路径是否指向一个存在的普通文件。
61
+ *
62
+ * @param {string} candidate 待检查路径
63
+ * @returns {boolean} 是文件则 true
64
+ */
65
+ function isExistingFile(candidate) {
66
+ try {
67
+ return fs.statSync(candidate).isFile();
68
+ } catch {
69
+ // 不存在或不可读
70
+ return false;
71
+ }
72
+ }
73
+
53
74
  /**
54
75
  * 在 PATH 中查找可执行文件(Windows 下按 PATHEXT 补全后缀)。
55
76
  *
@@ -60,26 +81,130 @@ function log(message) {
60
81
  */
61
82
  function which(name) {
62
83
  if (name.includes(path.sep) || name.includes('/')) {
63
- return fs.existsSync(name) ? name : null;
84
+ return isExistingFile(name) ? name : null;
85
+ }
86
+ let exts = [''];
87
+ if (process.platform === 'win32') {
88
+ exts = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
89
+ // 名字已自带 PATHEXT 后缀时(jenkins-config-mcp.cmd)要先按原名找,
90
+ // 否则只会去试 xxx.cmd.EXE 这类不存在的组合,明明在 PATH 上却找不到
91
+ if (exts.some((ext) => name.toLowerCase().endsWith(ext.toLowerCase()))) {
92
+ exts = [''].concat(exts);
93
+ }
64
94
  }
65
- const exts =
66
- process.platform === 'win32'
67
- ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
68
- : [''];
69
95
  for (const dir of (process.env.PATH || '').split(path.delimiter)) {
70
96
  if (!dir) continue;
71
97
  for (const ext of exts) {
72
98
  const candidate = path.join(dir, name + ext);
73
- try {
74
- if (fs.statSync(candidate).isFile()) return candidate;
75
- } catch {
76
- // 不存在或不可读,继续尝试下一个候选
77
- }
99
+ if (isExistingFile(candidate)) return candidate;
78
100
  }
79
101
  }
80
102
  return null;
81
103
  }
82
104
 
105
+ /**
106
+ * 判断命令是否是 Windows 批处理脚本(.cmd / .bat)。
107
+ *
108
+ * Node 18.20 / 20.12 起(CVE-2024-27980 加固)不再允许在 shell: false 下
109
+ * 直接 spawn 批处理文件,会同步抛出 EINVAL。而 pip / npm 在 Windows 上
110
+ * 生成的 console script shim 经常就是 .cmd,兜底分支正好会命中它。
111
+ *
112
+ * @param {string} command 待执行的命令路径
113
+ * @returns {boolean} 是批处理脚本则 true
114
+ */
115
+ function isBatchScript(command) {
116
+ return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
117
+ }
118
+
119
+ /**
120
+ * 转义交给 cmd.exe 的命令路径。
121
+ *
122
+ * 走 cmd.exe 是被迫的(批处理只能由它解释),因此必须自己把元字符挡掉,
123
+ * 不能让路径里的 `&` `^` 之类被当成命令分隔符。
124
+ *
125
+ * @param {string} value 命令路径
126
+ * @returns {string} 转义后的命令
127
+ */
128
+ function escapeCmdCommand(value) {
129
+ return String(value).replace(CMD_META, '^$1');
130
+ }
131
+
132
+ /**
133
+ * 转义交给 cmd.exe 的单个参数。
134
+ *
135
+ * 先按 Windows 的 argv 规则处理反斜杠与引号,再包引号,最后转义 cmd 元字符;
136
+ * 元字符转义做两遍 —— 批处理里的 `%*` 会被 cmd 二次解析,只转一遍在 shim
137
+ * 展开参数时又会被吃掉一层。
138
+ *
139
+ * @param {string} value 参数原文
140
+ * @returns {string} 转义后的参数
141
+ */
142
+ function escapeCmdArgument(value) {
143
+ let arg = String(value);
144
+ arg = arg.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"');
145
+ arg = arg.replace(/(?=(\\+?)?)\1$/, '$1$1');
146
+ arg = `"${arg}"`;
147
+ return arg.replace(CMD_META, '^$1').replace(CMD_META, '^$1');
148
+ }
149
+
150
+ /**
151
+ * 把批处理命令包装成一次 cmd.exe 调用。
152
+ *
153
+ * 不用 spawn 的 shell: true —— Node 24 起那条路径会打印 DEP0190 弃用警告,
154
+ * 且由 Node 拼接命令行时不会转义参数。这里自己拼好并置
155
+ * windowsVerbatimArguments,告诉 Node 命令行已经转义完毕。
156
+ *
157
+ * @param {string} command 批处理脚本路径
158
+ * @param {string[]} args 传给脚本的参数
159
+ * @returns {{command: string, args: string[]}} cmd.exe 调用形式
160
+ */
161
+ function wrapForCmd(command, args) {
162
+ const line = [escapeCmdCommand(command)].concat(args.map(escapeCmdArgument)).join(' ');
163
+ return { command: process.env.COMSPEC || 'cmd.exe', args: ['/d', '/s', '/c', `"${line}"`] };
164
+ }
165
+
166
+ /**
167
+ * 终止子进程(经 cmd.exe 转发时连同整棵进程树)。
168
+ *
169
+ * 走 cmd.exe 时 child 只是 cmd.exe 本身,真正的 MCP Server 是它的子进程。
170
+ * Windows 没有进程组信号,单杀 cmd.exe 会留下孤儿进程继续持有继承来的
171
+ * stdin/stdout,客户端会一直等一个没人回应的管道,只能用 taskkill /T 杀树。
172
+ *
173
+ * 只执行一次:taskkill 路径不会置位 child.killed,重复信号会拿着可能已被
174
+ * 复用的 PID 再杀一遍。
175
+ *
176
+ * @param {import('node:child_process').ChildProcess} child 子进程
177
+ * @param {string} sig 收到的信号名
178
+ * @param {boolean} viaCmd 是否经 cmd.exe 转发
179
+ */
180
+ function terminate(child, sig, viaCmd) {
181
+ if (shutdownSignal) return;
182
+ shutdownSignal = sig;
183
+
184
+ if (viaCmd && child.pid) {
185
+ try {
186
+ const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
187
+ stdio: 'ignore',
188
+ windowsHide: true,
189
+ });
190
+ // taskkill 不在 PATH 上(精简镜像)时退回单进程 kill,至少别静默什么都不做
191
+ killer.on('error', () => child.kill(sig));
192
+ // taskkill 起来了却没干成(拒绝访问、被安全软件拦截)同样要退回,
193
+ // 否则进程树存活而这里既无日志也无动作 —— child.kill 对已退出进程是空操作
194
+ killer.on('exit', (code) => {
195
+ if (code !== 0 && !child.killed) {
196
+ log(`taskkill 失败(退出码 ${code}),退回单进程 kill`);
197
+ child.kill(sig);
198
+ }
199
+ });
200
+ return;
201
+ } catch {
202
+ // spawn 同步抛错,同样退回单进程 kill
203
+ }
204
+ }
205
+ child.kill(sig);
206
+ }
207
+
83
208
  /**
84
209
  * 当前平台对应的 Release 资产名。
85
210
  *
@@ -282,33 +407,65 @@ async function main() {
282
407
  }
283
408
 
284
409
  const args = resolved.args.concat(process.argv.slice(2));
410
+ // 只有批处理 shim 才绕 cmd.exe —— 二进制与 python.exe 继续直接 spawn,
411
+ // 不让用户参数经过命令行解析器
412
+ const viaCmd = isBatchScript(resolved.command);
413
+ const launch = viaCmd ? wrapForCmd(resolved.command, args) : { command: resolved.command, args };
285
414
 
286
415
  if (process.env.JENKINS_MCP_LAUNCHER_DRYRUN) {
287
416
  process.stdout.write(
288
- JSON.stringify({ source: resolved.source, command: resolved.command, args }) + '\n'
417
+ JSON.stringify({
418
+ source: resolved.source,
419
+ command: resolved.command,
420
+ args,
421
+ via_cmd: viaCmd,
422
+ }) + '\n'
289
423
  );
290
424
  return;
291
425
  }
292
426
 
293
- const child = spawn(resolved.command, args, {
427
+ // 经 cmd.exe 转发后,脚本不存在只会让 cmd 自己报错并返回 1,spawn 的
428
+ // 'error' 事件不再触发,所以这里先自己判一次,保证仍有一行归因日志。
429
+ // 用 which() 判定而不是直接 existsSync:命令可能是裸名(如
430
+ // JENKINS_MCP_BINARY=foo.cmd),那种情况 cmd.exe 会按 PATH 找,不能按 CWD 否掉
431
+ if (viaCmd && !which(resolved.command)) {
432
+ log(`启动失败 (${resolved.command}): 文件不存在`);
433
+ process.exit(1);
434
+ }
435
+
436
+ const child = spawn(launch.command, launch.args, {
294
437
  stdio: 'inherit',
295
438
  shell: false,
296
439
  windowsHide: true,
440
+ windowsVerbatimArguments: viaCmd,
297
441
  });
298
442
 
299
443
  child.on('error', (err) => {
300
- log(`启动失败 (${resolved.command}): ${err.message}`);
444
+ // 转发时 spawn 的对象是 cmd.exe,两个路径都打出来才能区分是 COMSPEC 还是 shim 的问题
445
+ log(`启动失败 (${launch.command}${viaCmd ? ` → ${resolved.command}` : ''}): ${err.message}`);
301
446
  process.exit(1);
302
447
  });
303
448
 
304
449
  for (const sig of ['SIGINT', 'SIGTERM']) {
305
450
  process.on(sig, () => {
306
- if (!child.killed) child.kill(sig);
451
+ if (!child.killed) terminate(child, sig, viaCmd);
307
452
  });
308
453
  }
309
454
 
310
455
  child.on('exit', (code, signal) => {
456
+ // 主动关停:taskkill /F 会让 cmd.exe 带非零码退出,不能报成失败。
457
+ // 重新抛信号前先摘掉自己的 listener,否则被自己接住,进程反而退不掉
458
+ if (shutdownSignal) {
459
+ process.removeAllListeners(shutdownSignal);
460
+ if (process.platform === 'win32') {
461
+ // Windows 没有真正的信号投递,process.kill 自己等于 TerminateProcess
462
+ process.exit(0);
463
+ }
464
+ process.kill(process.pid, shutdownSignal);
465
+ return;
466
+ }
311
467
  if (signal) {
468
+ process.removeAllListeners(signal);
312
469
  process.kill(process.pid, signal);
313
470
  return;
314
471
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zythegit/jenkins-config-mcp",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "npx launcher for the jenkins-config MCP Server (Python)",
5
5
  "bin": {
6
6
  "jenkins-config-mcp": "bin/jenkins-config-mcp.js"