@vrs-soft/wecom-aibot-mcp 1.5.0 → 2.3.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.
- package/README.md +50 -337
- package/dist/bin.js +172 -85
- package/dist/channel-server.d.ts +15 -0
- package/dist/channel-server.js +492 -0
- package/dist/channel-server.test.d.ts +5 -0
- package/dist/channel-server.test.js +324 -0
- package/dist/client-pool.js +4 -3
- package/dist/client.js +49 -49
- package/dist/config-wizard.d.ts +13 -3
- package/dist/config-wizard.js +424 -141
- package/dist/connection-log.js +7 -6
- package/dist/connection-manager.d.ts +0 -8
- package/dist/connection-manager.js +11 -23
- package/dist/daemon.js +7 -6
- package/dist/headless-state.d.ts +0 -12
- package/dist/headless-state.js +11 -35
- package/dist/http-server.d.ts +21 -2
- package/dist/http-server.js +421 -88
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/keepalive-monitor.js +5 -4
- package/dist/logger.d.ts +51 -0
- package/dist/logger.js +84 -0
- package/dist/message-bus.d.ts +13 -1
- package/dist/message-bus.js +56 -3
- package/dist/project-config.d.ts +16 -0
- package/dist/project-config.js +91 -12
- package/dist/tools/index.js +226 -57
- package/package.json +1 -1
- package/skills/headless-mode/SKILL.md +136 -181
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel MCP 透明代理服务器
|
|
3
|
+
*
|
|
4
|
+
* 作为 HTTP MCP 的透明代理 + SSE Channel 唤醒能力
|
|
5
|
+
*
|
|
6
|
+
* 核心职责:
|
|
7
|
+
* 1. 声明完整工具列表(和 HTTP MCP 完全一样)
|
|
8
|
+
* 2. 转发所有请求到 HTTP MCP(需要初始化 session)
|
|
9
|
+
* 3. enter_headless_mode 后建立 SSE 连接
|
|
10
|
+
* 4. SSE 消息 → notifications/claude/channel 唤醒 agent
|
|
11
|
+
*/
|
|
12
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import * as os from 'os';
|
|
18
|
+
const MCP_URL = process.env.MCP_URL || 'http://127.0.0.1:18963';
|
|
19
|
+
// Channel 日志文件
|
|
20
|
+
const CHANNEL_LOG_FILE = path.join(os.homedir(), '.wecom-aibot-mcp', 'channel.log');
|
|
21
|
+
/**
|
|
22
|
+
* 写入 Channel 日志
|
|
23
|
+
*/
|
|
24
|
+
function logChannel(message, data) {
|
|
25
|
+
const timestamp = new Date().toISOString();
|
|
26
|
+
const logLine = `[${timestamp}] ${message}${data ? ` | ${JSON.stringify(data)}` : ''}\n`;
|
|
27
|
+
// 写入日志文件
|
|
28
|
+
try {
|
|
29
|
+
fs.appendFileSync(CHANNEL_LOG_FILE, logLine);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
console.error(`[channel] 日志写入失败: ${err}`);
|
|
33
|
+
}
|
|
34
|
+
// 同时输出到 stderr
|
|
35
|
+
console.error(`[channel] ${message}${data ? ` | ${JSON.stringify(data).slice(0, 200)}` : ''}`);
|
|
36
|
+
}
|
|
37
|
+
// SSE 连接状态
|
|
38
|
+
let sseConnected = false;
|
|
39
|
+
let sseAbortController = null;
|
|
40
|
+
let mcpServer = null;
|
|
41
|
+
// HTTP MCP session ID(需要在转发请求前初始化)
|
|
42
|
+
let httpSessionId = null;
|
|
43
|
+
/**
|
|
44
|
+
* 初始化 HTTP MCP session
|
|
45
|
+
*/
|
|
46
|
+
async function initHttpSession() {
|
|
47
|
+
if (httpSessionId)
|
|
48
|
+
return httpSessionId;
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`${MCP_URL}/mcp`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
'Accept': 'application/json, text/event-stream',
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
jsonrpc: '2.0',
|
|
58
|
+
method: 'initialize',
|
|
59
|
+
params: {
|
|
60
|
+
protocolVersion: '2024-11-05',
|
|
61
|
+
capabilities: {},
|
|
62
|
+
clientInfo: { name: 'channel-proxy', version: '1.0' },
|
|
63
|
+
},
|
|
64
|
+
id: 1,
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
// 从响应 header 获取 session ID
|
|
68
|
+
const sessionId = res.headers.get('mcp-session-id');
|
|
69
|
+
if (sessionId) {
|
|
70
|
+
httpSessionId = sessionId;
|
|
71
|
+
logChannel('HTTP MCP session initialized', { sessionId });
|
|
72
|
+
return sessionId;
|
|
73
|
+
}
|
|
74
|
+
// SSE 响应可能没有 header,需要解析 body
|
|
75
|
+
const text = await res.text();
|
|
76
|
+
const match = text.match(/mcp-session-id:\s*(\S+)/i);
|
|
77
|
+
if (match) {
|
|
78
|
+
httpSessionId = match[1];
|
|
79
|
+
logChannel('HTTP MCP session from body', { sessionId: httpSessionId });
|
|
80
|
+
return httpSessionId;
|
|
81
|
+
}
|
|
82
|
+
logChannel('Failed to get HTTP MCP session ID');
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
logChannel('HTTP MCP init error', { error: String(err) });
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 转发请求到 HTTP MCP
|
|
92
|
+
*/
|
|
93
|
+
async function forwardToHttpMcp(toolName, params) {
|
|
94
|
+
logChannel('转发请求到 HTTP MCP', { toolName, params });
|
|
95
|
+
// 确保已初始化 HTTP session
|
|
96
|
+
const sessionId = await initHttpSession();
|
|
97
|
+
if (!sessionId) {
|
|
98
|
+
logChannel('转发失败: HTTP MCP session 未初始化');
|
|
99
|
+
return {
|
|
100
|
+
content: [{
|
|
101
|
+
type: 'text',
|
|
102
|
+
text: JSON.stringify({ error: 'Failed to initialize HTTP MCP session' }),
|
|
103
|
+
}],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const res = await fetch(`${MCP_URL}/mcp`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
'Accept': 'application/json, text/event-stream',
|
|
111
|
+
'mcp-session-id': sessionId,
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
jsonrpc: '2.0',
|
|
115
|
+
method: 'tools/call',
|
|
116
|
+
params: { name: toolName, arguments: params },
|
|
117
|
+
id: Date.now(),
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
// 解析 SSE 响应
|
|
121
|
+
const text = await res.text();
|
|
122
|
+
let data = null;
|
|
123
|
+
// SSE 格式: event: message\n data: {...}
|
|
124
|
+
// 需要处理多行 JSON(包含 \n 转义字符)
|
|
125
|
+
const lines = text.split('\n');
|
|
126
|
+
for (const line of lines) {
|
|
127
|
+
if (line.startsWith('data: ')) {
|
|
128
|
+
// 提取 data 后的 JSON(可能跨多行,但 MCP 响应通常是单行)
|
|
129
|
+
const jsonStr = line.slice(6);
|
|
130
|
+
try {
|
|
131
|
+
data = JSON.parse(jsonStr);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
// JSON 解析失败,可能需要合并多行
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// 如果 SSE 解析失败,尝试直接解析 JSON
|
|
140
|
+
if (!data) {
|
|
141
|
+
try {
|
|
142
|
+
data = JSON.parse(text);
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
logChannel('解析响应失败', { text: text.slice(0, 100) });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (data?.error) {
|
|
149
|
+
logChannel('HTTP MCP 返回错误', { error: data.error });
|
|
150
|
+
return {
|
|
151
|
+
content: [{
|
|
152
|
+
type: 'text',
|
|
153
|
+
text: JSON.stringify({ error: data.error.message || 'MCP request failed' }),
|
|
154
|
+
}],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// HTTP MCP 返回的 result(包含 content 数组)
|
|
158
|
+
logChannel('转发成功', { result: data.result });
|
|
159
|
+
return data.result || {
|
|
160
|
+
content: [{
|
|
161
|
+
type: 'text',
|
|
162
|
+
text: JSON.stringify({ error: 'Empty result from HTTP MCP' }),
|
|
163
|
+
}],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* 建立 SSE 连接(enter_headless_mode 后调用)
|
|
168
|
+
*/
|
|
169
|
+
function connectSSE(ccId) {
|
|
170
|
+
if (sseConnected) {
|
|
171
|
+
logChannel('SSE already connected, skip');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
sseConnected = true;
|
|
175
|
+
const sseUrl = ccId ? `${MCP_URL}/sse/${ccId}` : `${MCP_URL}/sse`;
|
|
176
|
+
logChannel('Connecting to SSE', { url: sseUrl, ccId, mcpServerReady: mcpServer ? 'yes' : 'no' });
|
|
177
|
+
sseAbortController = new AbortController();
|
|
178
|
+
// SSE fetch 配置:添加 keep-alive headers 确保连接稳定
|
|
179
|
+
fetch(sseUrl, {
|
|
180
|
+
method: 'GET',
|
|
181
|
+
signal: sseAbortController.signal,
|
|
182
|
+
headers: {
|
|
183
|
+
'Accept': 'text/event-stream',
|
|
184
|
+
'Cache-Control': 'no-cache',
|
|
185
|
+
'Connection': 'keep-alive',
|
|
186
|
+
},
|
|
187
|
+
}).then(async (res) => {
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
logChannel('SSE connect failed', { status: res.status });
|
|
190
|
+
sseConnected = false;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
logChannel('SSE connected, waiting for messages', { status: res.status });
|
|
194
|
+
const reader = res.body?.getReader();
|
|
195
|
+
if (!reader) {
|
|
196
|
+
logChannel('No response body');
|
|
197
|
+
sseConnected = false;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const decoder = new TextDecoder();
|
|
201
|
+
let buffer = '';
|
|
202
|
+
let messageCount = 0;
|
|
203
|
+
// 添加心跳监控
|
|
204
|
+
const heartbeatInterval = setInterval(() => {
|
|
205
|
+
logChannel('SSE heartbeat', { connected: sseConnected, messages: messageCount });
|
|
206
|
+
}, 30000);
|
|
207
|
+
while (true) {
|
|
208
|
+
const { done, value } = await reader.read();
|
|
209
|
+
if (done) {
|
|
210
|
+
logChannel('SSE stream ended');
|
|
211
|
+
clearInterval(heartbeatInterval);
|
|
212
|
+
sseConnected = false;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
216
|
+
logChannel('SSE chunk received', { bytes: chunk.length, preview: chunk.slice(0, 100) });
|
|
217
|
+
buffer += chunk;
|
|
218
|
+
// 解析 SSE 事件
|
|
219
|
+
const lines = buffer.split('\n');
|
|
220
|
+
buffer = '';
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
logChannel('SSE line', { line: line.slice(0, 80) });
|
|
223
|
+
if (line.startsWith('data: ')) {
|
|
224
|
+
const data = line.slice(6);
|
|
225
|
+
logChannel('📩 SSE MESSAGE RECEIVED', { data: data.slice(0, 100) });
|
|
226
|
+
try {
|
|
227
|
+
const msg = JSON.parse(data);
|
|
228
|
+
messageCount++;
|
|
229
|
+
logChannel('✅ 消息解析成功', { messageNumber: messageCount, msg });
|
|
230
|
+
// 推送 notifications/claude/channel 唤醒 Claude agent
|
|
231
|
+
if (mcpServer) {
|
|
232
|
+
// content 成为 <channel> 标签正文,meta 成为标签属性(只允许字母/数字/下划线)
|
|
233
|
+
const message = msg.message || {};
|
|
234
|
+
const notification = {
|
|
235
|
+
method: 'notifications/claude/channel',
|
|
236
|
+
params: {
|
|
237
|
+
content: message.content || JSON.stringify(msg),
|
|
238
|
+
meta: {
|
|
239
|
+
from: message.from || '',
|
|
240
|
+
chatid: message.chatid || '',
|
|
241
|
+
chattype: message.chattype || 'single',
|
|
242
|
+
cc_id: msg.ccId || '',
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
logChannel('📤 发送 notification', { notification });
|
|
247
|
+
try {
|
|
248
|
+
mcpServer.server.notification(notification);
|
|
249
|
+
logChannel('✅ NOTIFICATION 发送成功', { notification });
|
|
250
|
+
}
|
|
251
|
+
catch (notifyErr) {
|
|
252
|
+
logChannel('❌ NOTIFICATION 发送失败', { error: String(notifyErr) });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
logChannel('❌ ERROR: mcpServer is null');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
logChannel('JSON parse error', { error: String(e), data: data.slice(0, 50) });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else if (line.startsWith('event: ')) {
|
|
264
|
+
logChannel('SSE event type', { type: line.slice(7) });
|
|
265
|
+
}
|
|
266
|
+
else if (line === '') {
|
|
267
|
+
// 事件分隔符,忽略
|
|
268
|
+
}
|
|
269
|
+
else if (line.startsWith(':')) {
|
|
270
|
+
// SSE 注释(如 ": heartbeat"),忽略,不要写回 buffer
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
// 可能是跨行 JSON 的一部分
|
|
274
|
+
buffer = line;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
clearInterval(heartbeatInterval);
|
|
279
|
+
}).catch((err) => {
|
|
280
|
+
logChannel('SSE error', { error: String(err) });
|
|
281
|
+
sseConnected = false;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* 注册所有工具(和 HTTP MCP 完全一样)
|
|
286
|
+
*/
|
|
287
|
+
function registerChannelTools(server) {
|
|
288
|
+
// ============================================
|
|
289
|
+
// 工具 1: 发送文本消息
|
|
290
|
+
// ============================================
|
|
291
|
+
server.tool('send_message', '向企业微信发送消息(用于通知用户)。群聊时传入 chatid 可回复到群里。', {
|
|
292
|
+
content: z.string().describe('消息内容(支持 Markdown)'),
|
|
293
|
+
target_user: z.string().optional().describe('目标用户/群 ID(可选)'),
|
|
294
|
+
cc_id: z.string().describe('CC 唯一标识(enter_headless_mode 返回的 ccId)'),
|
|
295
|
+
}, async ({ content, target_user, cc_id }) => {
|
|
296
|
+
// 转发请求到 HTTP MCP,但拦截返回消息
|
|
297
|
+
await forwardToHttpMcp('send_message', { content, target_user, cc_id });
|
|
298
|
+
// 返回简单确认(不转发 HTTP MCP 的完整消息)
|
|
299
|
+
return {
|
|
300
|
+
content: [{ type: 'text', text: '✅ 消息已发送' }],
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
// ============================================
|
|
304
|
+
// 工具 2: 心跳检查(HTTP 模式)
|
|
305
|
+
// ============================================
|
|
306
|
+
server.tool('heartbeat_check', '心跳检查,提示智能体继续轮询(仅 HTTP 模式使用)', {}, async () => {
|
|
307
|
+
return forwardToHttpMcp('heartbeat_check', {});
|
|
308
|
+
});
|
|
309
|
+
// ============================================
|
|
310
|
+
// 工具 3: 保存心跳 job ID(HTTP 模式)
|
|
311
|
+
// ============================================
|
|
312
|
+
server.tool('update_heartbeat_job_id', '保存心跳定时任务 job ID 到配置文件(HTTP 模式用,/loop 创建后调用)', {
|
|
313
|
+
cc_id: z.string().describe('CC 唯一标识'),
|
|
314
|
+
job_id: z.string().describe('由 /loop 命令返回的 job ID'),
|
|
315
|
+
}, async ({ cc_id, job_id }) => {
|
|
316
|
+
return forwardToHttpMcp('update_heartbeat_job_id', { cc_id, job_id });
|
|
317
|
+
});
|
|
318
|
+
// ============================================
|
|
319
|
+
// 工具 4: 检查连接状态
|
|
320
|
+
// ============================================
|
|
321
|
+
server.tool('check_connection', '检查当前 WebSocket 连接状态', {}, async () => {
|
|
322
|
+
return forwardToHttpMcp('check_connection', {});
|
|
323
|
+
});
|
|
324
|
+
// ============================================
|
|
325
|
+
// 工具 4: 获取待处理消息
|
|
326
|
+
// ============================================
|
|
327
|
+
server.tool('get_pending_messages', '获取待处理的微信消息。支持长轮询:传入 timeout_ms 后阻塞等待,有消息立即返回,无消息等到超时。超时后继续轮询,不要停止。', {
|
|
328
|
+
clear: z.boolean().optional().default(true).describe('是否清除已获取的消息'),
|
|
329
|
+
timeout_ms: z.number().optional().default(30000).describe('长轮询超时(毫秒),默认 30000,最大 60000'),
|
|
330
|
+
cc_id: z.string().describe('CC 唯一标识(enter_headless_mode 返回的 ccId)'),
|
|
331
|
+
}, async ({ clear, timeout_ms, cc_id }) => {
|
|
332
|
+
return forwardToHttpMcp('get_pending_messages', { clear, timeout_ms, cc_id });
|
|
333
|
+
});
|
|
334
|
+
// ============================================
|
|
335
|
+
// 工具 5: 获取安装配置指南
|
|
336
|
+
// ============================================
|
|
337
|
+
server.tool('get_setup_guide', '获取企业微信 MCP 服务安装配置指南', {}, async () => {
|
|
338
|
+
return forwardToHttpMcp('get_setup_guide', {});
|
|
339
|
+
});
|
|
340
|
+
// ============================================
|
|
341
|
+
// 工具 6: 获取配置需求
|
|
342
|
+
// ============================================
|
|
343
|
+
server.tool('get_setup_requirements', '获取 MCP 配置需求,用于 skill 自动配置本地环境(权限、Hook、skill)。启动时调用检查配置是否完整。', {}, async () => {
|
|
344
|
+
return forwardToHttpMcp('get_setup_requirements', {});
|
|
345
|
+
});
|
|
346
|
+
// ============================================
|
|
347
|
+
// 工具 7: 添加新机器人配置
|
|
348
|
+
// ============================================
|
|
349
|
+
server.tool('add_robot_config', '添加新机器人配置', {
|
|
350
|
+
name: z.string().describe('机器人名称'),
|
|
351
|
+
bot_id: z.string().describe('企业微信 Bot ID'),
|
|
352
|
+
secret: z.string().describe('机器人密钥'),
|
|
353
|
+
default_user: z.string().optional().describe('默认目标用户'),
|
|
354
|
+
}, async ({ name, bot_id, secret, default_user }) => {
|
|
355
|
+
return forwardToHttpMcp('add_robot_config', { name, bot_id, secret, default_user });
|
|
356
|
+
});
|
|
357
|
+
// ============================================
|
|
358
|
+
// 工具 8: 列出所有机器人
|
|
359
|
+
// ============================================
|
|
360
|
+
server.tool('list_robots', '列出配置中的所有机器人。多 CC 可共享同一机器人,直接选择使用即可。', {}, async () => {
|
|
361
|
+
return forwardToHttpMcp('list_robots', {});
|
|
362
|
+
});
|
|
363
|
+
// ============================================
|
|
364
|
+
// 工具 9: 进入 headless 模式(关键:建立 SSE 连接)
|
|
365
|
+
// ============================================
|
|
366
|
+
server.tool('enter_headless_mode', '进入微信模式,建立 WebSocket 连接。当用户说「现在开始通过微信联系」时调用。', {
|
|
367
|
+
agent_name: z.string().optional().describe('智能体/项目名称(用于生成 ccId,如项目名)'),
|
|
368
|
+
cc_id: z.string().optional().describe('CC 唯一标识(可选,未传入时服务端自动生成)'),
|
|
369
|
+
robot_id: z.string().optional().describe('指定机器人名称或序号'),
|
|
370
|
+
project_dir: z.string().optional().describe('项目目录路径(用于写入配置文件)'),
|
|
371
|
+
mode: z.enum(['channel', 'http']).optional().default('http')
|
|
372
|
+
.describe('运行模式:channel=SSE推送(推荐),http=轮询(兼容)'),
|
|
373
|
+
auto_approve: z.boolean().optional().default(true).describe('超时自动审批(默认 true)'),
|
|
374
|
+
auto_approve_timeout: z.number().optional().default(600).describe('自动审批超时时间(秒,默认 600 即 10 分钟)'),
|
|
375
|
+
}, async ({ agent_name, cc_id, robot_id, project_dir, mode, auto_approve, auto_approve_timeout }) => {
|
|
376
|
+
// 转发请求
|
|
377
|
+
const result = await forwardToHttpMcp('enter_headless_mode', {
|
|
378
|
+
agent_name,
|
|
379
|
+
cc_id,
|
|
380
|
+
robot_id,
|
|
381
|
+
project_dir,
|
|
382
|
+
mode,
|
|
383
|
+
auto_approve,
|
|
384
|
+
auto_approve_timeout,
|
|
385
|
+
});
|
|
386
|
+
// 拦截响应,提取 ccId,建立 SSE 连接
|
|
387
|
+
if (result && typeof result === 'object' && 'content' in result) {
|
|
388
|
+
const content = result.content;
|
|
389
|
+
if (content[0]?.text) {
|
|
390
|
+
try {
|
|
391
|
+
const parsed = JSON.parse(content[0].text);
|
|
392
|
+
if (parsed.ccId) {
|
|
393
|
+
logChannel('Got ccId, connecting SSE', { ccId: parsed.ccId, mode });
|
|
394
|
+
connectSSE(parsed.ccId);
|
|
395
|
+
// Channel 模式:过滤 heartbeat 信息,简化消息
|
|
396
|
+
if (mode === 'channel' || parsed.mode === 'channel') {
|
|
397
|
+
delete parsed.heartbeat; // Channel 模式不需要 heartbeat loop
|
|
398
|
+
parsed.message = `已进入微信模式(Channel),消息将通过 SSE 自动推送`;
|
|
399
|
+
content[0].text = JSON.stringify(parsed);
|
|
400
|
+
logChannel('enter_headless_mode 响应已处理', { parsed });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch (e) {
|
|
405
|
+
logChannel('JSON 解析失败', { error: String(e) });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return result;
|
|
410
|
+
});
|
|
411
|
+
// ============================================
|
|
412
|
+
// 工具 10: 退出 headless 模式
|
|
413
|
+
// ============================================
|
|
414
|
+
server.tool('exit_headless_mode', '退出微信模式,断开连接。当用户说「结束微信模式」或「我回来了」时调用。', {
|
|
415
|
+
cc_id: z.string().describe('CC 唯一标识(enter_headless_mode 返回的 ccId)'),
|
|
416
|
+
project_dir: z.string().optional().describe('项目目录路径(用于更新配置文件)'),
|
|
417
|
+
}, async ({ cc_id, project_dir }) => {
|
|
418
|
+
// 断开 SSE 连接
|
|
419
|
+
if (sseAbortController) {
|
|
420
|
+
sseAbortController.abort();
|
|
421
|
+
sseAbortController = null;
|
|
422
|
+
sseConnected = false;
|
|
423
|
+
logChannel('SSE disconnected', { cc_id });
|
|
424
|
+
}
|
|
425
|
+
return forwardToHttpMcp('exit_headless_mode', { cc_id, project_dir });
|
|
426
|
+
});
|
|
427
|
+
// ============================================
|
|
428
|
+
// 工具 11: 从消息识别用户
|
|
429
|
+
// ============================================
|
|
430
|
+
server.tool('detect_user_from_message', '等待用户发送消息并返回用户 ID。', {
|
|
431
|
+
timeout: z.number().optional().describe('超时时间(秒),默认 60'),
|
|
432
|
+
cc_id: z.string().optional().describe('CC 唯一标识(enter_headless_mode 返回的 ccId)'),
|
|
433
|
+
}, async ({ timeout, cc_id }) => {
|
|
434
|
+
return forwardToHttpMcp('detect_user_from_message', { timeout, cc_id });
|
|
435
|
+
});
|
|
436
|
+
// ============================================
|
|
437
|
+
// 工具 12: 获取连接状态统计
|
|
438
|
+
// ============================================
|
|
439
|
+
server.tool('get_connection_stats', '获取连接状态统计和日志', { recent_logs: z.number().optional().describe('最近 N 条日志') }, async ({ recent_logs }) => {
|
|
440
|
+
return forwardToHttpMcp('get_connection_stats', { recent_logs });
|
|
441
|
+
});
|
|
442
|
+
// ============================================
|
|
443
|
+
// 工具 13: 获取 skill 文件内容
|
|
444
|
+
// ============================================
|
|
445
|
+
server.tool('get_skill', '获取 headless-mode skill 文件内容,用于写入本地项目目录。远程部署时 HTTP MCP 可能不在本地,skill 文件需要从此接口获取。', {}, async () => {
|
|
446
|
+
// 直接请求 HTTP MCP 的 /skill 端点
|
|
447
|
+
const res = await fetch(`${MCP_URL}/skill`);
|
|
448
|
+
if (!res.ok) {
|
|
449
|
+
return {
|
|
450
|
+
content: [{
|
|
451
|
+
type: 'text',
|
|
452
|
+
text: JSON.stringify({ error: `获取 skill 失败: ${res.status}` }),
|
|
453
|
+
}],
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
const content = await res.text();
|
|
457
|
+
return {
|
|
458
|
+
content: [{
|
|
459
|
+
type: 'text',
|
|
460
|
+
text: JSON.stringify({ success: true, content, filename: 'SKILL.md' }),
|
|
461
|
+
}],
|
|
462
|
+
};
|
|
463
|
+
});
|
|
464
|
+
logChannel('Registered 13 tools');
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* 启动 Channel MCP Server
|
|
468
|
+
*/
|
|
469
|
+
export async function startChannelServer() {
|
|
470
|
+
logChannel('Starting Channel MCP Proxy');
|
|
471
|
+
// 创建 MCP Server
|
|
472
|
+
mcpServer = new McpServer({
|
|
473
|
+
name: 'wecom-aibot-channel',
|
|
474
|
+
version: '2.0.0',
|
|
475
|
+
}, {
|
|
476
|
+
capabilities: {
|
|
477
|
+
// 必须声明 experimental['claude/channel'],Claude Code 才会注册 notification listener
|
|
478
|
+
experimental: { 'claude/channel': {} },
|
|
479
|
+
tools: {},
|
|
480
|
+
},
|
|
481
|
+
// 告知 Claude 如何处理 channel 事件
|
|
482
|
+
instructions: '企业微信消息通过 <channel> 标签推送。属性说明:from=发送者userid, chatid=会话ID(单聊=用户ID,群聊=群ID), chattype=single|group, cc_id=当前会话标识。收到消息后:1) 发送确认 send_message(cc_id, "收到...", target_user=chatid);2) 处理任务;3) 发送结果 send_message(cc_id, "【完成】...", target_user=chatid)。',
|
|
483
|
+
});
|
|
484
|
+
// 注册工具
|
|
485
|
+
registerChannelTools(mcpServer);
|
|
486
|
+
logChannel('Registered 13 tools');
|
|
487
|
+
// 连接 stdio transport
|
|
488
|
+
const transport = new StdioServerTransport();
|
|
489
|
+
await mcpServer.connect(transport);
|
|
490
|
+
logChannel('Connected to CC via stdio');
|
|
491
|
+
logChannel('Channel MCP Proxy ready');
|
|
492
|
+
}
|