zen-gitsync 2.17.46 → 2.17.48

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.
Files changed (70) hide show
  1. package/README.md +16 -2
  2. package/package.json +2 -1
  3. package/src/cli/ai/agent.js +1342 -1342
  4. package/src/cli/ai/context.js +431 -253
  5. package/src/cli/ai/context.test.js +376 -258
  6. package/src/cli/ai/mcp.js +323 -27
  7. package/src/cli/ai/mcp.test.js +202 -2
  8. package/src/cli/ai/runtime.test.js +7 -4
  9. package/src/cli/ai/turn.js +166 -166
  10. package/src/config.js +906 -871
  11. package/src/ui/public/assets/{AgentEngineSelector-D6rlSfMD.js → AgentEngineSelector-B829U5mr.js} +1 -1
  12. package/src/ui/public/assets/AgentView-r3_ClUmr.js +1 -0
  13. package/src/ui/public/assets/{AgentView-h2YnbB7J.css → AgentView-uHf-KMCH.css} +1 -1
  14. package/src/ui/public/assets/{AppVersionBadge-BnPsn1X5.js → AppVersionBadge-NtghusOI.js} +2 -2
  15. package/src/ui/public/assets/{BranchSelector-D30GJwUl.js → BranchSelector-D0claXAs.js} +1 -1
  16. package/src/ui/public/assets/{CommitForm-Dvg-RUZI.js → CommitForm-BIq6-sqk.js} +1 -1
  17. package/src/ui/public/assets/{CommonDialog-BzLau2RJ.js → CommonDialog-NqZURH2F.js} +1 -1
  18. package/src/ui/public/assets/EditorView-0T5LR5Hz.css +1 -0
  19. package/src/ui/public/assets/EditorView-Cny0lDhG.js +1 -0
  20. package/src/ui/public/assets/{FlowExecutionViewer-VL-rUElj.js → FlowExecutionViewer-DxDKzmaP.js} +1 -1
  21. package/src/ui/public/assets/{FlowOrchestrationWorkspace-BPNlHRVu.js → FlowOrchestrationWorkspace-B3CAJZ-U.js} +1 -1
  22. package/src/ui/public/assets/{LogList-COwGjQl6.js → LogList-D5sS4Il3.js} +1 -1
  23. package/src/ui/public/assets/{MindmapView-BVuqPD_H.js → MindmapView-6bc0EAJM.js} +1 -1
  24. package/src/ui/public/assets/{MonitorView-hqe-4xd0.js → MonitorView-BnUV4RaV.js} +1 -1
  25. package/src/ui/public/assets/{ProjectStartupButton-RBc-DJBR.js → ProjectStartupButton-Cbw079DB.js} +1 -1
  26. package/src/ui/public/assets/RecentDirectoriesChat-BCZsJR7n.js +1 -0
  27. package/src/ui/public/assets/RecentDirectoriesChat-THkqWEtO.css +1 -0
  28. package/src/ui/public/assets/{RemoteManagerDialog-D6Rbjchl.js → RemoteManagerDialog-BWWPEnSd.js} +1 -1
  29. package/src/ui/public/assets/{RemoteRepoCard-l0NpXgvB.js → RemoteRepoCard-BhA3-4PZ.js} +1 -1
  30. package/src/ui/public/assets/{SourceMapView-CeVystt0.js → SourceMapView-DHDmNzSh.js} +1 -1
  31. package/src/ui/public/assets/{SvgIcon-B-xDJQA1.js → SvgIcon-DcVasCdo.js} +1 -1
  32. package/src/ui/public/assets/{UserInputNode-DJglU68l.js → UserInputNode-B313MadX.js} +1 -1
  33. package/src/ui/public/assets/WorkbenchView-NCE_bk5a.js +20 -0
  34. package/src/ui/public/assets/WorkbenchView-rscs76HF.css +1 -0
  35. package/src/ui/public/assets/_plugin-vue_export-helper-Bsx6Zj3t.js +19 -0
  36. package/src/ui/public/assets/agentConversations-BAhGYmeY.js +8 -0
  37. package/src/ui/public/assets/{configStore-CMlW8sMa.js → configStore-DQSKV2_Y.js} +1 -1
  38. package/src/ui/public/assets/{dagre-DyI0XLXY.js → dagre-BmYeUlEa.js} +3 -3
  39. package/src/ui/public/assets/{element-plus-DrCk0pcV.js → element-plus-DTtm3XCd.js} +1 -1
  40. package/src/ui/public/assets/{flow-mindmap-Cx5RNYN9.js → flow-mindmap-JxOspgwx.js} +1 -1
  41. package/src/ui/public/assets/{index-u9LyUFkr.css → index-BgN6Z2X9.css} +1 -1
  42. package/src/ui/public/assets/{index-BQxIFopZ.js → index-DQuqmPuL.js} +9 -9
  43. package/src/ui/public/assets/{monaco-Dfcwm1aX.js → monaco-D3R9OM26.js} +1 -1
  44. package/src/ui/public/assets/{office-docx-C0unxEAm.js → office-docx-CO7mIaWO.js} +1 -1
  45. package/src/ui/public/assets/{office-excel-DPdyr_DJ.js → office-excel-gA4KEkK4.js} +1 -1
  46. package/src/ui/public/assets/{office-pptx-eojGAs9D.js → office-pptx-CQK4l_1Q.js} +1 -1
  47. package/src/ui/public/assets/{vendor-yntih_em.js → vendor-O17eRefg.js} +222 -222
  48. package/src/ui/public/assets/vendor-U3ZXMgeO.css +1 -0
  49. package/src/ui/public/assets/{vue-flow-De8mr_Fv.js → vue-flow-BG6ZF1It.js} +1 -1
  50. package/src/ui/public/index.html +13 -13
  51. package/src/ui/server/routes/config.js +1321 -1314
  52. package/src/ui/server/routes/workbench/agentChat.js +594 -571
  53. package/src/ui/server/routes/workbench/agentChatShared.test.js +302 -233
  54. package/src/ui/server/routes/workbench/agentMarketplace.js +65 -30
  55. package/src/ui/server/routes/workbench/agentMarketplace.test.js +28 -4
  56. package/src/ui/server/routes/workbench/agentRoutes.js +631 -631
  57. package/src/ui/server/routes/workbench/liveJobs.js +4 -0
  58. package/src/ui/server/routes/workbench/liveJobs.test.js +11 -0
  59. package/src/ui/server/routes/workbench/taskRunner.js +44 -7
  60. package/src/ui/server/routes/workbench/taskRunner.thinking.test.js +115 -0
  61. package/src/ui/public/assets/AgentView-BFRGoIVb.js +0 -1
  62. package/src/ui/public/assets/EditorView-8Rb4n-Wh.css +0 -1
  63. package/src/ui/public/assets/EditorView-D4ZQutRw.js +0 -1
  64. package/src/ui/public/assets/RecentDirectoriesChat-BxbpKrXc.css +0 -1
  65. package/src/ui/public/assets/RecentDirectoriesChat-DFljcYFH.js +0 -1
  66. package/src/ui/public/assets/WorkbenchView-BSQ87CDi.css +0 -1
  67. package/src/ui/public/assets/WorkbenchView-BpH5kNht.js +0 -20
  68. package/src/ui/public/assets/_plugin-vue_export-helper-Dz1ARW9a.js +0 -19
  69. package/src/ui/public/assets/agentConversations-B8nY45V4.js +0 -7
  70. package/src/ui/public/assets/vendor-CP-WuGZG.css +0 -1
