@vrs-soft/wecom-aibot-mcp 3.1.0 → 3.2.0

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.
@@ -12,6 +12,8 @@
12
12
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { z } from 'zod';
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
15
17
  import { execSync } from 'child_process';
16
18
  import { VERSION, installSkill } from './config-wizard.js';
17
19
  import { addPermissionHook, registerActiveProject, unregisterActiveProject, updateWechatModeConfig } from './project-config.js';
@@ -58,6 +60,105 @@ function findClaudePid(startPid) {
58
60
  }
59
61
  const MCP_URL = process.env.MCP_URL || 'http://127.0.0.1:18963';
60
62
  const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN;
63
+ // ============================================
64
+ // 文件传输辅助(v3.2.0)—— channel-server 本地 fs + HTTP 直传 daemon
65
+ // ============================================
66
+ const MIME_BY_EXT = {
67
+ '.md': 'text/markdown', '.txt': 'text/plain', '.json': 'application/json',
68
+ '.yaml': 'application/yaml', '.yml': 'application/yaml', '.toml': 'application/toml',
69
+ '.ts': 'text/typescript', '.js': 'text/javascript', '.py': 'text/x-python',
70
+ '.html': 'text/html', '.css': 'text/css', '.csv': 'text/csv',
71
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
72
+ '.webp': 'image/webp', '.svg': 'image/svg+xml',
73
+ '.pdf': 'application/pdf', '.zip': 'application/zip',
74
+ };
75
+ async function uploadFileToHttp(args) {
76
+ const abs = path.resolve(args.file_path);
77
+ if (!fs.existsSync(abs)) {
78
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'file_not_found', detail: abs }) }] };
79
+ }
80
+ let stat;
81
+ try {
82
+ stat = fs.statSync(abs);
83
+ }
84
+ catch (e) {
85
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'file_unreadable', detail: String(e) }) }] };
86
+ }
87
+ if (!stat.isFile()) {
88
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'not_a_file' }) }] };
89
+ }
90
+ const title = args.title || path.basename(abs);
91
+ const mime = args.mime_type || MIME_BY_EXT[path.extname(abs).toLowerCase()] || 'application/octet-stream';
92
+ const buf = fs.readFileSync(abs);
93
+ const endpoint = args.kind === 'document' ? '/api/v1/upload/document' : '/api/v1/upload/shared';
94
+ const headers = {
95
+ 'Content-Type': 'application/octet-stream',
96
+ 'Content-Length': String(buf.length),
97
+ 'X-Title': encodeURIComponent(title),
98
+ 'X-Mime': mime,
99
+ };
100
+ if (MCP_AUTH_TOKEN)
101
+ headers['Authorization'] = `Bearer ${MCP_AUTH_TOKEN}`;
102
+ if (args.ttl_seconds)
103
+ headers['X-TTL'] = String(args.ttl_seconds);
104
+ if (args.kind === 'document') {
105
+ headers['X-From-CC'] = args.cc_id;
106
+ headers['X-To-CC'] = args.to_cc || '';
107
+ }
108
+ else {
109
+ headers['X-Owner-CC'] = args.cc_id;
110
+ if (args.tags && args.tags.length > 0)
111
+ headers['X-Tags'] = args.tags.join(',');
112
+ }
113
+ try {
114
+ const res = await fetch(`${MCP_URL}${endpoint}`, { method: 'POST', headers, body: buf });
115
+ const text = await res.text();
116
+ if (!res.ok) {
117
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'upload_http_failed', status: res.status, body: text.slice(0, 500) }) }] };
118
+ }
119
+ return { content: [{ type: 'text', text }] };
120
+ }
121
+ catch (e) {
122
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'upload_network_failed', detail: String(e) }) }] };
123
+ }
124
+ }
125
+ async function downloadFileFromHttp(args) {
126
+ const savePath = path.resolve(args.save_path);
127
+ const endpoint = args.kind === 'document'
128
+ ? `/api/v1/download/document/${encodeURIComponent(args.id)}?cc=${encodeURIComponent(args.cc_id)}`
129
+ : `/api/v1/download/shared/${encodeURIComponent(args.id)}`;
130
+ const headers = {};
131
+ if (MCP_AUTH_TOKEN)
132
+ headers['Authorization'] = `Bearer ${MCP_AUTH_TOKEN}`;
133
+ try {
134
+ const res = await fetch(`${MCP_URL}${endpoint}`, { method: 'GET', headers });
135
+ if (!res.ok) {
136
+ const text = await res.text().catch(() => '');
137
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'download_http_failed', status: res.status, body: text.slice(0, 500) }) }] };
138
+ }
139
+ const arrayBuf = await res.arrayBuffer();
140
+ const buf = Buffer.from(arrayBuf);
141
+ const dir = path.dirname(savePath);
142
+ if (!fs.existsSync(dir))
143
+ fs.mkdirSync(dir, { recursive: true });
144
+ fs.writeFileSync(savePath, buf);
145
+ const stat = fs.statSync(savePath);
146
+ return {
147
+ content: [{
148
+ type: 'text',
149
+ text: JSON.stringify({
150
+ ok: true,
151
+ saved_path: savePath,
152
+ size: stat.size,
153
+ mime_type: res.headers.get('content-type') || undefined,
154
+ }),
155
+ }],
156
+ };
157
+ }
158
+ catch (e) {
159
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'download_network_failed', detail: String(e) }) }] };
160
+ }
161
+ }
61
162
  // 构建带 auth 的 fetch headers
