weflow-cli 1.8.1 → 1.9.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/CHANGELOG.md +359 -0
- package/OPERATIONS.md +79 -0
- package/README.en.md +9 -2
- package/README.md +12 -2
- package/bin/weflow-cli-electron.cjs +2 -0
- package/bin/weflow-cli.ts +648 -79
- package/dist/bin/weflow-cli.js +675 -78
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/http.d.ts +14 -0
- package/dist/mcp-server/http.d.ts.map +1 -0
- package/dist/mcp-server/http.js +84 -0
- package/dist/mcp-server/http.js.map +1 -0
- package/dist/mcp-server/httpConfig.d.ts +50 -0
- package/dist/mcp-server/httpConfig.d.ts.map +1 -0
- package/dist/mcp-server/httpConfig.js +104 -0
- package/dist/mcp-server/httpConfig.js.map +1 -0
- package/dist/mcp-server/index.d.ts +13 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +39 -373
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +26 -1
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +69 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +270 -8
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantScenes.d.ts +81 -0
- package/dist/src/services/assistantScenes.d.ts.map +1 -0
- package/dist/src/services/assistantScenes.js +322 -0
- package/dist/src/services/assistantScenes.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +14 -4
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +82 -6
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantSkills.d.ts +94 -0
- package/dist/src/services/assistantSkills.d.ts.map +1 -0
- package/dist/src/services/assistantSkills.js +330 -0
- package/dist/src/services/assistantSkills.js.map +1 -0
- package/dist/src/services/assistantTools.d.ts +7 -0
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +770 -61
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +4 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -1
- package/dist/src/services/assistantTrace.js +9 -0
- package/dist/src/services/assistantTrace.js.map +1 -1
- package/dist/src/services/configService.d.ts +7 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +4 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/outboundSend.d.ts +53 -0
- package/dist/src/services/outboundSend.d.ts.map +1 -0
- package/dist/src/services/outboundSend.js +90 -0
- package/dist/src/services/outboundSend.js.map +1 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -1
- package/dist/src/services/pythonBridge.js +1 -0
- package/dist/src/services/pythonBridge.js.map +1 -1
- package/dist/src/utils/python.js +2 -2
- package/dist/src/utils/python.js.map +1 -1
- package/docs/BRANCHES.md +37 -0
- package/docs/DECISIONS.md +350 -0
- package/docs/EXTENDING.md +29 -3
- package/docs/MCP.md +56 -2
- package/docs/PROJECT_STATE.md +121 -9
- package/mcp-server/http.ts +108 -0
- package/mcp-server/httpConfig.ts +126 -0
- package/mcp-server/index.ts +344 -681
- package/package.json +1 -1
- package/resources/panel/ball-position.cjs +48 -2
- package/resources/panel/index.html +6 -4
- package/resources/panel/main.cjs +137 -5
- package/resources/panel/mascot-base.png +0 -0
- package/resources/panel/mascot-iris.png +0 -0
- package/resources/panel/mascot-peek.png +0 -0
- package/resources/panel/panel.css +107 -63
- package/resources/panel/preload.cjs +26 -0
- package/resources/panel/renderer.js +167 -10
- package/scripts/_utils.py +47 -1
- package/scripts/backfill_articles.py +202 -21
- package/scripts/biz_daily.py +8 -31
- package/scripts/bonds.py +331 -0
- package/scripts/chat_notes.py +551 -470
- package/scripts/chat_stats.py +8 -7
- package/scripts/compile_wiki.py +24 -2
- package/scripts/export_chat_html.py +3 -1
- package/scripts/mcp_bridge.py +6 -7
- package/scripts/wechat_voice.py +9 -0
- package/scripts/wiki_lint.py +30 -7
- package/src/panel/server.ts +25 -1
- package/src/services/assistantEval.ts +323 -12
- package/src/services/assistantScenes.ts +342 -0
- package/src/services/assistantService.ts +81 -6
- package/src/services/assistantSkills.ts +364 -0
- package/src/services/assistantTools.ts +738 -59
- package/src/services/assistantTrace.ts +9 -0
- package/src/services/configService.ts +412 -403
- package/src/services/outboundSend.ts +126 -0
- package/src/services/pythonBridge.ts +1 -0
- package/src/utils/python.ts +2 -2
package/mcp-server/index.ts
CHANGED
|
@@ -1,681 +1,344 @@
|
|
|
1
|
-
#!/usr/bin/env npx tsx
|
|
2
|
-
/**
|
|
3
|
-
* WeFlow MCP Server — 让 Claude Code 等 AI Agent 直接查询知识库。
|
|
4
|
-
*
|
|
5
|
-
* 启动: npx tsx mcp-server/index.ts
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from '@modelcontextprotocol/sdk/
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
.
|
|
123
|
-
|
|
124
|
-
.
|
|
125
|
-
|
|
126
|
-
.
|
|
127
|
-
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
required: ['name'],
|
|
346
|
-
},
|
|
347
|
-
},
|
|
348
|
-
{
|
|
349
|
-
name: 'wechat.get_review',
|
|
350
|
-
description: '获取某日的 AI 学习日报',
|
|
351
|
-
inputSchema: {
|
|
352
|
-
type: 'object',
|
|
353
|
-
properties: {
|
|
354
|
-
date: { type: 'string', description: '日期 YYYY-MM-DD,默认最新' },
|
|
355
|
-
},
|
|
356
|
-
},
|
|
357
|
-
},
|
|
358
|
-
{
|
|
359
|
-
name: 'wechat.get_stats',
|
|
360
|
-
description: '统计概览: 知识库文章/概念页 + 本地微信数据(会话数/收藏总数)',
|
|
361
|
-
inputSchema: { type: 'object', properties: {} },
|
|
362
|
-
},
|
|
363
|
-
{
|
|
364
|
-
name: 'wechat.format_article',
|
|
365
|
-
description: '将 Markdown 文章转换为微信公众号排版 HTML(支持 4 种主题:default/warm/minimal/green)',
|
|
366
|
-
inputSchema: {
|
|
367
|
-
type: 'object',
|
|
368
|
-
properties: {
|
|
369
|
-
content: { type: 'string', description: 'Markdown 格式的文章内容' },
|
|
370
|
-
theme: { type: 'string', description: '排版主题: default | warm | minimal | green,默认 default' },
|
|
371
|
-
},
|
|
372
|
-
required: ['content'],
|
|
373
|
-
},
|
|
374
|
-
},
|
|
375
|
-
{
|
|
376
|
-
name: 'wechat.list_themes',
|
|
377
|
-
description: '列出微信公众号排版可用的所有主题',
|
|
378
|
-
inputSchema: { type: 'object', properties: {} },
|
|
379
|
-
},
|
|
380
|
-
{
|
|
381
|
-
name: 'wechat.search_public',
|
|
382
|
-
description: '搜索全网微信公众号文章(通过搜索引擎 site:mp.weixin.qq.com)',
|
|
383
|
-
inputSchema: {
|
|
384
|
-
type: 'object',
|
|
385
|
-
properties: {
|
|
386
|
-
keyword: { type: 'string', description: '搜索关键词' },
|
|
387
|
-
limit: { type: 'number', description: '返回数量,默认10' },
|
|
388
|
-
},
|
|
389
|
-
required: ['keyword'],
|
|
390
|
-
},
|
|
391
|
-
},
|
|
392
|
-
{
|
|
393
|
-
name: 'wechat.fetch_article',
|
|
394
|
-
description: '抓取单篇微信公众号文章,转换为 Markdown',
|
|
395
|
-
inputSchema: {
|
|
396
|
-
type: 'object',
|
|
397
|
-
properties: {
|
|
398
|
-
url: { type: 'string', description: '文章链接(mp.weixin.qq.com)' },
|
|
399
|
-
},
|
|
400
|
-
required: ['url'],
|
|
401
|
-
},
|
|
402
|
-
},
|
|
403
|
-
{
|
|
404
|
-
name: 'wechat.export_messages',
|
|
405
|
-
description: '以 weflow-message/v1 JSON 返回指定会话的本地消息。只读,不返回数据库路径、密钥或配置。',
|
|
406
|
-
inputSchema: {
|
|
407
|
-
type: 'object',
|
|
408
|
-
properties: {
|
|
409
|
-
contact: { type: 'string', description: '会话 ID,或唯一匹配的会话名称' },
|
|
410
|
-
limit: { type: 'number', description: '返回上限,默认 100,最大 1000' },
|
|
411
|
-
from: { type: 'string', description: '起始日期 YYYY-MM-DD(含当天)' },
|
|
412
|
-
to: { type: 'string', description: '结束日期 YYYY-MM-DD(含当天)' },
|
|
413
|
-
},
|
|
414
|
-
required: ['contact'],
|
|
415
|
-
},
|
|
416
|
-
},
|
|
417
|
-
// ---- 本地微信数据 (与 weflow-cli assistant / 微信 bot 共享工具层) ----
|
|
418
|
-
...ASSISTANT_TOOLS,
|
|
419
|
-
],
|
|
420
|
-
}))
|
|
421
|
-
|
|
422
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
423
|
-
const { name, arguments: args = {} } = request.params
|
|
424
|
-
|
|
425
|
-
try {
|
|
426
|
-
switch (name) {
|
|
427
|
-
case 'wechat.search_articles':
|
|
428
|
-
return { content: [{ type: 'text', text: searchArticles(args) }] }
|
|
429
|
-
|
|
430
|
-
case 'wechat.get_daily': {
|
|
431
|
-
const dates = existsSync(BIZ_DAILY) ? readdirSync(BIZ_DAILY).sort().reverse() : []
|
|
432
|
-
if (args.date && !safeDate(args.date)) return { content: [{ type: 'text', text: '日期无效,请使用 YYYY-MM-DD' }] }
|
|
433
|
-
const date = safeDate(args.date) || dates.find(d => /^\d{4}-\d{2}-\d{2}$/.test(d)) || ''
|
|
434
|
-
const readme = safeChildPath(BIZ_DAILY, join(date, 'README.md'))
|
|
435
|
-
if (!readme || !existsSync(readme)) return { content: [{ type: 'text', text: `未找到 ${date} 的日报` }] }
|
|
436
|
-
return { content: [{ type: 'text', text: readFileSync(readme, 'utf-8') }] }
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
case 'wechat.get_concepts': {
|
|
440
|
-
// 两条线各有一份索引:`Wiki/00-Overview.md`(文章)与 `Chat/00-Overview.md`(聊天)。
|
|
441
|
-
// 只回一份的话,另一个知识库对调用方就等于不存在——而索引本来就是"这里有什么"的清单。
|
|
442
|
-
const indexes: string[] = []
|
|
443
|
-
for (const [index, label] of [[VAULT_INDEX, '文章'], [VAULT_CHAT_INDEX, '聊天']] as const) {
|
|
444
|
-
if (!existsSync(index)) continue
|
|
445
|
-
indexes.push(`<!-- ${label}知识库 -->\n` + readFileSync(index, 'utf-8'))
|
|
446
|
-
}
|
|
447
|
-
if (!indexes.length) return { content: [{ type: 'text', text: '概念索引尚未生成,请先运行 wiki compile' }] }
|
|
448
|
-
return { content: [{ type: 'text', text: indexes.join('\n\n') }] }
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
case 'wechat.get_concept': {
|
|
452
|
-
const name = String(args.name)
|
|
453
|
-
if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) {
|
|
454
|
-
return { content: [{ type: 'text', text: '概念名无效' }] }
|
|
455
|
-
}
|
|
456
|
-
for (const dir of VAULT_WIKI_DIRS) {
|
|
457
|
-
for (const ext of ['', '.md']) {
|
|
458
|
-
const path = safeChildPath(dir, name + ext)
|
|
459
|
-
if (path && existsSync(path)) {
|
|
460
|
-
return { content: [{ type: 'text', text: readFileSync(path, 'utf-8') }] }
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
// Fuzzy search
|
|
465
|
-
for (const dir of VAULT_WIKI_DIRS) {
|
|
466
|
-
if (!existsSync(dir)) continue
|
|
467
|
-
for (const f of readdirSync(dir)) {
|
|
468
|
-
if (f.includes(name)) {
|
|
469
|
-
const path = safeChildPath(dir, f)
|
|
470
|
-
if (path) return { content: [{ type: 'text', text: readFileSync(path, 'utf-8') }] }
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
return { content: [{ type: 'text', text: `未找到概念: ${name}` }] }
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
case 'wechat.get_review': {
|
|
478
|
-
const files = existsSync(REVIEWS) ? readdirSync(REVIEWS).sort().reverse() : []
|
|
479
|
-
const date = String(args.date || '')
|
|
480
|
-
let path: string
|
|
481
|
-
if (date) {
|
|
482
|
-
path = safeDate(date) ? join(REVIEWS, `Daily-${date}.md`) : ''
|
|
483
|
-
} else {
|
|
484
|
-
path = files.length ? join(REVIEWS, files[0]) : ''
|
|
485
|
-
}
|
|
486
|
-
if (!path || !existsSync(path)) return { content: [{ type: 'text', text: `未找到 ${date || '最新'} 的学习日报` }] }
|
|
487
|
-
return { content: [{ type: 'text', text: readFileSync(path, 'utf-8') }] }
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
case 'wechat.format_article': {
|
|
491
|
-
const content = String(args.content || '')
|
|
492
|
-
if (!content) return { content: [{ type: 'text', text: '错误: content 不能为空' }] }
|
|
493
|
-
const theme = String(args.theme || 'default')
|
|
494
|
-
const html = formatWeChatArticle(content, { theme: theme as any })
|
|
495
|
-
const preview = html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').slice(0, 200) + '...'
|
|
496
|
-
return {
|
|
497
|
-
content: [{
|
|
498
|
-
type: 'text',
|
|
499
|
-
text: [
|
|
500
|
-
`主题: ${theme}`,
|
|
501
|
-
`总长度: ${html.length} 字符`,
|
|
502
|
-
`正文预览: ${preview}`,
|
|
503
|
-
`---`,
|
|
504
|
-
html,
|
|
505
|
-
].join('\n')
|
|
506
|
-
}]
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
case 'wechat.list_themes': {
|
|
511
|
-
const themes = listThemes()
|
|
512
|
-
return {
|
|
513
|
-
content: [{
|
|
514
|
-
type: 'text',
|
|
515
|
-
text: themes.map(t => `- **${t.id}** — ${t.name}: ${t.description}`).join('\n')
|
|
516
|
-
}]
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
case 'wechat.get_stats': {
|
|
521
|
-
const articles = scanArticles()
|
|
522
|
-
const dates = [...new Set(articles.map(a => a.dateDir))].sort()
|
|
523
|
-
const topics: Record<string, number> = {}
|
|
524
|
-
articles.forEach(a => { topics[a.topic || '未知'] = (topics[a.topic || '未知'] || 0) + 1 })
|
|
525
|
-
let stats = `## 知识库统计\n\n- 文章总数: ${articles.length}\n- 覆盖日期: ${dates.length} 天 (${dates[0]} ~ ${dates[dates.length-1]})\n- 主题分布:\n`
|
|
526
|
-
for (const [t, c] of Object.entries(topics).sort((a, b) => b[1] - a[1])) {
|
|
527
|
-
stats += ` - ${t}: ${c} 篇\n`
|
|
528
|
-
}
|
|
529
|
-
if (existsSync(VAULT_INDEX)) {
|
|
530
|
-
const idx = readFileSync(VAULT_INDEX, 'utf-8')
|
|
531
|
-
const m = idx.match(/共 (\d+) 个概念/)
|
|
532
|
-
if (m) stats += `- 概念页: ${m[1]} 个\n`
|
|
533
|
-
}
|
|
534
|
-
// 追加本地微信数据统计 (会话/收藏; 数据库未配置时返回提示)
|
|
535
|
-
const chatStats = await executeTool('get_stats', {}, MCP_TOOL_CTX)
|
|
536
|
-
stats += `\n## 本地微信数据\n\n${chatStats}\n`
|
|
537
|
-
return { content: [{ type: 'text', text: stats }] }
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
case 'wechat.search_public': {
|
|
541
|
-
const keyword = String(args.keyword || '').trim()
|
|
542
|
-
if (!keyword) return { content: [{ type: 'text', text: '错误: keyword 不能为空' }] }
|
|
543
|
-
let limit = 10
|
|
544
|
-
try {
|
|
545
|
-
limit = boundedToolInteger(args.limit, 10, 20)
|
|
546
|
-
} catch {
|
|
547
|
-
return { content: [{ type: 'text', text: '错误: limit 必须是 1-20 的整数' }] }
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
try {
|
|
551
|
-
// 使用搜狗微信搜索
|
|
552
|
-
const resp = await fetch(
|
|
553
|
-
`https://weixin.sogou.com/weixin?type=2&query=${encodeURIComponent(keyword)}`,
|
|
554
|
-
{
|
|
555
|
-
headers: {
|
|
556
|
-
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
557
|
-
'Referer': 'https://weixin.sogou.com/',
|
|
558
|
-
'Accept': 'text/html',
|
|
559
|
-
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
560
|
-
},
|
|
561
|
-
signal: AbortSignal.timeout(20_000),
|
|
562
|
-
}
|
|
563
|
-
)
|
|
564
|
-
const html = await readResponseTextLimited(resp, 2 * 1024 * 1024)
|
|
565
|
-
|
|
566
|
-
// 从搜狗结果中提取标题、公众号名、摘要
|
|
567
|
-
// 搜狗将结果放在 li 标签中,每篇文章包含标题链接 + 摘要
|
|
568
|
-
const items: Array<{ title: string; account: string; desc: string }> = []
|
|
569
|
-
|
|
570
|
-
// 提取文章标题(h3 标签内的文本)
|
|
571
|
-
const titlePattern = /<h3[^>]*>\s*<a[^>]*>(.*?)<\/a>\s*<\/h3>/g
|
|
572
|
-
const titles: string[] = []
|
|
573
|
-
let tm: RegExpExecArray | null
|
|
574
|
-
while ((tm = titlePattern.exec(html)) !== null) {
|
|
575
|
-
const t = tm[1].replace(/<em[^>]*>/g, '').replace(/<\/em>/g, '').replace(/<!--[^>]*-->/g, '').trim()
|
|
576
|
-
if (t) titles.push(t)
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// 提取公众号名(在 span.all-time-y2 中)
|
|
580
|
-
const acctPattern = /<span[^>]*class="[^"]*all-time-y2[^"]*"[^>]*>(.*?)<\/span>/g
|
|
581
|
-
const accounts: string[] = []
|
|
582
|
-
let am: RegExpExecArray | null
|
|
583
|
-
while ((am = acctPattern.exec(html)) !== null) {
|
|
584
|
-
const a = am[1].replace(/<[^>]+>/g, '').trim()
|
|
585
|
-
if (a) accounts.push(a)
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// 提取摘要(p 标签)
|
|
589
|
-
const descPattern = /<p[^>]*class="[^"]*txt-info[^"]*"[^>]*>(.*?)<\/p>/g
|
|
590
|
-
const descs: string[] = []
|
|
591
|
-
let dm: RegExpExecArray | null
|
|
592
|
-
while ((dm = descPattern.exec(html)) !== null) {
|
|
593
|
-
const d = dm[1].replace(/<[^>]+>/g, '').replace(/…/g, '…').replace(/→/g, '→').replace(/—/g, '—').trim()
|
|
594
|
-
if (d && d.length > 10) descs.push(d)
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// 组合结果
|
|
598
|
-
const count = Math.max(titles.length, accounts.length, descs.length)
|
|
599
|
-
for (let i = 0; i < Math.min(count, limit); i++) {
|
|
600
|
-
items.push({
|
|
601
|
-
title: titles[i] || '(未知标题)',
|
|
602
|
-
account: accounts[i] || '(未知公众号)',
|
|
603
|
-
desc: descs[i] || '',
|
|
604
|
-
})
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
if (!items.length) {
|
|
608
|
-
return { content: [{ type: 'text', text: `未找到与"${keyword}"相关的微信公众号文章。\n\n提示:\n1. 尝试更具体的关键词\n2. 加上作者/公众号名\n3. 直接提供文章链接使用 wechat.fetch_article 抓取` }] }
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const results = items.map((item, i) =>
|
|
612
|
-
`${i + 1}. **${item.title}**\n 公众号: ${item.account}\n ${item.desc}`
|
|
613
|
-
).join('\n\n')
|
|
614
|
-
|
|
615
|
-
return {
|
|
616
|
-
content: [{
|
|
617
|
-
type: 'text',
|
|
618
|
-
text: [
|
|
619
|
-
`搜索关键词: "${keyword}" | 找到 ${items.length} 篇:`,
|
|
620
|
-
'',
|
|
621
|
-
results,
|
|
622
|
-
'',
|
|
623
|
-
'---',
|
|
624
|
-
'提示: 在微信或搜狗微信搜索中打开看到文章后,复制链接使用 wechat.fetch_article 抓取全文。'
|
|
625
|
-
].join('\n')
|
|
626
|
-
}]
|
|
627
|
-
}
|
|
628
|
-
} catch {
|
|
629
|
-
return { content: [{ type: 'text', text: '搜索失败,请检查网络连接后重试' }] }
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
case 'wechat.fetch_article': {
|
|
634
|
-
const url = String(args.url || '').trim()
|
|
635
|
-
if (!url) return { content: [{ type: 'text', text: '错误: url 不能为空' }] }
|
|
636
|
-
if (!isAllowedWeChatArticleUrl(url)) {
|
|
637
|
-
return { content: [{ type: 'text', text: '错误: 仅支持 HTTPS 微信公众号文章链接 (mp.weixin.qq.com)' }] }
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
try {
|
|
641
|
-
const article = await fetchWeChatArticle(url)
|
|
642
|
-
return {
|
|
643
|
-
content: [{
|
|
644
|
-
type: 'text',
|
|
645
|
-
text: [
|
|
646
|
-
`# ${article.title}`,
|
|
647
|
-
`> 作者: ${article.author}`,
|
|
648
|
-
article.description ? `> ${article.description}` : '',
|
|
649
|
-
'',
|
|
650
|
-
article.content,
|
|
651
|
-
].join('\n')
|
|
652
|
-
}]
|
|
653
|
-
}
|
|
654
|
-
} catch {
|
|
655
|
-
return { content: [{ type: 'text', text: '抓取失败,请检查链接和网络连接。部分文章需要微信客户端环境才能访问。' }] }
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
case 'wechat.export_messages': {
|
|
660
|
-
return { content: [{ type: 'text', text: await exportMessagesForMcp(args) }] }
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
default: {
|
|
664
|
-
// assistant 工具层统一分发 (wechat.list_sessions / wechat.get_messages / ...)
|
|
665
|
-
if (name.startsWith('wechat.')) {
|
|
666
|
-
const text = await executeTool(name.slice('wechat.'.length), args, MCP_TOOL_CTX)
|
|
667
|
-
return { content: [{ type: 'text', text }] }
|
|
668
|
-
}
|
|
669
|
-
return { content: [{ type: 'text', text: `未知工具: ${name}` }] }
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
} catch {
|
|
673
|
-
return { content: [{ type: 'text', text: '工具执行失败,请检查输入、本地配置或运行状态' }] }
|
|
674
|
-
}
|
|
675
|
-
})
|
|
676
|
-
|
|
677
|
-
const transport = new StdioServerTransport()
|
|
678
|
-
await server.connect(transport)
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
main().catch(console.error)
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* WeFlow MCP Server — 让 Claude Code 等 AI Agent 直接查询知识库。
|
|
4
|
+
*
|
|
5
|
+
* 启动: npx tsx mcp-server/index.ts (stdio,默认)
|
|
6
|
+
* npx tsx mcp-server/index.ts --http --token=<随机串> (HTTP,只绑回环)
|
|
7
|
+
*
|
|
8
|
+
* 两条传输的工具集**完全一样**(同一张 `TOOL_DEFS` 派生表),变的只是搬运动作的方式。
|
|
9
|
+
* HTTP 那条的边界(必须带令牌、只绑回环、DNS 重绑定防护)在 `httpConfig.ts` / `http.ts`,
|
|
10
|
+
* 理由与用法写在 docs/MCP.md 的「HTTP 传输」一节。
|
|
11
|
+
*/
|
|
12
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
13
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
14
|
+
import {
|
|
15
|
+
CallToolRequestSchema,
|
|
16
|
+
ListToolsRequestSchema,
|
|
17
|
+
} from '@modelcontextprotocol/sdk/types.js'
|
|
18
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'fs'
|
|
19
|
+
import { join } from 'path'
|
|
20
|
+
import { pathToFileURL } from 'node:url'
|
|
21
|
+
import { safeChildPath, safeDate } from '../src/utils/mcpSecurity.js'
|
|
22
|
+
import { resolvePackageRoot } from '../src/utils/packageRoot.js'
|
|
23
|
+
import { chatService } from '../src/services/chatService.js'
|
|
24
|
+
import { createWeFlowEnvelope } from '../src/services/messageContract.js'
|
|
25
|
+
import { resolveHttpOptions } from './httpConfig.js'
|
|
26
|
+
import { startHttpServer } from './http.js'
|
|
27
|
+
|
|
28
|
+
// ---- 微信公众号文章抓取 ----
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
/** 简单的 HTML → Markdown 转换(处理微信文章常见结构) */
|
|
32
|
+
import { boundedToolInteger, MCP_TOOL_DEFS, executeTool } from '../src/services/assistantTools.js'
|
|
33
|
+
import { AssistantMemory } from '../src/services/assistantMemory.js'
|
|
34
|
+
|
|
35
|
+
// ---- 本地微信数据工具 (复用 assistant 工具层, 微信 bot / MCP server 同源) ----
|
|
36
|
+
// MCP 无微信用户身份, 记忆固定挂 'mcp' 用户下; 与 assistant 守护进程共用同一持久化文件
|
|
37
|
+
//
|
|
38
|
+
// `requiresConfirm` 是**这条路独有的边界**:MCP 客户端是别人家的进程,它那边谁也看不到
|
|
39
|
+
// 这一轮是在什么上下文里决定调用的。所以凡是"会把用户数据送出去"的工具(目前是
|
|
40
|
+
// `draft_reply`:把一段对话发给两个云端模型),机器调用默认只给预览,要显式带
|
|
41
|
+
// `confirm: true` 才真出境。面板/微信那条路不置这一位。
|
|
42
|
+
const mcpMemory = new AssistantMemory()
|
|
43
|
+
const MCP_TOOL_CTX = { userId: 'mcp', memory: mcpMemory, requiresConfirm: true }
|
|
44
|
+
|
|
45
|
+
/** assistant 工具 (OpenAI function 格式) → MCP 工具格式; get_stats 并入现有 wechat.get_stats */
|
|
46
|
+
// 手写表曾经有一份 `get_stats`(把知识库统计与本地数据拼起来),它由助手那边的同名工具
|
|
47
|
+
// 一并承担了,所以这里不再排除派生项——两处各报一半正是"同一个名字两个答案"的来源。
|
|
48
|
+
const ASSISTANT_TOOLS = MCP_TOOL_DEFS
|
|
49
|
+
.map(t => ({
|
|
50
|
+
name: `wechat.${t.function.name}`,
|
|
51
|
+
description: t.function.description,
|
|
52
|
+
inputSchema: t.function.parameters,
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
const PKG_ROOT = resolvePackageRoot(import.meta.url)
|
|
56
|
+
const BIZ_DAILY = join(PKG_ROOT, 'output', 'biz-daily')
|
|
57
|
+
// **两个概念目录**:`Wiki/Concepts` 是公众号文章线的,`Chat/Concepts` 是聊天线的
|
|
58
|
+
// (用户要求两条线分开)。这里必须两个都带上——只留一个的话,另一半概念页会**静默地查不到**,
|
|
59
|
+
// 工具照样返回"未找到概念"。与 `src/services/assistantTools.ts` 的 `VAULT_WIKI_DIRS`、
|
|
60
|
+
// 以及 `scripts/_utils.py` 的 `CONCEPT_DIRS` 是同一份清单,`test/concept-dirs-agreement.test.ts` 钉住。
|
|
61
|
+
const VAULT_WIKI_DIRS = [
|
|
62
|
+
join(PKG_ROOT, 'output', 'wechat-vault', 'Wiki', 'Concepts'),
|
|
63
|
+
join(PKG_ROOT, 'output', 'wechat-vault', 'Chat', 'Concepts'),
|
|
64
|
+
]
|
|
65
|
+
const VAULT_WIKI = VAULT_WIKI_DIRS[0]
|
|
66
|
+
const VAULT_INDEX = join(PKG_ROOT, 'output', 'wechat-vault', 'Wiki', '00-Overview.md')
|
|
67
|
+
const VAULT_CHAT_INDEX = join(PKG_ROOT, 'output', 'wechat-vault', 'Chat', '00-Overview.md')
|
|
68
|
+
const REVIEWS = join(PKG_ROOT, 'output', 'reviews', 'Daily')
|
|
69
|
+
const MCP_MESSAGE_LIMIT_DEFAULT = 100
|
|
70
|
+
const MCP_MESSAGE_LIMIT_MAX = 1000
|
|
71
|
+
|
|
72
|
+
function parseMessageDate(value: unknown, label: string): number | undefined {
|
|
73
|
+
if (value === undefined || value === null || value === '') return undefined
|
|
74
|
+
const date = safeDate(value)
|
|
75
|
+
if (!date) throw new Error(`${label} 必须是有效的 YYYY-MM-DD 日期`)
|
|
76
|
+
const [year, month, day] = date.split('-').map(Number)
|
|
77
|
+
const start = new Date(year, month - 1, day)
|
|
78
|
+
if (start.getFullYear() !== year || start.getMonth() !== month - 1 || start.getDate() !== day) {
|
|
79
|
+
throw new Error(`${label} 必须是有效的日历日期`)
|
|
80
|
+
}
|
|
81
|
+
return Math.floor(start.getTime() / 1000)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function resolveMcpTalker(input: unknown): Promise<string> {
|
|
85
|
+
const contact = String(input || '').trim()
|
|
86
|
+
if (!contact) throw new Error('contact 不能为空')
|
|
87
|
+
if (contact.startsWith('wxid_') || contact.includes('@chatroom') || contact.includes('@openim')) return contact
|
|
88
|
+
|
|
89
|
+
const sessions = await chatService.listSessions(contact, 20)
|
|
90
|
+
const exact = sessions.filter(session => session.username === contact || session.displayName === contact)
|
|
91
|
+
if (exact.length === 1) return exact[0].username
|
|
92
|
+
if (exact.length > 1 || sessions.length > 1) throw new Error('contact 匹配到多个会话,请使用会话 ID')
|
|
93
|
+
if (sessions.length === 1) return sessions[0].username
|
|
94
|
+
throw new Error('未找到对应会话,请使用会话 ID 或准确的会话名称')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function exportMessagesForMcp(args: Record<string, any>): Promise<string> {
|
|
98
|
+
const limit = args.limit === undefined ? MCP_MESSAGE_LIMIT_DEFAULT : Number(args.limit)
|
|
99
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MCP_MESSAGE_LIMIT_MAX) {
|
|
100
|
+
throw new Error(`limit 必须是 1-${MCP_MESSAGE_LIMIT_MAX} 的整数`)
|
|
101
|
+
}
|
|
102
|
+
const from = parseMessageDate(args.from, 'from')
|
|
103
|
+
const toStart = parseMessageDate(args.to, 'to')
|
|
104
|
+
const to = toStart === undefined ? undefined : toStart + 24 * 60 * 60 - 1
|
|
105
|
+
if (from !== undefined && to !== undefined && from > to) throw new Error('from 不能晚于 to')
|
|
106
|
+
|
|
107
|
+
const talker = await resolveMcpTalker(args.contact)
|
|
108
|
+
const messages = await chatService.getMessagesInRange(talker, limit, from, to)
|
|
109
|
+
return JSON.stringify(createWeFlowEnvelope(messages, new Date().toISOString(), {
|
|
110
|
+
requestedFrom: from,
|
|
111
|
+
requestedTo: to,
|
|
112
|
+
requestedLimit: limit,
|
|
113
|
+
}), null, 2)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseFrontmatter(text: string): Record<string, any> {
|
|
117
|
+
if (!text.startsWith('---')) return {}
|
|
118
|
+
const end = text.indexOf('---', 3)
|
|
119
|
+
if (end === -1) return {}
|
|
120
|
+
const fm: Record<string, any> = {}
|
|
121
|
+
for (const line of text.slice(3, end).split('\n')) {
|
|
122
|
+
const idx = line.indexOf(':')
|
|
123
|
+
if (idx === -1) continue
|
|
124
|
+
const key = line.slice(0, idx).trim()
|
|
125
|
+
let val = line.slice(idx + 1).trim()
|
|
126
|
+
if (val.startsWith('[') && val.endsWith(']')) {
|
|
127
|
+
fm[key] = val.slice(1, -1).split(',').map(v => v.trim().replace(/['"]/g, ''))
|
|
128
|
+
} else if (val.startsWith('"') && val.endsWith('"')) {
|
|
129
|
+
fm[key] = val.slice(1, -1)
|
|
130
|
+
} else {
|
|
131
|
+
fm[key] = val
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return fm
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function scanArticles(): any[] {
|
|
138
|
+
const results: any[] = []
|
|
139
|
+
if (!existsSync(BIZ_DAILY)) return results
|
|
140
|
+
for (const dateDir of readdirSync(BIZ_DAILY).sort().reverse()) {
|
|
141
|
+
const dp = join(BIZ_DAILY, dateDir)
|
|
142
|
+
if (!statSync(dp).isDirectory()) continue
|
|
143
|
+
for (const topic of readdirSync(dp)) {
|
|
144
|
+
const tp = join(dp, topic)
|
|
145
|
+
if (!statSync(tp).isDirectory()) continue
|
|
146
|
+
for (const file of readdirSync(tp)) {
|
|
147
|
+
if (!file.endsWith('.md') || file === 'README.md') continue
|
|
148
|
+
const content = readFileSync(join(tp, file), 'utf-8')
|
|
149
|
+
const fm = parseFrontmatter(content)
|
|
150
|
+
results.push({
|
|
151
|
+
...fm,
|
|
152
|
+
file: join(dateDir, topic, file),
|
|
153
|
+
dateDir,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return results
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function searchArticles(args: Record<string, any>): string {
|
|
162
|
+
let articles = scanArticles()
|
|
163
|
+
if (args.keyword) {
|
|
164
|
+
const kw = String(args.keyword).toLowerCase()
|
|
165
|
+
articles = articles.filter(a =>
|
|
166
|
+
(a.title || '').toLowerCase().includes(kw) ||
|
|
167
|
+
(a.source || '').toLowerCase().includes(kw) ||
|
|
168
|
+
(a.tags || []).some((t: string) => t.toLowerCase().includes(kw))
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
if (args.topic) {
|
|
172
|
+
articles = articles.filter(a => a.topic === args.topic)
|
|
173
|
+
}
|
|
174
|
+
if (args.date) {
|
|
175
|
+
articles = articles.filter(a => a.dateDir === args.date)
|
|
176
|
+
}
|
|
177
|
+
const limit = boundedToolInteger(args.limit, 20, 100)
|
|
178
|
+
const top = articles.slice(0, limit)
|
|
179
|
+
if (!top.length) return '未找到匹配文章'
|
|
180
|
+
return top.map(a =>
|
|
181
|
+
`- [${a.date}] **${a.title || '(无标题)'}** — ${a.source || ''} [${a.topic || ''}] [${(a.tags || []).join(', ')}]\n ${(a.description || '').slice(0, 120)}`
|
|
182
|
+
).join('\n\n')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 建一个 MCP Server(工具表与处理器)。stdio 与 HTTP 两条传输都用它,**每次都新建**无状态。 */
|
|
186
|
+
export function buildServer(): Server {
|
|
187
|
+
const server = new Server(
|
|
188
|
+
{ name: 'weflow-mcp', version: '1.0.0' },
|
|
189
|
+
{ capabilities: { tools: {} } }
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
193
|
+
tools: [
|
|
194
|
+
{
|
|
195
|
+
name: 'wechat.search_articles',
|
|
196
|
+
description: '搜索知识库文章(可按关键词/主题/日期过滤)',
|
|
197
|
+
inputSchema: {
|
|
198
|
+
type: 'object',
|
|
199
|
+
properties: {
|
|
200
|
+
keyword: { type: 'string', description: '搜索关键词(标题/来源/标签)' },
|
|
201
|
+
topic: { type: 'string', description: '主题: AI | 学术 | 新闻 | 文学 | 投资' },
|
|
202
|
+
date: { type: 'string', description: '日期 YYYY-MM-DD' },
|
|
203
|
+
limit: { type: 'number', description: '返回数量,默认20' },
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: 'wechat.get_daily',
|
|
209
|
+
description: '获取指定日期的公众号日报',
|
|
210
|
+
inputSchema: {
|
|
211
|
+
type: 'object',
|
|
212
|
+
properties: {
|
|
213
|
+
date: { type: 'string', description: '日期 YYYY-MM-DD,默认最新' },
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: 'wechat.get_concept',
|
|
219
|
+
description: '获取某个概念的详细 Wiki 页',
|
|
220
|
+
inputSchema: {
|
|
221
|
+
type: 'object',
|
|
222
|
+
properties: {
|
|
223
|
+
name: { type: 'string', description: '概念名,如 RAG、Agent' },
|
|
224
|
+
},
|
|
225
|
+
required: ['name'],
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: 'wechat.export_messages',
|
|
230
|
+
description: '以 weflow-message/v1 JSON 返回指定会话的本地消息。只读,不返回数据库路径、密钥或配置。',
|
|
231
|
+
inputSchema: {
|
|
232
|
+
type: 'object',
|
|
233
|
+
properties: {
|
|
234
|
+
contact: { type: 'string', description: '会话 ID,或唯一匹配的会话名称' },
|
|
235
|
+
limit: { type: 'number', description: '返回上限,默认 100,最大 1000' },
|
|
236
|
+
from: { type: 'string', description: '起始日期 YYYY-MM-DD(含当天)' },
|
|
237
|
+
to: { type: 'string', description: '结束日期 YYYY-MM-DD(含当天)' },
|
|
238
|
+
},
|
|
239
|
+
required: ['contact'],
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
// ---- 本地微信数据 (与 weflow-cli assistant / 微信 bot 共享工具层) ----
|
|
243
|
+
...ASSISTANT_TOOLS,
|
|
244
|
+
],
|
|
245
|
+
}))
|
|
246
|
+
|
|
247
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
248
|
+
const { name, arguments: args = {} } = request.params
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
switch (name) {
|
|
252
|
+
case 'wechat.search_articles':
|
|
253
|
+
return { content: [{ type: 'text', text: searchArticles(args) }] }
|
|
254
|
+
|
|
255
|
+
case 'wechat.get_daily': {
|
|
256
|
+
const dates = existsSync(BIZ_DAILY) ? readdirSync(BIZ_DAILY).sort().reverse() : []
|
|
257
|
+
if (args.date && !safeDate(args.date)) return { content: [{ type: 'text', text: '日期无效,请使用 YYYY-MM-DD' }] }
|
|
258
|
+
const date = safeDate(args.date) || dates.find(d => /^\d{4}-\d{2}-\d{2}$/.test(d)) || ''
|
|
259
|
+
const readme = safeChildPath(BIZ_DAILY, join(date, 'README.md'))
|
|
260
|
+
if (!readme || !existsSync(readme)) return { content: [{ type: 'text', text: `未找到 ${date} 的日报` }] }
|
|
261
|
+
return { content: [{ type: 'text', text: readFileSync(readme, 'utf-8') }] }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
case 'wechat.get_concept': {
|
|
266
|
+
const name = String(args.name)
|
|
267
|
+
if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) {
|
|
268
|
+
return { content: [{ type: 'text', text: '概念名无效' }] }
|
|
269
|
+
}
|
|
270
|
+
for (const dir of VAULT_WIKI_DIRS) {
|
|
271
|
+
for (const ext of ['', '.md']) {
|
|
272
|
+
const path = safeChildPath(dir, name + ext)
|
|
273
|
+
if (path && existsSync(path)) {
|
|
274
|
+
return { content: [{ type: 'text', text: readFileSync(path, 'utf-8') }] }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Fuzzy search
|
|
279
|
+
for (const dir of VAULT_WIKI_DIRS) {
|
|
280
|
+
if (!existsSync(dir)) continue
|
|
281
|
+
for (const f of readdirSync(dir)) {
|
|
282
|
+
if (f.includes(name)) {
|
|
283
|
+
const path = safeChildPath(dir, f)
|
|
284
|
+
if (path) return { content: [{ type: 'text', text: readFileSync(path, 'utf-8') }] }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return { content: [{ type: 'text', text: `未找到概念: ${name}` }] }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
case 'wechat.export_messages': {
|
|
298
|
+
return { content: [{ type: 'text', text: await exportMessagesForMcp(args) }] }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
default: {
|
|
302
|
+
// assistant 工具层统一分发 (wechat.list_sessions / wechat.get_messages / ...)
|
|
303
|
+
if (name.startsWith('wechat.')) {
|
|
304
|
+
const text = await executeTool(name.slice('wechat.'.length), args, MCP_TOOL_CTX)
|
|
305
|
+
return { content: [{ type: 'text', text }] }
|
|
306
|
+
}
|
|
307
|
+
return { content: [{ type: 'text', text: `未知工具: ${name}` }] }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
return { content: [{ type: 'text', text: '工具执行失败,请检查输入、本地配置或运行状态' }] }
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
return server
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 传输在这里选。**默认 stdio**(与以前逐字相同);给 `--http` 或 `WEFLOW_MCP_HTTP` 才走 HTTP。
|
|
320
|
+
* 判定的规则在 `httpConfig.ts`(纯函数、有测试):必须带令牌、只绑回环、默认不开。
|
|
321
|
+
*/
|
|
322
|
+
async function main() {
|
|
323
|
+
const opts = resolveHttpOptions(process.env, process.argv.slice(2))
|
|
324
|
+
if (!opts) {
|
|
325
|
+
await buildServer().connect(new StdioServerTransport())
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
const { url } = await startHttpServer(opts, buildServer, (line) => process.stderr.write(line + '\n'))
|
|
329
|
+
// **令牌一个字符都不打印**:日志会被翻、会被贴进 issue。只报地址与"用什么连"。
|
|
330
|
+
process.stderr.write(
|
|
331
|
+
`WeFlow MCP 已监听 ${url}\n`
|
|
332
|
+
+ ` 只绑 ${opts.host}(回环),需要 Authorization: Bearer <令牌>\n`
|
|
333
|
+
+ ' 客户端配置见 docs/MCP.md 的「HTTP 传输」一节\n',
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 只在**被当脚本跑**时启动。被 `import`(测试要 `buildServer`)就只拿工厂,别顺手起一个服务 ——
|
|
338
|
+
// 否则 `npm test` 会挂在一个等 stdin 的 stdio 服务上。
|
|
339
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
340
|
+
main().catch((error) => {
|
|
341
|
+
process.stderr.write(`启动失败: ${error?.message ?? error}\n`)
|
|
342
|
+
process.exit(1)
|
|
343
|
+
})
|
|
344
|
+
}
|