package/src/cli/ai/mcp.js CHANGED
@@ -12,19 +12,32 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
  //
15
- // g ai 的 MCP(Model Context Protocol)客户端 — stdio 传输实现。
15
+ // g ai 的 MCP(Model Context Protocol)客户端 — 支持 stdio 与 Streamable HTTP 两种传输。
16
16
  //
17
17
  // 为什么自己实现而不是装 SDK:
18
- // 本包 dependencies 一直保持精简,而 stdio 这一层的协议面很小
19
- // (newline-delimited JSON-RPC + initialize / tools/list / tools/call 三个方法),
20
- // 自己写≈200 行,换取零依赖 + 完全可控的超时与降级行为。
18
+ // 本包 dependencies 一直保持精简,而这两种传输的协议面都很小
19
+ // (JSON-RPC + initialize / tools/list / tools/call 三个方法),自己写换取
20
+ // 零依赖 + 完全可控的超时与降级行为。
21
21
  //
22
22
  // 配置来源(两级,项目级覆盖全局同名 server):
23
23
  // 1) ~/.zen-gitsync/ai/mcp.json 「g ai 智能体」全局安装(对所有项目生效)
24
24
  // 2) <cwd>/.zen-gitsync/ai/mcp.json 项目级安装
25
25
  // 形状沿用生态通行的 mcpServers。项目级路径常量与广场安装端共用 src/paths.js 那一份;
26
26
  // 项目级**不再读** <cwd>/.mcp.json(那是 Claude Code 一系的约定路径,2026-09-30 迁出)。
27
- // { "mcpServers": { "github": { "command": "npx", "args": ["-y", "@..."], "env": {} } } }
27
+ //
28
+ // 两种条目形状,按有没有 command 自动区分(同一条目同时有两者时以 command 为准):
29
+ // { "github": { "command": "npx", "args": ["-y", "@..."], "env": {} } }
30
+ // { "todo": { "type": "http", "url": "https://.../api/mcp", "headers": { "Authorization": "Bearer ..." } } }
31
+ //
32
+ // HTTP 侧只说 Streamable HTTP(2025-03-26 起规范里的那套):
33
+ // POST JSON-RPC → 200 application/json 或 text/event-stream(取 id 匹配的那一帧);
34
+ // 响应头里的 Mcp-Session-Id 会被记住并在后续请求回传;关闭时尽力 DELETE 结束会话。
35
+ // 老式 "HTTP+SSE"(2024-11-05 的 GET /sse + POST endpoint)不在此列 —— 那种服务端
36
+ // 自己也在陆续迁走,真需要时用 mcp-remote 桥成 stdio 更省事。
37
+ //
38
+ // ⚠️ HTTPS 的证书链:fetch 走 Node 内置根证书,不读 Windows/macOS 证书库。
39
+ // 站点只发叶证书、中间证书没带全时,Node 会报 unable to verify the first certificate,
40
+ // 而浏览器/curl 都正常。这里**不擅自关校验**(那是安全红线),只在报错里把可操作的做法写清楚。
28
41
  //
29
42
  // ⚠️ Windows 上 spawn 的两个坑(已实测,不要"优化"掉):
30
43
  // 1) 带空格的参数在 shell:true 下会被 cmd 拆碎 ——
@@ -46,6 +59,11 @@ const IS_WIN = process.platform === 'win32';
46
59
  // 工具名前缀。OpenAI function calling 允许 [A-Za-z0-9_-],用双下划线分段。
47
60
  export const MCP_TOOL_PREFIX = 'mcp__';
48
61
 
62
+ // 握手时声明的协议版本。stdio 沿用旧值不动(现有 server 都是照这个档位对接的);
63
+ // HTTP 侧必须报 Streamable HTTP 那一版,否则部分服务端会按老式 HTTP+SSE 处理。
64
+ const STDIO_PROTOCOL_VERSION = '2024-11-05';
65
+ const HTTP_PROTOCOL_VERSION = '2025-03-26';
66
+
49
67
  // 初始化握手超时。
50
68
  // ⚠️ 别按"握手只需要几百毫秒"来定这个值:command 是 npx -y 时,**首次**会现场下载
51
69
  // 整个 server 包及其依赖。国内直连 registry(无代理)实测这一步能轻松吃掉 30s+,
@@ -144,6 +162,9 @@ export function buildSpawnSpec(command, args, env) {
144
162
  /**
145
163
  * 读取一个 mcp.json 形状的配置文件,返回 mcpServers 对象。
146
164
  * 文件不存在 / 坏了都只当"没有配置",不抛错 —— 一个坏文件不该让 g ai 起不来。
165
+ *
166
+ * 保留条件:有 command(stdio)或有 http(s) 的 url(Streamable HTTP)。
167
+ * 两者都没有的条目直接丢掉 —— 免得后面按 undefined 去 spawn 炸得莫名其妙。
147
168
  */
148
169
  async function readServersFile(file) {
149
170
  const raw = await fs.readFile(file, 'utf8').catch(() => '');
@@ -155,7 +176,9 @@ async function readServersFile(file) {
155
176
  const out = {};
156
177
  for (const [id, cfg] of Object.entries(servers)) {
157
178
  if (!cfg || typeof cfg !== 'object') continue;
158
- if (!cfg.command || typeof cfg.command !== 'string') continue;
179
+ const hasCommand = typeof cfg.command === 'string' && cfg.command.trim() !== '';
180
+ const hasUrl = typeof cfg.url === 'string' && /^https?:\/\//i.test(cfg.url.trim());
181
+ if (!hasCommand && !hasUrl) continue;
159
182
  out[id] = cfg;
160
183
  }
161
184
  return out;
@@ -187,11 +210,55 @@ export async function loadMcpServers({ cwd, globalFile = AI_MCP_FILE, projectFil
187
210
  // 单个 server 的连接
188
211
  // ──────────────────────────────────────────────
189
212
 
213
+ /**
214
+ * 这个条目该走哪种传输:有 command 就是 stdio(优先级高于 url),
215
+ * 否则 url 是 http(s) 时走 Streamable HTTP。
216
+ *
217
+ * @param {object} config
218
+ * @returns {'stdio'|'http'|null} null = 两边都不像,调用方应跳过
219
+ */
220
+ export function serverTransport(config) {
221
+ if (!config || typeof config !== 'object') return null;
222
+ if (typeof config.command === 'string' && config.command.trim() !== '') return 'stdio';
223
+ if (typeof config.url === 'string' && /^https?:\/\//i.test(config.url.trim())) return 'http';
224
+ return null;
225
+ }
226
+
190
227
  // 工具名会被拼进 OpenAI 的 function name,必须收敛到安全字符集
191
228
  function sanitizeToolName(name) {
192
229
  return String(name || '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 48);
193
230
  }
194
231
 
232
+ /**
233
+ * 两种传输共用的握手:initialize → notifications/initialized → tools/list。
234
+ * 请求/通知都通过参数注入,stdio 与 HTTP 各交各的实现。
235
+ *
236
+ * @param {{ id: string, protocolVersion: string,
237
+ * request: (method: string, params: object, timeoutMs: number) => Promise<any>,
238
+ * notify: (method: string, params: object) => any }} io
239
+ * @returns {Promise<Array<object>>} 归一化后的工具定义
240
+ */
241
+ async function handshake({ id, protocolVersion, request, notify }) {
242
+ await request('initialize', {
243
+ protocolVersion,
244
+ capabilities: {},
245
+ clientInfo: { name: 'zen-gitsync-g-ai', version: '1.0.0' },
246
+ }, INIT_TIMEOUT_MS);
247
+ await notify('notifications/initialized', {});
248
+
249
+ const listed = await request('tools/list', {}, INIT_TIMEOUT_MS);
250
+ return (Array.isArray(listed?.tools) ? listed.tools : [])
251
+ .filter(tool => tool && typeof tool.name === 'string')
252
+ .map(tool => ({
253
+ serverId: id,
254
+ name: tool.name,
255
+ description: tool.description || '',
256
+ inputSchema: tool.inputSchema && typeof tool.inputSchema === 'object'
257
+ ? tool.inputSchema
258
+ : { type: 'object', properties: {} },
259
+ }));
260
+ }
261
+
195
262
  class McpConnection {
196
263
  /**
197
264
  * @param {string} id - server 标识(配置里的键名)
@@ -216,6 +283,11 @@ class McpConnection {
216
283
  return sanitizeToolName(this.id);
217
284
  }
218
285
 
286
+ /** 传输类型。诊断(/mcp)与 UI 展示用,连接本身不依赖它。 */
287
+ get transport() {
288
+ return 'stdio';
289
+ }
290
+
219
291
  /**
220
292
  * 启动子进程并完成 initialize + tools/list 握手。
221
293
  * 任何失败都写进 this.error 并返回 false,由调用方决定是否提示用户。
@@ -246,24 +318,12 @@ class McpConnection {
246
318
  this.spawnFailed = spawnFailed;
247
319
  spawnFailed.catch(() => {});
248
320
 
249
- await this.#request('initialize', {
250
- protocolVersion: '2024-11-05',
251
- capabilities: {},
252
- clientInfo: { name: 'zen-gitsync-g-ai', version: '1.0.0' },
253
- }, INIT_TIMEOUT_MS);
254
- this.#notify('notifications/initialized', {});
255
-
256
- const listed = await this.#request('tools/list', {}, INIT_TIMEOUT_MS);
257
- this.tools = (Array.isArray(listed?.tools) ? listed.tools : [])
258
- .filter(tool => tool && typeof tool.name === 'string')
259
- .map(tool => ({
260
- serverId: this.id,
261
- name: tool.name,
262
- description: tool.description || '',
263
- inputSchema: tool.inputSchema && typeof tool.inputSchema === 'object'
264
- ? tool.inputSchema
265
- : { type: 'object', properties: {} },
266
- }));
321
+ this.tools = await handshake({
322
+ id: this.id,
323
+ protocolVersion: STDIO_PROTOCOL_VERSION,
324
+ request: (method, params, timeoutMs) => this.#request(method, params, timeoutMs),
325
+ notify: (method, params) => this.#notify(method, params),
326
+ });
267
327
  return true;
268
328
  } catch (err) {
269
329
  this.error = err.message;
@@ -360,6 +420,237 @@ class McpConnection {
360
420
  }
361
421
  }
362
422
 
423
+ // ──────────────────────────────────────────────
424
+ // Streamable HTTP 传输
425
+ // ──────────────────────────────────────────────
426
+
427
+ // Node 内置根证书不认站点证书链时的错误码 —— 单独识别出来,好把"该怎么修"写进提示
428
+ const TLS_ERROR_CODES = new Set([
429
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
430
+ 'SELF_SIGNED_CERT_IN_CHAIN',
431
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
432
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
433
+ 'CERT_HAS_EXPIRED',
434
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
435
+ ]);
436
+
437
+ function shortText(text, max) {
438
+ const clean = String(text ?? '').replace(/\s+/g, ' ').trim();
439
+ return clean.length > max ? `${clean.slice(0, max)}…` : clean;
440
+ }
441
+
442
+ /**
443
+ * 把 fetch 的失败翻译成用户能照着做的中文。
444
+ * 证书链那条是本项目实测踩过的坑:服务端只发叶证书时,浏览器和 curl 都正常,
445
+ * 唯独 Node 报 unable to verify the first certificate —— 只说 "fetch failed" 等于没说。
446
+ */
447
+ function describeHttpError(err, id) {
448
+ const message = err?.message || String(err);
449
+ // 已经翻译过的(#send 里翻过一次)不要二次加工成"连接 x 失败: 连接 x 失败: ..."
450
+ if (message.startsWith(`连接 "${id}"`)) return message;
451
+
452
+ const cause = err?.cause;
453
+ const code = cause?.code || '';
454
+ if (TLS_ERROR_CODES.has(code)) {
455
+ return `连接 "${id}" 失败:服务器证书链不被 Node 内置根证书信任(${code})。`
456
+ + '可任选一种办法:① 启动 g ai 前设 NODE_EXTRA_CA_CERTS=<站点 CA 文件路径>;'
457
+ + '② Node ≥ 22.15 时用 NODE_OPTIONS=--use-system-ca 改走系统证书库;'
458
+ + '③ 让服务端补齐中间证书。';
459
+ }
460
+ if (message === 'fetch failed' && (code || cause?.message)) {
461
+ // 优先用错误码:undici 的 cause.message 往往还是那句 "fetch failed",没有信息量
462
+ const detail = code || cause.message;
463
+ return `连接 "${id}" 失败: ${message} (${detail})`;
464
+ }
465
+ return `连接 "${id}" 失败: ${message}`;
466
+ }
467
+
468
+ /** SSE 帧 → JSON-RPC 消息。只认 data: 行,event: / id: / 注释行一律忽略。 */
469
+ function parseSseFrame(frame) {
470
+ const data = frame.split(/\r?\n/)
471
+ .filter(line => line.startsWith('data:'))
472
+ .map(line => line.slice(5).trimStart())
473
+ .join('\n');
474
+ if (!data.trim()) return null;
475
+ try { return JSON.parse(data) } catch { return null }
476
+ }
477
+
478
+ /**
479
+ * 一个远程 MCP 服务(Streamable HTTP)的连接。
480
+ * 对外接口与 stdio 版完全一致(start / tools / callTool / close / error),
481
+ * 上层 McpManager 不需要知道下面是怎么连的。
482
+ */
483
+ export class HttpConnection {
484
+ /**
485
+ * @param {string} id - server 标识(配置里的键名)
486
+ * @param {object} config - { url, headers?, disabled? }
487
+ * @param {string} cwd - 只是保持与 stdio 版的构造签名一致(HTTP 没有子进程)
488
+ */
489
+ constructor(id, config, cwd) {
490
+ this.id = id;
491
+ this.config = config;
492
+ this.cwd = cwd || process.cwd();
493
+ this.tools = [];
494
+ this.error = null;
495
+ // 与 stdio 版同形状:那边把子进程 stderr 留在这里,HTTP 没有对应物
496
+ this.stderr = '';
497
+ this.closed = false;
498
+ this.sessionId = '';
499
+ this.nextId = 1;
500
+ }
501
+
502
+ get safeId() { return sanitizeToolName(this.id) }
503
+
504
+ get transport() { return 'http' }
505
+
506
+ get endpoint() { return String(this.config.url || '').trim() }
507
+
508
+ /**
509
+ * 握手(initialize → notifications/initialized → tools/list)。
510
+ * 失败写进 this.error 并返回 false,由 McpManager 汇总,不影响其他 server。
511
+ */
512
+ async start() {
513
+ try {
514
+ if (!/^https?:\/\//i.test(this.endpoint)) {
515
+ throw new Error(`url 不合法: ${this.endpoint || '(空)'}`);
516
+ }
517
+ this.tools = await handshake({
518
+ id: this.id,
519
+ protocolVersion: HTTP_PROTOCOL_VERSION,
520
+ request: (method, params, timeoutMs) => this.#request(method, params, timeoutMs),
521
+ // 通知失败不拖垮握手(与 stdio 版一个态度),只在后台记账
522
+ notify: (method, params) => { this.#notify(method, params).catch(() => {}) },
523
+ });
524
+ return true;
525
+ } catch (err) {
526
+ this.error = describeHttpError(err, this.id);
527
+ this.closed = true;
528
+ return false;
529
+ }
530
+ }
531
+
532
+ #headers() {
533
+ const headers = {
534
+ 'Content-Type': 'application/json',
535
+ // 少写 text/event-stream 会被规范型实现判 406,这是 Streamable HTTP 的硬要求
536
+ 'Accept': 'application/json, text/event-stream',
537
+ };
538
+ for (const [key, value] of Object.entries(this.config.headers || {})) {
539
+ if (!key || value === undefined || value === null) continue;
540
+ headers[key] = String(value);
541
+ }
542
+ if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
543
+ return headers;
544
+ }
545
+
546
+ /** 发一条 JSON-RPC,返回 result(通知类无响应体时是 null);失败抛已翻译过的 Error。 */
547
+ async #send(payload, timeoutMs) {
548
+ if (this.closed) throw new Error('MCP 连接已关闭');
549
+ const controller = new AbortController();
550
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
551
+ try {
552
+ let response;
553
+ try {
554
+ response = await fetch(this.endpoint, {
555
+ method: 'POST',
556
+ headers: this.#headers(),
557
+ body: JSON.stringify(payload),
558
+ signal: controller.signal,
559
+ });
560
+ } catch (err) {
561
+ const failure = err?.name === 'AbortError'
562
+ ? new Error(`请求超时(${Math.round(timeoutMs / 1000)}s)`)
563
+ : err;
564
+ throw new Error(describeHttpError(failure, this.id));
565
+ }
566
+
567
+ const sessionId = response.headers.get('mcp-session-id');
568
+ if (sessionId) this.sessionId = sessionId;
569
+
570
+ if (!response.ok) {
571
+ const body = await response.text().catch(() => '');
572
+ throw new Error(`HTTP ${response.status}${body ? `: ${shortText(body, 300)}` : ''}`);
573
+ }
574
+
575
+ const contentType = String(response.headers.get('content-type') || '');
576
+ if (contentType.includes('text/event-stream')) {
577
+ return await this.#readSseResult(response, payload.id);
578
+ }
579
+
580
+ const text = await response.text();
581
+ if (!text.trim()) return null; // 202 Accepted:通知类请求没有响应体
582
+ let message;
583
+ try { message = JSON.parse(text) } catch {
584
+ throw new Error(`响应不是合法 JSON: ${shortText(text, 200)}`);
585
+ }
586
+ if (message?.error) throw new Error(message.error.message || `MCP error ${message.error.code}`);
587
+ return message?.result ?? null;
588
+ } finally {
589
+ clearTimeout(timer);
590
+ }
591
+ }
592
+
593
+ /**
594
+ * 读 SSE 响应,取 id 匹配的那一帧。
595
+ *
596
+ * 用流式 reader 而不是 await response.text():服务端完全可以在返回响应后继续推通知、
597
+ * 把流一直挂着,等整条流读完就会一路挂到超时。拿到目标帧立刻 cancel。
598
+ */
599
+ async #readSseResult(response, wantedId) {
600
+ const reader = response.body?.getReader?.();
601
+ if (!reader) return null;
602
+ const decoder = new TextDecoder();
603
+ let buffer = '';
604
+ let found = null;
605
+ try {
606
+ while (!found) {
607
+ const { value, done } = await reader.read();
608
+ if (done) break;
609
+ buffer += decoder.decode(value, { stream: true });
610
+ let index;
611
+ while ((index = buffer.search(/\r?\n\r?\n/)) >= 0) {
612
+ const frame = buffer.slice(0, index);
613
+ buffer = buffer.slice(index).replace(/^\r?\n\r?\n/, '');
614
+ const message = parseSseFrame(frame);
615
+ if (!message) continue;
616
+ // 服务器主动推的通知(没有 id)直接忽略
617
+ if (wantedId === undefined || message.id === wantedId) { found = message; break }
618
+ }
619
+ }
620
+ } finally {
621
+ reader.cancel().catch(() => {});
622
+ }
623
+ if (!found) return null;
624
+ if (found.error) throw new Error(found.error.message || `MCP error ${found.error.code}`);
625
+ return found.result ?? null;
626
+ }
627
+
628
+ #request(method, params, timeoutMs) {
629
+ const id = this.nextId++;
630
+ return this.#send({ jsonrpc: '2.0', id, method, params }, timeoutMs);
631
+ }
632
+
633
+ #notify(method, params) {
634
+ return this.#send({ jsonrpc: '2.0', method, params }, INIT_TIMEOUT_MS);
635
+ }
636
+
637
+ /** 调用一个工具。口径与 stdio 版一致:content 数组拍平成字符串回喂给模型。 */
638
+ async callTool(name, args) {
639
+ const result = await this.#request('tools/call', { name, arguments: args || {} }, CALL_TIMEOUT_MS);
640
+ return flattenToolResult(result);
641
+ }
642
+
643
+ async close() {
644
+ this.closed = true;
645
+ const url = this.endpoint;
646
+ if (!url || !this.sessionId) return;
647
+ // 规范里结束会话是 DELETE(带 session 头),不是所有服务端都实现 —— 尽力而为
648
+ try {
649
+ await fetch(url, { method: 'DELETE', headers: this.#headers(), signal: AbortSignal.timeout(5000) });
650
+ } catch { /* noop */ }
651
+ }
652
+ }
653
+
363
654
  // MCP tools/call 返回 { content: [{type:'text',text} | {type:'image',...} | {type:'resource',...}], isError? }
364
655
  function flattenToolResult(result) {
365
656
  const parts = Array.isArray(result?.content) ? result.content : [];
@@ -420,7 +711,10 @@ export class McpManager {
420
711
  const enabled = Object.entries(resolved).filter(([, cfg]) => !cfg.disabled);
421
712
 
422
713
  const settled = await Promise.all(enabled.map(async ([id, cfg]) => {
423
- const connection = new McpConnection(id, cfg, cwd || process.cwd());
714
+ // 有 command → 本地子进程;只有 url → 远程 Streamable HTTP(serverTransport 已判过一遍)
715
+ const connection = serverTransport(cfg) === 'http'
716
+ ? new HttpConnection(id, cfg, cwd || process.cwd())
717
+ : new McpConnection(id, cfg, cwd || process.cwd());
424
718
  const ok = await connection.start();
425
719
  if (!ok && onWarn) onWarn(`MCP "${id}" 连接失败: ${connection.error}`);
426
720
  return connection;
@@ -471,7 +765,9 @@ export class McpManager {
471
765
  status() {
472
766
  return this.connections.map(connection => ({
473
767
  id: connection.id,
474
- command: connection.config.command,
768
+ transport: connection.transport,
769
+ // http 型没有 command,退回显示 url —— 渲染端只认这一个字段
770
+ command: connection.config.command || connection.config.url || '',
475
771
  tools: connection.tools.length,
476
772
  error: connection.error,
477
773
  }));
@@ -496,4 +792,4 @@ export class McpManager {
496
792
  }
497
793
  }
498
794
 
499
- export default { McpManager, loadMcpServers, buildSpawnSpec, MCP_TOOL_PREFIX };
795
+ export default { McpManager, HttpConnection, loadMcpServers, buildSpawnSpec, serverTransport, MCP_TOOL_PREFIX };
@@ -12,17 +12,21 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
  //
15
- // MCP stdio 客户端的回归测试。
15
+ // MCP 客户端(stdio + Streamable HTTP)的回归测试。
16
16
  //
17
17
  // 这里最值钱的一条是 buildSpawnSpec 的转义用例 —— Windows 上
18
18
  // `spawn(cmd, args, { shell: true })` 会把含空格的参数拆碎(实测 C:\a b\proj
19
19
  // 会变成 "C:a" + "bproj"),而直接 spawn .cmd 又会被 Node 拒绝(EINVAL)。
20
20
  // 项目路径带空格是常态,这两条任一回归都会让"装了 MCP 但连不上"变成玄学问题。
21
+ //
22
+ // HTTP 那半边的重点在:响应可能是 JSON 也可能是 SSE、流可能一直挂着不关、
23
+ // session 头要回传 —— 任一条错都表现为"远程 MCP 连不上"。
21
24
 
22
25
  import { test } from 'node:test';
23
26
  import assert from 'node:assert/strict';
24
27
  import { promises as fs } from 'node:fs';
25
28
  import { spawn } from 'node:child_process';
29
+ import http from 'node:http';
26
30
  import os from 'node:os';
27
31
  import path from 'node:path';
28
32
 
@@ -33,7 +37,7 @@ process.env.HOME = sandboxHome;
33
37
  delete process.env.HOMEDRIVE;
34
38
  delete process.env.HOMEPATH;
35
39
 
36
- const { buildSpawnSpec, loadMcpServers, McpManager } = await import('./mcp.js');
40
+ const { buildSpawnSpec, loadMcpServers, McpManager, serverTransport } = await import('./mcp.js');
37
41
  const { AI_MCP_FILE } = await import('../../paths.js');
38
42
 
39
43
  const IS_WIN = process.platform === 'win32';
@@ -285,3 +289,199 @@ test('McpManager: 没有配置任何 server 时是空管理器,不影响内置
285
289
  await manager.close();
286
290
  }
287
291
  });
292
+
293
+ // ── Streamable HTTP 往返 ──────────────────────────────────────
294
+
295
+ test('serverTransport: 有 command 就是 stdio,只有 url 才是 http,都没有则两边都不是', () => {
296
+ assert.equal(serverTransport({ command: 'npx', args: ['-y', 'x'] }), 'stdio');
297
+ assert.equal(serverTransport({ type: 'http', url: 'https://example.com/mcp' }), 'http');
298
+ // 同时给了两者时以 command 为准 —— 本地子进程更可控,也是生态里的通行优先级
299
+ assert.equal(serverTransport({ command: 'npx', url: 'https://example.com/mcp' }), 'stdio');
300
+ assert.equal(serverTransport({ args: ['no-command-no-url'] }), null);
301
+ assert.equal(serverTransport({ url: 'ftp://example.com' }), null, '只认 http(s)');
302
+ assert.equal(serverTransport(null), null);
303
+ });
304
+
305
+ test('loadMcpServers: url 型(Streamable HTTP)条目不再被当成坏配置丢掉', async () => {
306
+ const project = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-mcp-http-merge-'));
307
+ const file = path.join(project, '.zen-gitsync', 'ai', 'mcp.json');
308
+ await fs.mkdir(path.dirname(file), { recursive: true });
309
+ await fs.writeFile(file, JSON.stringify({
310
+ mcpServers: {
311
+ remote: { type: 'http', url: 'https://example.com/api/mcp', headers: { Authorization: 'Bearer t' } },
312
+ local: { command: 'npx', args: ['-y', 'whatever'] },
313
+ broken: { args: ['no-command-no-url'] },
314
+ },
315
+ }), 'utf8');
316
+
317
+ const { servers } = await loadMcpServers({ cwd: project });
318
+ assert.equal(servers.remote?.url, 'https://example.com/api/mcp');
319
+ assert.equal(servers.local?.command, 'npx');
320
+ assert.equal(servers.broken, undefined, '两边都没有的条目仍要被丢弃');
321
+ });
322
+
323
+ // 一个最小可用的远程 MCP server。按 method 回 JSON 或 SSE;可要求客户端回传 session 头。
324
+ function startFakeHttpMcp({ sse = false, holdSse = false, session = 'sess-abc' } = {}) {
325
+ const state = { requests: [], session };
326
+ const server = http.createServer((req, res) => {
327
+ let body = '';
328
+ req.setEncoding('utf8');
329
+ req.on('data', chunk => { body += chunk });
330
+ req.on('end', () => {
331
+ if (req.method === 'DELETE') {
332
+ state.requests.push({ method: 'DELETE', headers: req.headers });
333
+ res.writeHead(204);
334
+ res.end();
335
+ return;
336
+ }
337
+ const message = JSON.parse(body || '{}');
338
+ state.requests.push({ method: message.method, headers: req.headers, message });
339
+
340
+ const reply = payload => {
341
+ const headers = { 'Content-Type': sse ? 'text/event-stream' : 'application/json' };
342
+ if (session && state.session && message.method === 'initialize') headers['Mcp-Session-Id'] = state.session;
343
+ res.writeHead(200, headers);
344
+ const text = JSON.stringify(payload);
345
+ if (!sse) { res.end(text); return }
346
+ res.write(`event: message\ndata: ${text}\n\n`);
347
+ // holdSse:故意不结束流 —— 服务端完全有权利把连接挂着继续推通知,
348
+ // 客户端必须在拿到目标帧时就收手,而不是等流结束
349
+ if (!holdSse) res.end();
350
+ };
351
+
352
+ if (message.method === 'initialize') {
353
+ reply({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: '2025-03-26', capabilities: {}, serverInfo: { name: 'fake-http', version: '0' } } });
354
+ } else if (message.method === 'notifications/initialized') {
355
+ res.writeHead(202);
356
+ res.end();
357
+ } else if (message.method === 'tools/list') {
358
+ reply({ jsonrpc: '2.0', id: message.id, result: { tools: [
359
+ { name: 'echo', description: '回显输入', inputSchema: { type: 'object', properties: { text: { type: 'string' } } } },
360
+ { name: 'boom', description: '永远失败', inputSchema: { type: 'object' } },
361
+ ] } });
362
+ } else if (message.method === 'tools/call') {
363
+ if (message.params.name === 'boom') {
364
+ reply({ jsonrpc: '2.0', id: message.id, result: { isError: true, content: [{ type: 'text', text: '炸了' }] } });
365
+ } else {
366
+ reply({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: 'echo:' + (message.params.arguments?.text ?? '') }] } });
367
+ }
368
+ } else {
369
+ res.writeHead(404);
370
+ res.end();
371
+ }
372
+ });
373
+ });
374
+ return new Promise(resolve => {
375
+ server.listen(0, '127.0.0.1', () => {
376
+ resolve({
377
+ server,
378
+ state,
379
+ url: `http://127.0.0.1:${server.address().port}/mcp`,
380
+ stop: () => new Promise(done => {
381
+ server.closeAllConnections?.();
382
+ server.close(() => done());
383
+ }),
384
+ });
385
+ });
386
+ });
387
+ }
388
+
389
+ test('McpManager(http): 握手后挂成 mcp__<server>__<tool>,调用结果与 stdio 版同口径', async () => {
390
+ const fake = await startFakeHttpMcp();
391
+ const manager = await McpManager.create({
392
+ cwd: sandboxProject,
393
+ servers: { remote: { type: 'http', url: fake.url } },
394
+ onWarn: () => {},
395
+ });
396
+ try {
397
+ const status = manager.status();
398
+ assert.equal(status[0].error, null, `应连接成功,实际: ${status[0].error}`);
399
+ assert.equal(status[0].transport, 'http');
400
+ assert.equal(status[0].command, fake.url, '诊断里应回落到 url,而不是留个空 command');
401
+ assert.equal(manager.toolCount, 2);
402
+
403
+ const names = manager.toolDefinitions.map(tool => tool.function.name);
404
+ assert.ok(names.includes('mcp__remote__echo'), names.join(','));
405
+ assert.ok(names.includes('mcp__remote__boom'), names.join(','));
406
+
407
+ assert.equal(await manager.call('mcp__remote__echo', { text: '你好' }), 'echo:你好');
408
+ assert.match(await manager.call('mcp__remote__boom', {}), /^错误: 炸了/);
409
+ assert.equal(await manager.call('mcp__remote__nope', {}), null, '未知工具应回落到内置工具表');
410
+ } finally {
411
+ await manager.close();
412
+ await fake.stop();
413
+ }
414
+ });
415
+
416
+ test('McpManager(http): 自定义头透传、session 头回传、关闭时 DELETE 结束会话', async () => {
417
+ const fake = await startFakeHttpMcp();
418
+ const manager = await McpManager.create({
419
+ cwd: sandboxProject,
420
+ servers: { remote: { type: 'http', url: fake.url, headers: { Authorization: 'Bearer t0ken' } } },
421
+ onWarn: () => {},
422
+ });
423
+ try {
424
+ const init = fake.state.requests.find(item => item.method === 'initialize');
425
+ assert.equal(init.headers.authorization, 'Bearer t0ken', '配置里的 headers 必须原样发出去');
426
+ assert.match(init.headers.accept, /text\/event-stream/, 'Accept 必须同时声明 json 与 event-stream,否则会被判 406');
427
+
428
+ const list = fake.state.requests.find(item => item.method === 'tools/list');
429
+ assert.equal(list.headers['mcp-session-id'], 'sess-abc', 'initialize 给的 session 头要在后续请求回传');
430
+
431
+ await manager.call('mcp__remote__echo', { text: 'x' });
432
+ const call = fake.state.requests.find(item => item.method === 'tools/call');
433
+ assert.equal(call.headers['mcp-session-id'], 'sess-abc');
434
+
435
+ await manager.close();
436
+ const del = fake.state.requests.find(item => item.method === 'DELETE');
437
+ assert.ok(del, '关闭时应尽力 DELETE 结束会话');
438
+ assert.equal(del.headers['mcp-session-id'], 'sess-abc');
439
+ } finally {
440
+ await fake.stop();
441
+ }
442
+ });
443
+
444
+ test('McpManager(http): SSE 响应能取到结果,且服务端不关流也不会拖到超时', async () => {
445
+ const fake = await startFakeHttpMcp({ sse: true, holdSse: true });
446
+ const started = Date.now();
447
+ const manager = await McpManager.create({
448
+ cwd: sandboxProject,
449
+ servers: { remote: { type: 'http', url: fake.url } },
450
+ onWarn: () => {},
451
+ });
452
+ try {
453
+ assert.equal(manager.toolCount, 2, 'SSE 响应里的 tools/list 也要能解析出来');
454
+ assert.equal(await manager.call('mcp__remote__echo', { text: 'sse' }), 'echo:sse');
455
+ assert.ok(Date.now() - started < 5000, `拿到目标帧就该收手,不该等流结束,实际耗时 ${Date.now() - started}ms`);
456
+ } finally {
457
+ await manager.close();
458
+ await fake.stop();
459
+ }
460
+ });
461
+
462
+ test('McpManager(http): 远程连不上只记错误,不影响其他 server 与内置工具', async () => {
463
+ const dead = await startFakeHttpMcp();
464
+ const deadUrl = dead.url;
465
+ await dead.stop(); // 端口关掉,模拟服务端不在
466
+
467
+ const manager = await McpManager.create({
468
+ cwd: sandboxProject,
469
+ servers: {
470
+ down: { type: 'http', url: deadUrl },
471
+ other: { args: ['no-command-no-url'] }, // 无效条目,连传输都判不出来
472
+ },
473
+ onWarn: () => {},
474
+ });
475
+ try {
476
+ const status = manager.status();
477
+ const down = status.find(item => item.id === 'down');
478
+ assert.ok(down.error, '连不上必须有可读错误');
479
+ assert.match(down.error, /连接 "down" 失败/, `错误应指明是哪个 server,实际: ${down.error}`);
480
+ assert.match(down.error, /ECONNREFUSED|fetch failed/, `错误应带上底层原因,实际: ${down.error}`);
481
+ assert.equal(down.tools, 0);
482
+ assert.equal(manager.toolCount, 0);
483
+ assert.equal(await manager.call('mcp__down__echo', {}), null);
484
+ } finally {
485
+ await manager.close();
486
+ }
487
+ });