62
163
  function getAuthHeaders() {
63
164
  const headers = {};
@@ -551,12 +652,59 @@ function registerChannelTools(server) {
551
652
  doc_id: z.string().describe('文档 ID'),
552
653
  save_as: z.string().optional().describe('可选:自定义文件名'),
553
654
  }, async (params) => forwardToHttpMcp('accept_document', params));
554
- server.tool('accept_shared_file', '从共享池下载文件并落盘到 {projectDir}/received-file/。共享池为 pull 模型,无强制询问。', {
655
+ server.tool('get_document_info', '查询单个文档的元数据(不返回 content)。与 fetch_document 同权限:仅 to_cc 可查。', {
656
+ cc_id: z.string().describe('自己的 CC 标识(必须 = 文档的 to_cc)'),
657
+ doc_id: z.string().describe('文档 ID'),
658
+ }, async (params) => forwardToHttpMcp('get_document_info', params));
659
+ server.tool('get_shared_file_info', '查询单个共享文件的元数据(不返回 content)。共享池无权限校验,任何 CC 都可查。', {
660
+ cc_id: z.string().describe('自己的 CC 标识'),
661
+ file_id: z.string().describe('共享文件 ID'),
662
+ }, async (params) => forwardToHttpMcp('get_shared_file_info', params));
663
+ server.tool('accept_shared_file', '【已废弃】仅本地 daemon 有效。远端 daemon 用 download_shared_file_to_path 替代。', {
555
664
  cc_id: z.string().describe('自己的 CC 标识'),
556
665
  file_id: z.string().describe('共享文件 ID'),
557
666
  save_as: z.string().optional().describe('可选:自定义文件名'),
558
667
  }, async (params) => forwardToHttpMcp('accept_shared_file', params));
559
668
  // ============================================
669
+ // 文件直传工具(v3.2.0)—— channel-server 本地读写 + HTTP 直传
670
+ // 解决远端 daemon 场景下 file_path/accept_* 不可用的 bug
671
+ // 文件字节全程不经 LLM context
672
+ // ============================================
673
+ server.tool('upload_document_from_file', 'CC 间发送文件给目标 CC:channel-server 本地读取 file_path → HTTP POST 字节流到 daemon → 目标 CC 收 cc_document_notify。文件全程不经 LLM context。', {
674
+ cc_id: z.string().describe('自己的 CC 标识'),
675
+ to_cc: z.string().describe('目标 CC 标识'),
676
+ file_path: z.string().describe('本地文件绝对路径(channel-server 进程能读到的路径)'),
677
+ title: z.string().optional().describe('可选:文档标题(默认用文件名)'),
678
+ mime_type: z.string().optional().describe('可选:MIME 类型(默认按扩展名推断)'),
679
+ ttl_seconds: z.number().int().min(60).max(86400).optional().describe('暂存秒数,默认 1800'),
680
+ }, async ({ cc_id, to_cc, file_path, title, mime_type, ttl_seconds }) => {
681
+ return uploadFileToHttp({ kind: 'document', cc_id, to_cc, file_path, title, mime_type, ttl_seconds });
682
+ });
683
+ server.tool('share_file_from_path', '共享本地文件到 daemon 共享池:channel-server 本地读取 file_path → HTTP POST 字节流到 daemon。文件全程不经 LLM context。', {
684
+ cc_id: z.string().describe('自己的 CC 标识(owner)'),
685
+ file_path: z.string().describe('本地文件绝对路径'),
686
+ title: z.string().optional().describe('可选:文件标题(默认用文件名)'),
687
+ mime_type: z.string().optional().describe('可选:MIME 类型'),
688
+ ttl_seconds: z.number().int().min(60).max(86400).optional().describe('暂存秒数,默认 1800'),
689
+ tags: z.array(z.string()).optional().describe('可选标签'),
690
+ }, async ({ cc_id, file_path, title, mime_type, ttl_seconds, tags }) => {
691
+ return uploadFileToHttp({ kind: 'shared', cc_id, file_path, title, mime_type, ttl_seconds, tags });
692
+ });
693
+ server.tool('download_document_to_path', '下载点对点文档到本地路径:channel-server 从 daemon HTTP GET 字节流 → 写入本地 save_path。文件全程不经 LLM context。', {
694
+ cc_id: z.string().describe('自己的 CC 标识(必须 = 文档的 to_cc)'),
695
+ doc_id: z.string().describe('文档 ID'),
696
+ save_path: z.string().describe('本地保存路径(绝对路径;目录会自动创建)'),
697
+ }, async ({ cc_id, doc_id, save_path }) => {
698
+ return downloadFileFromHttp({ kind: 'document', cc_id, id: doc_id, save_path });
699
+ });
700
+ server.tool('download_shared_file_to_path', '下载共享文件到本地路径:channel-server 从 daemon HTTP GET 字节流 → 写入本地 save_path。文件全程不经 LLM context。', {
701
+ cc_id: z.string().describe('自己的 CC 标识'),
702
+ file_id: z.string().describe('共享文件 ID'),
703
+ save_path: z.string().describe('本地保存路径(绝对路径;目录会自动创建)'),
704
+ }, async ({ cc_id, file_id, save_path }) => {
705
+ return downloadFileFromHttp({ kind: 'shared', cc_id, id: file_id, save_path });
706
+ });
707
+ // ============================================
560
708
  // 工具 4: 获取待处理消息
561
709
  // ============================================
562
710
  server.tool('get_pending_messages', '获取待处理的微信消息。支持长轮询:传入 timeout_ms 后阻塞等待,有消息立即返回,无消息等到超时。超时后继续轮询,不要停止。', {
@@ -845,7 +993,7 @@ export async function startChannelServer() {
845
993
  tools: {},
846
994
  },
847
995
  // 告知 Claude 如何处理 channel 事件
848
- instructions: '企业微信消息通过 <channel> 标签推送。属性说明:from=发送者userid, chatid=会话ID(单聊=用户ID,群聊=群ID), chattype=single|group, cc_id=当前会话标识。【强制规则1·用户消息】收到任何用户消息后,必须先执行步骤1再执行步骤2,禁止跳过:1) 立即发送确认 send_message(cc_id, "收到,正在处理...", target_user=chatid);2) 处理任务;3) 发送结果 send_message(cc_id, "【完成】...", target_user=chatid)。【强制规则2·CC 间文档】收到 <channel kind="document"> 通知(cc_document_notify)时,禁止擅自落盘:1) 立即 send_message 告知用户("CC <from_cc> 想发送文件「<title>」(<size>, <mime_type>),是否接收?"),target_user 取当前 cc 的 chatid;2) 等用户明确肯定回复(是/接受/yes/同意等);3) 同意后调 accept_document(cc_id, doc_id) 落盘到 {projectDir}/received-file/;4) 发送完成消息并附 saved_path;拒绝则忽略 doc_id 不调 fetch_document。共享池 share_file/fetch_shared_file 为 pull 模型,agent 主动决定,无需询问。',
996
+ instructions: '企业微信消息通过 <channel> 标签推送。属性说明:from=发送者userid, chatid=会话ID(单聊=用户ID,群聊=群ID), chattype=single|group, cc_id=当前会话标识。【强制规则1·用户消息】收到任何用户消息后,必须先执行步骤1再执行步骤2,禁止跳过:1) 立即发送确认 send_message(cc_id, "收到,正在处理...", target_user=chatid);2) 处理任务;3) 发送结果 send_message(cc_id, "【完成】...", target_user=chatid)。【强制规则2·CC 间文档】收到 <channel kind="document"> 通知(cc_document_notify)时,禁止擅自落盘:1) 立即 send_message 告知用户("CC <from_cc> 想发送文件「<title>」(<size>, <mime_type>),是否接收?"),target_user 取当前 cc 的 chatid;2) 等用户明确肯定回复(是/接受/yes/同意等);3) 同意后调 download_document_to_path(cc_id, doc_id, save_path) 落盘(save_path 推荐 {projectDir}/received-file/<title>,目录会自动创建);4) 发送完成消息并附 saved_path;拒绝则忽略 doc_id 不调任何下载工具。共享池 share_file_from_path/download_shared_file_to_path 为 pull 模型,agent 主动决定,无需询问。【重要】发送/接收文件请用 *_from_file / *_to_path 系列(文件字节走 channel-server 本地 fs + HTTP,不进 LLM context);旧的 upload_document/fetch_document/accept_document 仍可用但仅适合小内容(< 16KB),远端 daemon 部署下 accept_* 与 file_path 模式不可用。',
849
997
  });
850
998
  // 注册工具
851
999
  registerChannelTools(mcpServer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrs-soft/wecom-aibot-mcp",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "企业微信智能机器人 MCP 客户端 - 连接 wecom-aibot-server daemon",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",