@vrs-soft/wecom-aibot-mcp 3.4.2 → 3.4.4

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.
@@ -208,6 +208,9 @@ let sseConnected = false;
208
208
  let sseAbortController = null;
209
209
  let mcpServer = null;
210
210
  let sseCurrentCcId = undefined;
211
+ // v3.4.3: 暴露给 verify_sse_health 工具,判断"是否假在线"
212
+ let sseLastChunkAt = 0;
213
+ let sseWatchdogActive = false;
211
214
  // 保存首次 enter_headless_mode 的参数,重连时原样复用
212
215
  let sseRobotId = undefined;
213
216
  let sseProjectDir = undefined;
@@ -361,6 +364,7 @@ function connectSSE(ccId) {
361
364
  clearInterval(watchdogTimer);
362
365
  watchdogTimer = null;
363
366
  }
367
+ sseWatchdogActive = false; // v3.4.3
364
368
  };
365
369
  // SSE fetch 配置:添加 keep-alive headers 确保连接稳定
366
370
  fetch(sseUrl, {
@@ -407,6 +411,7 @@ function connectSSE(ccId) {
407
411
  let buffer = '';
408
412
  let messageCount = 0;
409
413
  let lastChunkAt = Date.now();
414
+ sseLastChunkAt = lastChunkAt; // v3.4.3: 暴露到模块作用域
410
415
  let currentEvent = 'message'; // SSE event type,由 `event: xxx` 行设置;空行复位
411
416
  // Watchdog:>45s 没收到任何 chunk(含 daemon 端的 `: heartbeat` 注释)
412
417
  // 视为单向 TCP 死链,主动 abort 让 catch 分支触发 reconnect。
@@ -421,11 +426,13 @@ function connectSSE(ccId) {
421
426
  catch { /* ignore */ }
422
427
  }
423
428
  }, 15000);
429
+ sseWatchdogActive = true; // v3.4.3
424
430
  while (true) {
425
431
  const { done, value } = await reader.read();
426
432
  if (done) {
427
433
  logChannel('SSE stream ended');
428
434
  clearWatchdog();
435
+ sseWatchdogActive = false; // v3.4.3
429
436
  sseConnected = false;
430
437
  // 非主动断开时自动重连
431
438
  if (!sseAbortController?.signal.aborted) {
@@ -435,6 +442,7 @@ function connectSSE(ccId) {
435
442
  break;
436
443
  }
437
444
  lastChunkAt = Date.now();
445
+ sseLastChunkAt = lastChunkAt; // v3.4.3
438
446
  const chunk = decoder.decode(value, { stream: true });
439
447
  logChannel('SSE chunk received', { bytes: chunk.length, preview: chunk.slice(0, 100) });
440
448
  buffer += chunk;
@@ -609,6 +617,108 @@ function registerChannelTools(server) {
609
617
  return forwardToHttpMcp('check_connection', {});
610
618
  });
611
619
  // ============================================
620
+ // 工具 4b: verify_sse_health(v3.4.3)—— 比对本地 SSE 状态 vs daemon 注册表
621
+ // 用于 "CC 自己以为还连着但 daemon 已经看不到" 的 split-brain 诊断
622
+ // ============================================
623
+ server.tool('verify_sse_health', '比对本地 channel-server 的 SSE 状态和远端 daemon 注册表,诊断"假在线/假离线"问题。auto_fix=true 时检测到 split-brain 自动 abort 触发重连。', {
624
+ cc_id: z.string().describe('要检查的 CC 标识(必填,多 CC 部署下唯一确认目标)'),
625
+ auto_fix: z.boolean().optional().default(false).describe('为 true 时若发现 stale_local 自动 abort SSE 触发重连'),
626
+ }, async ({ cc_id, auto_fix }) => {
627
+ const now = Date.now();
628
+ const localIdleMs = sseLastChunkAt ? now - sseLastChunkAt : -1;
629
+ const localView = {
630
+ sseConnected,
631
+ idleMs: localIdleMs,
632
+ watchdogActive: sseWatchdogActive,
633
+ ccId: sseCurrentCcId || null,
634
+ };
635
+ // 调 daemon list_active_ccs 看注册表里有没有 ccId(/state 没暴露 ccIdRegistry,
636
+ // 但 list_active_ccs 走 MCP 转发,返回结构含 lastOnline)
637
+ let remoteView = { reachable: false, error: 'unknown' };
638
+ try {
639
+ const listRes = await forwardToHttpMcp('list_active_ccs', { cc_id });
640
+ const text = listRes?.content?.[0]?.text;
641
+ const data = text ? JSON.parse(text) : {};
642
+ // list_active_ccs.self 是 caller 自己,data.ccs 是其他在线 CC
643
+ // 检查 cc_id 是不是 self(caller 在 registry 自然没问题,复用本地心跳新鲜度)
644
+ // 或者在 ccs 列表里
645
+ const isSelf = data.self === cc_id;
646
+ const otherEntry = (data.ccs || []).find((e) => e?.ccId === cc_id);
647
+ if (isSelf) {
648
+ remoteView = {
649
+ reachable: true,
650
+ inRegistry: true,
651
+ note: 'cc_id is self — daemon 注册表里一定有,参考 local.idleMs 判断新鲜度',
652
+ lastOnlineMs: localIdleMs,
653
+ };
654
+ }
655
+ else if (otherEntry) {
656
+ remoteView = {
657
+ reachable: true,
658
+ inRegistry: true,
659
+ lastOnlineMs: otherEntry.lastOnline ? now - otherEntry.lastOnline : -1,
660
+ robotName: otherEntry.robotName,
661
+ mode: otherEntry.mode,
662
+ };
663
+ }
664
+ else {
665
+ remoteView = {
666
+ reachable: true,
667
+ inRegistry: false,
668
+ knownCcs: [data.self, ...(data.ccs || []).map((e) => e?.ccId)].filter(Boolean),
669
+ };
670
+ }
671
+ }
672
+ catch (e) {
673
+ remoteView = { reachable: false, error: e?.message || String(e) };
674
+ }
675
+ // verdict
676
+ let verdict;
677
+ let message;
678
+ if (!remoteView.reachable) {
679
+ verdict = 'remote_unreachable';
680
+ message = `daemon 不可达:${remoteView.error}。channel-server 本地 sseConnected=${sseConnected},但无法验证 daemon 视角。`;
681
+ }
682
+ else if (!localView.ccId) {
683
+ verdict = 'never_entered';
684
+ message = 'channel-server 这个进程从未调过 enter_headless_mode,没建 SSE。让 LLM 跑一次 enter_headless_mode 即可。';
685
+ }
686
+ else if (!remoteView.inRegistry) {
687
+ verdict = 'stale_remote';
688
+ message = `daemon 注册表里看不到 ${cc_id}(被 stale-clean 掉了,或从未注册)。需要重新调 enter_headless_mode 让 daemon 重新认你。`;
689
+ }
690
+ else if (!sseConnected || (localIdleMs > 0 && localIdleMs > 60000)) {
691
+ verdict = 'stale_local';
692
+ message = `channel-server 本地 SSE 死了(connected=${sseConnected}, idleMs=${localIdleMs})但 daemon 还以为你在线。watchdog 应该 45s 触发但显然没。需要重连。`;
693
+ }
694
+ else if (remoteView.lastOnlineMs > 60000) {
695
+ verdict = 'split_brain';
696
+ message = `本地心跳活(idleMs=${localIdleMs}ms)但 daemon 上 lastOnline 是 ${Math.round(remoteView.lastOnlineMs / 1000)}s 前——路由可能有 bug,但你的 SSE 应该还在工作。`;
697
+ }
698
+ else {
699
+ verdict = 'ok';
700
+ message = `健康:本地 idleMs=${localIdleMs}ms,daemon 上 lastOnline=${Math.round(remoteView.lastOnlineMs / 1000)}s 前。`;
701
+ }
702
+ let autoFixed = false;
703
+ if (auto_fix && (verdict === 'stale_local')) {
704
+ try {
705
+ logger.info('verify_sse_health auto_fix: aborting SSE to trigger reconnect', { ccId: cc_id });
706
+ sseAbortController?.abort();
707
+ autoFixed = true;
708
+ message += ' [auto_fix] 已触发 SSE abort,约 3 秒后 channel-server 自动重连。';
709
+ }
710
+ catch (e) {
711
+ message += ` [auto_fix 失败:${String(e)}]`;
712
+ }
713
+ }
714
+ return {
715
+ content: [{
716
+ type: 'text',
717
+ text: JSON.stringify({ local: localView, remote: remoteView, verdict, message, autoFixed }, null, 2),
718
+ }],
719
+ };
720
+ });
721
+ // ============================================
612
722
  // 工具 4a: CC 间通信 — send_to_cc / list_active_ccs(v2.6.0+)
613
723
  // ============================================
614
724
  server.tool('send_to_cc', '向同一 daemon 上的另一个 CC 发送消息。目标 CC 收到时会作为 <channel source="cc:..."> 推送唤醒。仅支持同 daemon 间互通。支持 attachments 内联小文档(每个 < 16 KB);大文档请改用 upload_document。', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrs-soft/wecom-aibot-mcp",
3
- "version": "3.4.2",
3
+ "version": "3.4.4",
4
4
  "description": "企业微信智能机器人 MCP 客户端 - 连接 wecom-aibot-server daemon",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -50,27 +50,34 @@ description: 当用户说「现在开始通过微信联系」时触发,进入
50
50
 
51
51
  ### 3. 调用 enter_headless_mode
52
52
 
53
+ **强制前置校验**(避免错绑机器人):
54
+ - `cc_id` 必须取自步骤 2 读到的配置文件 / 步骤 2.1 生成的项目名
55
+ - `robot_id` 必须取自步骤 2 读到的 `robotName` / 步骤 2.2 用户选定的机器人
56
+ - **禁止用字面量或默认值**填这两个参数。下面所有示例里的 `${...}` 都是占位符,**不是常量**
57
+
53
58
  使用步骤 1 确定的 MCP 前缀:
54
59
 
55
60
  ```
56
61
  ${MCP}enter_headless_mode(
57
- cc_id="<项目名>",
58
- robot_id="<机器人名称>",
59
- mode="<channel http>",
60
- project_dir="<项目目录>"
62
+ cc_id="${步骤 2 / 2.1 得到的 ccId}",
63
+ robot_id="${步骤 2 / 2.2 得到的 robotName}",
64
+ mode="${步骤 1 确定的模式}",
65
+ project_dir="${当前项目目录}"
61
66
  )
62
67
  ```
63
68
 
64
- 例如 Channel 模式:
69
+ 例如 Channel 模式(假设步骤 2 已读到 `ccId="知识库", robotName="公司助理"`):
65
70
  ```
66
- mcp__wecom-aibot-channel__enter_headless_mode(cc_id="知识库", robot_id="CC", mode="channel", project_dir="...")
71
+ mcp__wecom-aibot-channel__enter_headless_mode(cc_id="知识库", robot_id="公司助理", mode="channel", project_dir="...")
67
72
  ```
68
73
 
69
- 例如 HTTP 模式:
74
+ 例如 HTTP 模式(同前提):
70
75
  ```
71
- mcp__wecom-aibot__enter_headless_mode(cc_id="知识库", robot_id="CC", mode="http", project_dir="...")
76
+ mcp__wecom-aibot__enter_headless_mode(cc_id="知识库", robot_id="公司助理", mode="http", project_dir="...")
72
77
  ```
73
78
 
79
+ > ⚠️ **示例里的 `公司助理` 是占位文案**,不要照抄。真实值来自 `.claude/wecom-aibot.json` 的 `robotName`,或本次会话中用户用 AskUserQuestion 选定的机器人名。配置存在但读出 `robotName` 与示例不符是正常的 —— 用配置里的,不要用示例里的。
80
+
74
81
  ### 2. 处理返回结果
75
82
 
76
83
  **返回字段**: