dingtalk-wiki 1.1.5 → 1.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.
- package/index.js +404 -34
- package/package.json +3 -1
- package/skill/SKILL.md +18 -4
package/index.js
CHANGED
|
@@ -11,6 +11,8 @@ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
|
11
11
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
12
12
|
const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
13
13
|
const axios = require('axios');
|
|
14
|
+
const MiniSearch = require('minisearch');
|
|
15
|
+
const Database = require('better-sqlite3');
|
|
14
16
|
const dotenv = require('dotenv');
|
|
15
17
|
|
|
16
18
|
// 钉钉 API 配置
|
|
@@ -19,8 +21,9 @@ const DINGTALK_API_V2 = 'https://api.dingtalk.com';
|
|
|
19
21
|
const fs = require('fs');
|
|
20
22
|
const path = require('path');
|
|
21
23
|
const os = require('os');
|
|
22
|
-
const CACHE_DIR = path.join(os.homedir(), '.cache', 'dingtalk-wiki
|
|
24
|
+
const CACHE_DIR = path.join(os.homedir(), '.cache', 'dingtalk-wiki');
|
|
23
25
|
const UNIONID_CACHE_PATH = path.join(CACHE_DIR, 'unionid-cache.json');
|
|
26
|
+
const SEARCH_INDEX_PATH = path.join(CACHE_DIR, 'search-index.sqlite');
|
|
24
27
|
|
|
25
28
|
if (!fs.existsSync(CACHE_DIR)) {
|
|
26
29
|
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
@@ -43,6 +46,221 @@ function saveUnionIdCache() {
|
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
class WikiSearchIndex {
|
|
50
|
+
constructor() {
|
|
51
|
+
this.db = new Database(SEARCH_INDEX_PATH);
|
|
52
|
+
this.db.pragma('journal_mode = WAL');
|
|
53
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS docs (
|
|
54
|
+
id TEXT PRIMARY KEY,
|
|
55
|
+
title TEXT NOT NULL DEFAULT '',
|
|
56
|
+
content TEXT NOT NULL DEFAULT '',
|
|
57
|
+
workspaceId TEXT NOT NULL DEFAULT '',
|
|
58
|
+
workspaceName TEXT NOT NULL DEFAULT '',
|
|
59
|
+
url TEXT NOT NULL DEFAULT '',
|
|
60
|
+
type TEXT NOT NULL DEFAULT ''
|
|
61
|
+
)`);
|
|
62
|
+
this._insertStmt = this.db.prepare(`INSERT OR REPLACE INTO docs (id, title, content, workspaceId, workspaceName, url, type) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
63
|
+
this._deleteStmt = this.db.prepare(`DELETE FROM docs WHERE id = ?`);
|
|
64
|
+
this._getStmt = this.db.prepare(`SELECT * FROM docs WHERE id = ?`);
|
|
65
|
+
|
|
66
|
+
this.index = new MiniSearch({
|
|
67
|
+
fields: ['title', 'content'],
|
|
68
|
+
storeFields: ['title', 'workspaceId', 'workspaceName', 'url', 'type'],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
this._load();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_load() {
|
|
75
|
+
try {
|
|
76
|
+
const rows = this.db.prepare('SELECT id, title, content, workspaceId, workspaceName, url, type FROM docs').all();
|
|
77
|
+
for (const row of rows) {
|
|
78
|
+
this.index.add({
|
|
79
|
+
id: row.id, title: row.title, content: row.content,
|
|
80
|
+
workspaceId: row.workspaceId, workspaceName: row.workspaceName,
|
|
81
|
+
url: row.url, type: row.type,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
console.error(`[钉钉MCP] 搜索索引已加载 (${rows.length} 篇文档)`);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
console.error('[钉钉MCP] 加载搜索索引失败:', e.message);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
add(docKey, data) {
|
|
91
|
+
this._insertStmt.run(docKey, data.title || '', data.content || '', data.workspaceId || '', data.workspaceName || '', data.url || '', data.type || '');
|
|
92
|
+
try {
|
|
93
|
+
this.index.add({ id: docKey, ...data });
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.error(`[钉钉MCP] 索引添加失败 [${docKey}]:`, e.message);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
remove(docKey) {
|
|
100
|
+
this._deleteStmt.run(docKey);
|
|
101
|
+
try { this.index.remove({ id: docKey }); } catch (e) { /* not in index */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
update(docKey, data) {
|
|
105
|
+
const existing = this._getStmt.get(docKey);
|
|
106
|
+
const merged = {
|
|
107
|
+
title: data.title ?? existing?.title ?? '',
|
|
108
|
+
content: data.content ?? existing?.content ?? '',
|
|
109
|
+
workspaceId: data.workspaceId ?? existing?.workspaceId ?? '',
|
|
110
|
+
workspaceName: data.workspaceName ?? existing?.workspaceName ?? '',
|
|
111
|
+
url: data.url ?? existing?.url ?? '',
|
|
112
|
+
type: data.type ?? existing?.type ?? '',
|
|
113
|
+
};
|
|
114
|
+
this._insertStmt.run(docKey, merged.title, merged.content, merged.workspaceId, merged.workspaceName, merged.url, merged.type);
|
|
115
|
+
try { this.index.remove({ id: docKey }); } catch (e) { /* ignore */ }
|
|
116
|
+
try {
|
|
117
|
+
this.index.add({ id: docKey, ...merged });
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.error(`[钉钉MCP] 索引更新失败 [${docKey}]:`, e.message);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
search(keyword, maxResults = 20) {
|
|
124
|
+
const results = this.index.search(keyword, { prefix: true, fuzzy: 0.2 });
|
|
125
|
+
const top = results.slice(0, maxResults);
|
|
126
|
+
if (top.length === 0) return [];
|
|
127
|
+
|
|
128
|
+
const ids = top.map(r => r.id);
|
|
129
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
130
|
+
const rows = this.db.prepare(`SELECT * FROM docs WHERE id IN (${placeholders})`).all(...ids);
|
|
131
|
+
const rowMap = Object.fromEntries(rows.map(r => [r.id, r]));
|
|
132
|
+
|
|
133
|
+
return top.map(r => ({
|
|
134
|
+
id: r.id, score: r.score,
|
|
135
|
+
title: rowMap[r.id]?.title || '',
|
|
136
|
+
content: rowMap[r.id]?.content || '',
|
|
137
|
+
workspaceId: rowMap[r.id]?.workspaceId || '',
|
|
138
|
+
workspaceName: rowMap[r.id]?.workspaceName || '',
|
|
139
|
+
url: rowMap[r.id]?.url || '',
|
|
140
|
+
type: rowMap[r.id]?.type || '',
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get size() {
|
|
145
|
+
return this.db.prepare('SELECT COUNT(*) as c FROM docs').get().c;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
clear() {
|
|
149
|
+
this.db.exec('DELETE FROM docs');
|
|
150
|
+
this.index = new MiniSearch({
|
|
151
|
+
fields: ['title', 'content'],
|
|
152
|
+
storeFields: ['title', 'workspaceId', 'workspaceName', 'url', 'type'],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function reindexNotableBase(baseId) {
|
|
158
|
+
try {
|
|
159
|
+
const sheetsRes = await dingtalk.notableRequest('GET', `/v1.0/notable/bases/${baseId}/sheets`);
|
|
160
|
+
const sheets = sheetsRes.value || [];
|
|
161
|
+
const texts = [];
|
|
162
|
+
for (const sheet of sheets) {
|
|
163
|
+
try {
|
|
164
|
+
const recordsRes = await dingtalk.notableRequest('POST', `/v1.0/notable/bases/${baseId}/sheets/${sheet.id}/records/list`, {
|
|
165
|
+
data: { maxResults: 500 }
|
|
166
|
+
});
|
|
167
|
+
const records = recordsRes.records || [];
|
|
168
|
+
for (const rec of records) {
|
|
169
|
+
for (const val of Object.values(rec.fields || {})) {
|
|
170
|
+
if (typeof val === 'string' || typeof val === 'number') {
|
|
171
|
+
texts.push(String(val));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch (e) { /* skip sheet */ }
|
|
176
|
+
}
|
|
177
|
+
wikiIndex.update(baseId, { content: texts.join('\n') });
|
|
178
|
+
} catch (e) {
|
|
179
|
+
console.error(`[钉钉MCP] 重建 Notable 表索引失败 [${baseId}]:`, e.message);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function rebuildSearchIndex() {
|
|
184
|
+
const wsResult = await dingtalk.wikiRequest('workspaces');
|
|
185
|
+
const workspaces = wsResult.workspaces || [];
|
|
186
|
+
|
|
187
|
+
wikiIndex.clear();
|
|
188
|
+
let total = 0;
|
|
189
|
+
|
|
190
|
+
for (const ws of workspaces) {
|
|
191
|
+
const queue = [ws.rootNodeId];
|
|
192
|
+
const visited = new Set();
|
|
193
|
+
|
|
194
|
+
while (queue.length > 0) {
|
|
195
|
+
const dentryId = queue.shift();
|
|
196
|
+
if (!dentryId || visited.has(dentryId)) continue;
|
|
197
|
+
visited.add(dentryId);
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const result = await dingtalk.docRequest('GET', `/v2.0/doc/spaces/${ws.workspaceId}/directories`, {
|
|
201
|
+
extraParams: dentryId !== ws.rootNodeId ? { dentryId, maxResults: 500 } : { maxResults: 500 }
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const children = result.children || [];
|
|
205
|
+
for (const child of children) {
|
|
206
|
+
const childId = child.dentryId || child.nodeId || child.id;
|
|
207
|
+
const name = child.name || '';
|
|
208
|
+
const isFolder = child.contentType === 'folder';
|
|
209
|
+
|
|
210
|
+
if (!isFolder && childId) {
|
|
211
|
+
let content = '';
|
|
212
|
+
try {
|
|
213
|
+
const blocks = await dingtalk.docRequest('GET', `/v1.0/doc/suites/documents/${childId}/blocks`);
|
|
214
|
+
const blockData = blocks.result?.data || [];
|
|
215
|
+
content = blockData.map(b => extractBlockText(b)).join('\n\n');
|
|
216
|
+
} catch (e) { /* content unavailable */ }
|
|
217
|
+
|
|
218
|
+
if (!content) {
|
|
219
|
+
try {
|
|
220
|
+
const sheetsRes = await dingtalk.notableRequest('GET', `/v1.0/notable/bases/${childId}/sheets`);
|
|
221
|
+
const sheets = sheetsRes.value || [];
|
|
222
|
+
const texts = [];
|
|
223
|
+
for (const sheet of sheets) {
|
|
224
|
+
try {
|
|
225
|
+
const recordsRes = await dingtalk.notableRequest('POST', `/v1.0/notable/bases/${childId}/sheets/${sheet.id}/records/list`, {
|
|
226
|
+
data: { maxResults: 500 }
|
|
227
|
+
});
|
|
228
|
+
const records = recordsRes.records || [];
|
|
229
|
+
for (const rec of records) {
|
|
230
|
+
for (const val of Object.values(rec.fields || {})) {
|
|
231
|
+
if (typeof val === 'string' || typeof val === 'number') {
|
|
232
|
+
texts.push(String(val));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} catch (e) { /* skip sheet */ }
|
|
237
|
+
}
|
|
238
|
+
content = texts.join('\n');
|
|
239
|
+
} catch (e) { /* not a Notable table */ }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
wikiIndex.add(childId, {
|
|
243
|
+
title: name, content,
|
|
244
|
+
workspaceId: ws.workspaceId,
|
|
245
|
+
workspaceName: ws.name,
|
|
246
|
+
url: child.url || '',
|
|
247
|
+
type: child.contentType || 'DOC',
|
|
248
|
+
});
|
|
249
|
+
total++;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (child.hasChildren && childId && !visited.has(childId)) {
|
|
253
|
+
queue.push(childId);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} catch (e) { /* skip errored nodes */ }
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.error(`[钉钉MCP] 搜索索引重建完成 (${total} 篇文档)`);
|
|
261
|
+
return total;
|
|
262
|
+
}
|
|
263
|
+
|
|
46
264
|
function loadEnvFile(filePath) {
|
|
47
265
|
if (!filePath || !fs.existsSync(filePath)) {
|
|
48
266
|
return false;
|
|
@@ -286,7 +504,7 @@ class DingTalkClient {
|
|
|
286
504
|
return this.operatorId;
|
|
287
505
|
}
|
|
288
506
|
|
|
289
|
-
async docRequest(method, pathName, { operatorId = null, data = null } = {}) {
|
|
507
|
+
async docRequest(method, pathName, { operatorId = null, data = null, extraParams = {} } = {}) {
|
|
290
508
|
const token = await this.getAccessToken();
|
|
291
509
|
const resolvedOperatorId = await this.resolveOperatorId(operatorId);
|
|
292
510
|
const url = `${DINGTALK_API_V2}${pathName}`;
|
|
@@ -300,7 +518,8 @@ class DingTalkClient {
|
|
|
300
518
|
'Content-Type': 'application/json'
|
|
301
519
|
},
|
|
302
520
|
params: {
|
|
303
|
-
operatorId: resolvedOperatorId
|
|
521
|
+
operatorId: resolvedOperatorId,
|
|
522
|
+
...extraParams
|
|
304
523
|
},
|
|
305
524
|
data
|
|
306
525
|
});
|
|
@@ -377,6 +596,7 @@ class DingTalkClient {
|
|
|
377
596
|
}
|
|
378
597
|
|
|
379
598
|
const dingtalk = new DingTalkClient();
|
|
599
|
+
const wikiIndex = new WikiSearchIndex();
|
|
380
600
|
|
|
381
601
|
// MCP Server 定义
|
|
382
602
|
const server = new Server(
|
|
@@ -502,7 +722,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
502
722
|
},
|
|
503
723
|
{
|
|
504
724
|
name: 'search_wiki',
|
|
505
|
-
description: '
|
|
725
|
+
description: '按名称搜索知识库中的文档和文件夹(遍历目录树,无需索引即可使用)',
|
|
506
726
|
inputSchema: {
|
|
507
727
|
type: 'object',
|
|
508
728
|
properties: {
|
|
@@ -512,15 +732,50 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
512
732
|
},
|
|
513
733
|
workspace_id: {
|
|
514
734
|
type: 'string',
|
|
515
|
-
description: '指定知识库 ID
|
|
735
|
+
description: '指定知识库 ID(可选,不传则搜索所有知识库)'
|
|
516
736
|
},
|
|
517
737
|
max_results: {
|
|
518
738
|
type: 'number',
|
|
519
|
-
description: '返回条数上限(默认
|
|
739
|
+
description: '返回条数上限(默认 20,最大 50)'
|
|
520
740
|
},
|
|
521
|
-
|
|
741
|
+
operator_id: {
|
|
742
|
+
type: 'string',
|
|
743
|
+
description: '操作者 unionid(不传则使用默认用户)'
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
required: ['keyword']
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
name: 'refresh_search_index',
|
|
751
|
+
description: '全量重建搜索索引(遍历所有知识库读取文档内容,建立全文索引后 search_wiki_content 方可使用)',
|
|
752
|
+
inputSchema: {
|
|
753
|
+
type: 'object',
|
|
754
|
+
properties: {
|
|
755
|
+
operator_id: {
|
|
756
|
+
type: 'string',
|
|
757
|
+
description: '操作者 unionid(不传则使用默认用户)'
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
name: 'search_wiki_content',
|
|
764
|
+
description: '全文搜索知识库文档内容(需先运行 refresh_search_index 建立索引)',
|
|
765
|
+
inputSchema: {
|
|
766
|
+
type: 'object',
|
|
767
|
+
properties: {
|
|
768
|
+
keyword: {
|
|
522
769
|
type: 'string',
|
|
523
|
-
description: '
|
|
770
|
+
description: '搜索关键词'
|
|
771
|
+
},
|
|
772
|
+
workspace_id: {
|
|
773
|
+
type: 'string',
|
|
774
|
+
description: '指定知识库 ID(可选,不传则搜索所有知识库)'
|
|
775
|
+
},
|
|
776
|
+
max_results: {
|
|
777
|
+
type: 'number',
|
|
778
|
+
description: '返回条数上限(默认 20,最大 50)'
|
|
524
779
|
},
|
|
525
780
|
operator_id: {
|
|
526
781
|
type: 'string',
|
|
@@ -1055,6 +1310,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1055
1310
|
lines.push(`📂 Workspace ID: ${doc.workspaceId}`);
|
|
1056
1311
|
}
|
|
1057
1312
|
|
|
1313
|
+
setImmediate(() => {
|
|
1314
|
+
wikiIndex.add(doc.nodeId || doc.docKey, {
|
|
1315
|
+
title: name,
|
|
1316
|
+
content: args.content || '',
|
|
1317
|
+
workspaceId: workspace_id || doc.workspaceId,
|
|
1318
|
+
workspaceName: '',
|
|
1319
|
+
url: doc.url || '',
|
|
1320
|
+
type: doc_type,
|
|
1321
|
+
});
|
|
1322
|
+
if (args.content && doc.nodeId) {
|
|
1323
|
+
dingtalk.docRequest('POST', `/v1.0/doc/suites/documents/${doc.nodeId}/overwriteContent`, {
|
|
1324
|
+
data: { content: args.content, contentType: 'markdown' }
|
|
1325
|
+
}).catch(() => {});
|
|
1326
|
+
}
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1058
1329
|
return {
|
|
1059
1330
|
content: [{
|
|
1060
1331
|
type: 'text',
|
|
@@ -1120,40 +1391,119 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1120
1391
|
}
|
|
1121
1392
|
|
|
1122
1393
|
case 'search_wiki': {
|
|
1123
|
-
const { keyword, workspace_id, max_results =
|
|
1394
|
+
const { keyword, workspace_id, max_results = 20, operator_id } = args;
|
|
1395
|
+
if (!keyword) {
|
|
1396
|
+
return {
|
|
1397
|
+
content: [{ type: 'text', text: '⚠️ 请提供搜索关键词 keyword' }],
|
|
1398
|
+
isError: true
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1124
1402
|
if (operator_id) {
|
|
1125
1403
|
dingtalk.setOperatorId(operator_id);
|
|
1126
1404
|
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
if (next_token) {
|
|
1132
|
-
body.nextToken = next_token;
|
|
1133
|
-
}
|
|
1405
|
+
|
|
1406
|
+
const MAX_RESULTS = Math.min(max_results, 50);
|
|
1407
|
+
const wsResult = await dingtalk.wikiRequest('workspaces');
|
|
1408
|
+
let workspaces = wsResult.workspaces || [];
|
|
1134
1409
|
if (workspace_id) {
|
|
1135
|
-
|
|
1410
|
+
workspaces = workspaces.filter(ws => ws.workspaceId === workspace_id);
|
|
1136
1411
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1412
|
+
|
|
1413
|
+
if (workspaces.length === 0) {
|
|
1414
|
+
return {
|
|
1415
|
+
content: [{ type: 'text', text: '⚠️ 未找到可搜索的知识库' }],
|
|
1416
|
+
isError: true
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const matchedNodes = [];
|
|
1421
|
+
for (const ws of workspaces) {
|
|
1422
|
+
if (matchedNodes.length >= MAX_RESULTS) break;
|
|
1423
|
+
const queue = [ws.rootNodeId];
|
|
1424
|
+
const visited = new Set();
|
|
1425
|
+
while (queue.length > 0 && matchedNodes.length < MAX_RESULTS) {
|
|
1426
|
+
const dentryId = queue.shift();
|
|
1427
|
+
if (!dentryId || visited.has(dentryId)) continue;
|
|
1428
|
+
visited.add(dentryId);
|
|
1429
|
+
try {
|
|
1430
|
+
const result = await dingtalk.docRequest('GET', `/v2.0/doc/spaces/${ws.workspaceId}/directories`, {
|
|
1431
|
+
extraParams: dentryId !== ws.rootNodeId ? { dentryId, maxResults: 500 } : { maxResults: 500 }
|
|
1432
|
+
});
|
|
1433
|
+
const children = result.children || [];
|
|
1434
|
+
for (const child of children) {
|
|
1435
|
+
const name = child.name || '';
|
|
1436
|
+
if (name.includes(keyword)) {
|
|
1437
|
+
matchedNodes.push({ name, nodeId: child.dentryId || child.nodeId || child.id, workspaceId: ws.workspaceId, workspaceName: ws.name, type: child.contentType === 'folder' ? '文件夹' : '文档', url: child.url || '' });
|
|
1438
|
+
}
|
|
1439
|
+
const childId = child.dentryId || child.nodeId || child.id;
|
|
1440
|
+
if (child.hasChildren && childId && !visited.has(childId)) queue.push(childId);
|
|
1441
|
+
}
|
|
1442
|
+
} catch (e) { /* skip */ }
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
let output = `🔍 搜索 "${keyword}" (${matchedNodes.length}条,按名称匹配)\n\n`;
|
|
1447
|
+
matchedNodes.slice(0, MAX_RESULTS).forEach((item, i) => {
|
|
1448
|
+
const icon = item.type === '文件夹' ? '📁' : '📄';
|
|
1449
|
+
output += `${i + 1}. ${icon} ${item.name}\n 知识库: ${item.workspaceName} (${item.workspaceId})\n`;
|
|
1450
|
+
if (item.url) output += ` 链接: ${item.url}\n`;
|
|
1451
|
+
output += '\n';
|
|
1147
1452
|
});
|
|
1148
|
-
if (
|
|
1149
|
-
output += '
|
|
1453
|
+
if (matchedNodes.length === 0) {
|
|
1454
|
+
output += '没有找到匹配的文档或文件夹。\n';
|
|
1150
1455
|
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1456
|
+
|
|
1457
|
+
return { content: [{ type: 'text', text: output }] };
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
case 'search_wiki_content': {
|
|
1461
|
+
const { keyword, workspace_id, max_results = 20, operator_id } = args;
|
|
1462
|
+
if (!keyword) {
|
|
1463
|
+
return {
|
|
1464
|
+
content: [{ type: 'text', text: '⚠️ 请提供搜索关键词 keyword' }],
|
|
1465
|
+
isError: true
|
|
1466
|
+
};
|
|
1153
1467
|
}
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1468
|
+
|
|
1469
|
+
if (operator_id) {
|
|
1470
|
+
dingtalk.setOperatorId(operator_id);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
if (wikiIndex.size === 0) {
|
|
1474
|
+
return {
|
|
1475
|
+
content: [{
|
|
1476
|
+
type: 'text',
|
|
1477
|
+
text: '🔍 全文搜索索引为空,请先运行 refresh_search_index 工具遍历知识库建立索引。'
|
|
1478
|
+
}]
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
const MAX_RESULTS = Math.min(max_results, 50);
|
|
1483
|
+
let results = wikiIndex.search(keyword, MAX_RESULTS);
|
|
1484
|
+
if (workspace_id) {
|
|
1485
|
+
results = results.filter(r => r.workspaceId === workspace_id);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
let output = `🔍 全文搜索 "${keyword}" (${results.length}条)\n\n`;
|
|
1489
|
+
results.slice(0, MAX_RESULTS).forEach((item, i) => {
|
|
1490
|
+
const icon = item.type === 'FOLDER' || item.type === 'folder' ? '📁' : '📄';
|
|
1491
|
+
output += `${i + 1}. ${icon} ${item.title}\n`;
|
|
1492
|
+
output += ` 知识库: ${item.workspaceName} (${item.workspaceId})\n`;
|
|
1493
|
+
output += ` 匹配度: ${(item.score * 100).toFixed(0)}%\n`;
|
|
1494
|
+
if (item.url) output += ` 链接: ${item.url}\n`;
|
|
1495
|
+
if (item.content) {
|
|
1496
|
+
const preview = item.content.slice(0, 120).replace(/\n+/g, ' ');
|
|
1497
|
+
output += ` 内容预览: ${preview}${item.content.length > 120 ? '...' : ''}\n`;
|
|
1498
|
+
}
|
|
1499
|
+
output += '\n';
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
if (results.length === 0) {
|
|
1503
|
+
output += '没有找到匹配的文档。\n';
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
return { content: [{ type: 'text', text: output }] };
|
|
1157
1507
|
}
|
|
1158
1508
|
|
|
1159
1509
|
case 'list_departments': {
|
|
@@ -1238,6 +1588,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1238
1588
|
operatorId: operator_id || null,
|
|
1239
1589
|
data: { content, contentType: 'markdown' }
|
|
1240
1590
|
});
|
|
1591
|
+
setImmediate(() => { wikiIndex.update(docKey, { content }); });
|
|
1241
1592
|
return {
|
|
1242
1593
|
content: [{ type: 'text', text: '✅ 文档内容已更新' }]
|
|
1243
1594
|
};
|
|
@@ -1252,6 +1603,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1252
1603
|
operatorId: operator_id || null,
|
|
1253
1604
|
data: { name }
|
|
1254
1605
|
});
|
|
1606
|
+
setImmediate(() => { wikiIndex.update(node_id, { title: name }); });
|
|
1255
1607
|
return {
|
|
1256
1608
|
content: [{ type: 'text', text: `✅ 文档已重命名为: ${name}` }]
|
|
1257
1609
|
};
|
|
@@ -1265,6 +1617,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1265
1617
|
await dingtalk.docRequest('DELETE', `/v1.0/doc/workspaces/${workspace_id}/docs/${node_id}`, {
|
|
1266
1618
|
operatorId: operator_id || null
|
|
1267
1619
|
});
|
|
1620
|
+
setImmediate(() => { wikiIndex.remove(node_id); });
|
|
1268
1621
|
return {
|
|
1269
1622
|
content: [{
|
|
1270
1623
|
type: 'text',
|
|
@@ -1329,6 +1682,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1329
1682
|
operatorId: operator_id || null,
|
|
1330
1683
|
data: { records }
|
|
1331
1684
|
});
|
|
1685
|
+
setImmediate(() => reindexNotableBase(base_id));
|
|
1332
1686
|
return {
|
|
1333
1687
|
content: [{
|
|
1334
1688
|
type: 'text',
|
|
@@ -1343,6 +1697,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1343
1697
|
operatorId: operator_id || null,
|
|
1344
1698
|
data: { records }
|
|
1345
1699
|
});
|
|
1700
|
+
setImmediate(() => reindexNotableBase(base_id));
|
|
1346
1701
|
return {
|
|
1347
1702
|
content: [{
|
|
1348
1703
|
type: 'text',
|
|
@@ -1357,6 +1712,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1357
1712
|
operatorId: operator_id || null,
|
|
1358
1713
|
data: { recordIds: record_ids }
|
|
1359
1714
|
});
|
|
1715
|
+
setImmediate(() => reindexNotableBase(base_id));
|
|
1360
1716
|
return {
|
|
1361
1717
|
content: [{
|
|
1362
1718
|
type: 'text',
|
|
@@ -1396,6 +1752,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1396
1752
|
};
|
|
1397
1753
|
}
|
|
1398
1754
|
|
|
1755
|
+
case 'refresh_search_index': {
|
|
1756
|
+
const { operator_id } = args || {};
|
|
1757
|
+
if (operator_id) {
|
|
1758
|
+
dingtalk.setOperatorId(operator_id);
|
|
1759
|
+
}
|
|
1760
|
+
rebuildSearchIndex().catch(e => console.error('[钉钉MCP] 索引重建失败:', e.message));
|
|
1761
|
+
return {
|
|
1762
|
+
content: [{
|
|
1763
|
+
type: 'text',
|
|
1764
|
+
text: '🔄 搜索索引全量重建已启动(后台运行),完成后搜索将支持全文检索。\n\n这可能需要一些时间,取决于知识库中文档的数量和内容长度。'
|
|
1765
|
+
}]
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1399
1769
|
default:
|
|
1400
1770
|
throw new Error(`未知工具: ${name}`);
|
|
1401
1771
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dingtalk-wiki",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "DingTalk Wiki / Docs read-write MCP server that fills the gap left by DingTalk official MCP.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
42
42
|
"axios": "latest",
|
|
43
|
+
"better-sqlite3": "^12.10.0",
|
|
43
44
|
"dotenv": "latest",
|
|
45
|
+
"minisearch": "^7.2.0",
|
|
44
46
|
"zod": "latest"
|
|
45
47
|
}
|
|
46
48
|
}
|
package/skill/SKILL.md
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
钉钉知识库 MCP Server,支持通过 MCP 协议读写钉钉 Wiki / Docs 内容。
|
|
4
4
|
|
|
5
5
|
### 知识库管理
|
|
6
|
+
- `search_wiki` - 按名称搜索知识库文档和文件夹(遍历目录树,无需索引)
|
|
7
|
+
- `search_wiki_content` - 全文搜索文档内容 + Notable 表格内容(需先 `refresh_search_index`)
|
|
8
|
+
- `refresh_search_index` - 全量重建搜索索引(支持普通文档和 Notable 表格)
|
|
6
9
|
- `list_wiki_workspaces` - 列出知识库工作空间列表
|
|
7
10
|
- `get_wiki_workspace` - 获取知识库详情
|
|
8
11
|
- `list_wiki_nodes` - 列出知识库节点(文档 / 目录)
|
|
@@ -12,7 +15,6 @@
|
|
|
12
15
|
- `update_wiki_doc_content` - 覆写文档内容(Markdown,⚠️ 全量覆盖)
|
|
13
16
|
- `rename_wiki_doc` - 重命名文档
|
|
14
17
|
- `delete_wiki_doc` - 删除文档节点
|
|
15
|
-
- `search_wiki` - 搜索知识库内容
|
|
16
18
|
- `list_notable_sheets` - 获取 `.able` / AI 表格中的所有数据表
|
|
17
19
|
- `list_notable_records` - 获取指定数据表中的 records
|
|
18
20
|
- `create_notable_record` - 创建记录(单条或多条)
|
|
@@ -100,7 +102,7 @@ mcporter call dingtalk-wiki.delete_wiki_doc \
|
|
|
100
102
|
workspace_id="your_workspace_id" \
|
|
101
103
|
node_id="your_node_id"
|
|
102
104
|
|
|
103
|
-
#
|
|
105
|
+
# 按名称搜索
|
|
104
106
|
mcporter call dingtalk-wiki.search_wiki keyword="项目规划"
|
|
105
107
|
|
|
106
108
|
# 在指定知识库内搜索
|
|
@@ -108,11 +110,23 @@ mcporter call dingtalk-wiki.search_wiki keyword="项目规划" workspace_id="you
|
|
|
108
110
|
|
|
109
111
|
# 自定义返回条数
|
|
110
112
|
mcporter call dingtalk-wiki.search_wiki keyword="项目规划" max_results=5
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### 全文搜索
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# 先重建索引(遍历所有知识库获取文档和 Notable 表格内容)
|
|
119
|
+
mcporter call dingtalk-wiki.refresh_search_index
|
|
120
|
+
|
|
121
|
+
# 全文搜索文档内容(包括 Notable 表格记录)
|
|
122
|
+
mcporter call dingtalk-wiki.search_wiki_content keyword="项目规划"
|
|
111
123
|
|
|
112
|
-
#
|
|
113
|
-
mcporter call dingtalk-wiki.
|
|
124
|
+
# 搜索结果含匹配度和内容预览
|
|
125
|
+
mcporter call dingtalk-wiki.search_wiki_content keyword="API集成" max_results=10
|
|
114
126
|
```
|
|
115
127
|
|
|
128
|
+
> 💡 `search_wiki`(按名称搜索)无需索引即可使用,`search_wiki_content`(全文搜索)需先运行 `refresh_search_index`。
|
|
129
|
+
|
|
116
130
|
### AI 表格(Notable)
|
|
117
131
|
|
|
118
132
|
```bash
|