@vrs-soft/wecom-aibot-mcp 3.1.1 → 3.2.1
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/dist/channel-server.js +149 -2
- package/package.json +1 -1
package/dist/channel-server.js
CHANGED
|
@@ -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 = {};
|
|
@@ -381,6 +482,7 @@ function connectSSE(ccId) {
|
|
|
381
482
|
params: {
|
|
382
483
|
content: message.content || JSON.stringify(msg),
|
|
383
484
|
meta: {
|
|
485
|
+
msgid: message.msgid || '',
|
|
384
486
|
from: message.from || '',
|
|
385
487
|
chatid: message.chatid || '',
|
|
386
488
|
chattype: message.chattype || 'single',
|
|
@@ -483,6 +585,12 @@ function registerChannelTools(server) {
|
|
|
483
585
|
// ============================================
|
|
484
586
|
// 工具 4a: CC 间通信 — send_to_cc / list_active_ccs(v2.6.0+)
|
|
485
587
|
// ============================================
|
|
588
|
+
server.tool('send_thinking', 'wecom 流式回复占位符。在用户原会话气泡里追加"思考中"文字,让用户立即看到反馈。仅对最近 60s 的 msgid 有效。', {
|
|
589
|
+
cc_id: z.string().describe('自己的 CC 标识'),
|
|
590
|
+
msgid: z.string().describe('用户消息的 msgid(来自 channel notification meta.msgid)'),
|
|
591
|
+
content: z.string().optional().describe('占位文字,默认"💭 正在处理..."'),
|
|
592
|
+
finished: z.boolean().optional().describe('是否最终回复'),
|
|
593
|
+
}, async (params) => forwardToHttpMcp('send_thinking', params));
|
|
486
594
|
server.tool('send_to_cc', '向同一 daemon 上的另一个 CC 发送消息。目标 CC 收到时会作为 <channel source="cc:..."> 推送唤醒。仅支持同 daemon 间互通。支持 attachments 内联小文档(每个 < 16 KB);大文档请改用 upload_document。', {
|
|
487
595
|
cc_id: z.string().describe('自己的 CC 标识'),
|
|
488
596
|
to_cc: z.string().describe('目标 CC 标识'),
|
|
@@ -559,12 +667,51 @@ function registerChannelTools(server) {
|
|
|
559
667
|
cc_id: z.string().describe('自己的 CC 标识'),
|
|
560
668
|
file_id: z.string().describe('共享文件 ID'),
|
|
561
669
|
}, async (params) => forwardToHttpMcp('get_shared_file_info', params));
|
|
562
|
-
server.tool('accept_shared_file', '
|
|
670
|
+
server.tool('accept_shared_file', '【已废弃】仅本地 daemon 有效。远端 daemon 用 download_shared_file_to_path 替代。', {
|
|
563
671
|
cc_id: z.string().describe('自己的 CC 标识'),
|
|
564
672
|
file_id: z.string().describe('共享文件 ID'),
|
|
565
673
|
save_as: z.string().optional().describe('可选:自定义文件名'),
|
|
566
674
|
}, async (params) => forwardToHttpMcp('accept_shared_file', params));
|
|
567
675
|
// ============================================
|
|
676
|
+
// 文件直传工具(v3.2.0)—— channel-server 本地读写 + HTTP 直传
|
|
677
|
+
// 解决远端 daemon 场景下 file_path/accept_* 不可用的 bug
|
|
678
|
+
// 文件字节全程不经 LLM context
|
|
679
|
+
// ============================================
|
|
680
|
+
server.tool('upload_document_from_file', 'CC 间发送文件给目标 CC:channel-server 本地读取 file_path → HTTP POST 字节流到 daemon → 目标 CC 收 cc_document_notify。文件全程不经 LLM context。', {
|
|
681
|
+
cc_id: z.string().describe('自己的 CC 标识'),
|
|
682
|
+
to_cc: z.string().describe('目标 CC 标识'),
|
|
683
|
+
file_path: z.string().describe('本地文件绝对路径(channel-server 进程能读到的路径)'),
|
|
684
|
+
title: z.string().optional().describe('可选:文档标题(默认用文件名)'),
|
|
685
|
+
mime_type: z.string().optional().describe('可选:MIME 类型(默认按扩展名推断)'),
|
|
686
|
+
ttl_seconds: z.number().int().min(60).max(86400).optional().describe('暂存秒数,默认 1800'),
|
|
687
|
+
}, async ({ cc_id, to_cc, file_path, title, mime_type, ttl_seconds }) => {
|
|
688
|
+
return uploadFileToHttp({ kind: 'document', cc_id, to_cc, file_path, title, mime_type, ttl_seconds });
|
|
689
|
+
});
|
|
690
|
+
server.tool('share_file_from_path', '共享本地文件到 daemon 共享池:channel-server 本地读取 file_path → HTTP POST 字节流到 daemon。文件全程不经 LLM context。', {
|
|
691
|
+
cc_id: z.string().describe('自己的 CC 标识(owner)'),
|
|
692
|
+
file_path: z.string().describe('本地文件绝对路径'),
|
|
693
|
+
title: z.string().optional().describe('可选:文件标题(默认用文件名)'),
|
|
694
|
+
mime_type: z.string().optional().describe('可选:MIME 类型'),
|
|
695
|
+
ttl_seconds: z.number().int().min(60).max(86400).optional().describe('暂存秒数,默认 1800'),
|
|
696
|
+
tags: z.array(z.string()).optional().describe('可选标签'),
|
|
697
|
+
}, async ({ cc_id, file_path, title, mime_type, ttl_seconds, tags }) => {
|
|
698
|
+
return uploadFileToHttp({ kind: 'shared', cc_id, file_path, title, mime_type, ttl_seconds, tags });
|
|
699
|
+
});
|
|
700
|
+
server.tool('download_document_to_path', '下载点对点文档到本地路径:channel-server 从 daemon HTTP GET 字节流 → 写入本地 save_path。文件全程不经 LLM context。', {
|
|
701
|
+
cc_id: z.string().describe('自己的 CC 标识(必须 = 文档的 to_cc)'),
|
|
702
|
+
doc_id: z.string().describe('文档 ID'),
|
|
703
|
+
save_path: z.string().describe('本地保存路径(绝对路径;目录会自动创建)'),
|
|
704
|
+
}, async ({ cc_id, doc_id, save_path }) => {
|
|
705
|
+
return downloadFileFromHttp({ kind: 'document', cc_id, id: doc_id, save_path });
|
|
706
|
+
});
|
|
707
|
+
server.tool('download_shared_file_to_path', '下载共享文件到本地路径:channel-server 从 daemon HTTP GET 字节流 → 写入本地 save_path。文件全程不经 LLM context。', {
|
|
708
|
+
cc_id: z.string().describe('自己的 CC 标识'),
|
|
709
|
+
file_id: z.string().describe('共享文件 ID'),
|
|
710
|
+
save_path: z.string().describe('本地保存路径(绝对路径;目录会自动创建)'),
|
|
711
|
+
}, async ({ cc_id, file_id, save_path }) => {
|
|
712
|
+
return downloadFileFromHttp({ kind: 'shared', cc_id, id: file_id, save_path });
|
|
713
|
+
});
|
|
714
|
+
// ============================================
|
|
568
715
|
// 工具 4: 获取待处理消息
|
|
569
716
|
// ============================================
|
|
570
717
|
server.tool('get_pending_messages', '获取待处理的微信消息。支持长轮询:传入 timeout_ms 后阻塞等待,有消息立即返回,无消息等到超时。超时后继续轮询,不要停止。', {
|
|
@@ -853,7 +1000,7 @@ export async function startChannelServer() {
|
|
|
853
1000
|
tools: {},
|
|
854
1001
|
},
|
|
855
1002
|
// 告知 Claude 如何处理 channel 事件
|
|
856
|
-
instructions: '企业微信消息通过 <channel> 标签推送。属性说明:from=发送者userid, chatid=会话ID(单聊=用户ID,群聊=群ID), chattype=single|group, cc_id
|
|
1003
|
+
instructions: '企业微信消息通过 <channel> 标签推送。属性说明:from=发送者userid, chatid=会话ID(单聊=用户ID,群聊=群ID), chattype=single|group, cc_id=当前会话标识, msgid=用户消息ID(用于 send_thinking 流式占位)。【强制规则1·用户消息】收到任何用户消息后,必须先执行步骤1再执行步骤2,禁止跳过:1) 立即发送确认 send_message(cc_id, "收到,正在处理...", target_user=chatid);2) 处理任务;3) 发送结果 send_message(cc_id, "【完成】...", target_user=chatid)。处理耗时较长时可选调 send_thinking(cc_id, msgid, content="...") 在用户原会话气泡里追加"思考中"占位,给用户视觉反馈(仅 60s 内有效)。【强制规则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 模式不可用。',
|
|
857
1004
|
});
|
|
858
1005
|
// 注册工具
|
|
859
1006
|
registerChannelTools(mcpServer);
|