dingtalk-wiki 1.1.6 → 1.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/README.md CHANGED
@@ -15,7 +15,8 @@
15
15
  ## Repository Highlights
16
16
 
17
17
  - **Official MCP gap**: Wiki / Docs read-write is not covered
18
- - **This project adds it**: workspace browsing, node browsing, and document creation
18
+ - **This project adds it**: workspace browsing, node browsing, document CRUD, and full-text search
19
+ - **Local search index**: MiniSearch + SQLite — full-text content search across docs and Notable tables
19
20
  - **MCP-compatible**: works with stdio-based MCP clients
20
21
  - **Agent-ready**: includes `SKILL.md` for OpenClaw-style skill workflows
21
22
 
@@ -124,6 +125,8 @@ node index.js
124
125
  | Browse workspaces | Not covered | ✅ |
125
126
  | Browse nodes / folders | Not covered | ✅ |
126
127
  | Read Notable / `.able` records | Not covered | ✅ |
128
+ | Name search | Not covered | ✅ |
129
+ | Full-text content search | Not covered | ✅ MiniSearch + SQLite |
127
130
  | MCP client compatibility | Partial / official scope only | ✅ stdio MCP-compatible |
128
131
  | OpenClaw skill packaging | No | ✅ includes `SKILL.md` |
129
132
 
@@ -143,7 +146,9 @@ node index.js
143
146
  - `WORKBOOK`
144
147
  - `MIND`
145
148
  - `FOLDER`
146
- - Search Wiki by linking to DingTalk search
149
+ - Search by name (`search_wiki`, BFS directory traversal, always available)
150
+ - Full-text content search (`search_wiki_content`, MiniSearch + SQLite index)
151
+ - Search index management (`refresh_search_index`)
147
152
  - Read Notable / `.able` sheets and records via official API
148
153
 
149
154
  ### Organization
@@ -243,17 +248,22 @@ mcporter call --stdio "node ./index.js" create_wiki_doc workspace_id="your_works
243
248
 
244
249
  ## Available MCP tools
245
250
 
246
- - `list_wiki_workspaces`
247
- - `get_wiki_workspace`
248
- - `list_wiki_nodes`
249
- - `get_wiki_node`
250
- - `create_wiki_doc`
251
- - `search_wiki`
252
- - `list_departments`
253
- - `get_department_users`
254
- - `get_user_info`
255
- - `list_notable_sheets`
256
- - `list_notable_records`
251
+ ### Wiki / Docs
252
+ - `list_wiki_workspaces` / `get_wiki_workspace`
253
+ - `list_wiki_nodes` / `get_wiki_node`
254
+ - `create_wiki_doc` / `delete_wiki_doc` / `rename_wiki_doc`
255
+ - `get_wiki_doc_content` / `update_wiki_doc_content`
256
+ - `search_wiki` — name search (BFS, no index needed)
257
+ - `search_wiki_content` — full-text search (requires index)
258
+ - `refresh_search_index` — rebuild search index
259
+
260
+ ### AI 表格 (Notable)
261
+ - `list_notable_sheets` / `list_notable_records`
262
+ - `create_notable_record` / `update_notable_record` / `delete_notable_record`
263
+ - `create_notable_sheet` / `delete_notable_sheet`
264
+
265
+ ### Organization
266
+ - `list_departments` / `get_department_users` / `get_user_info`
257
267
 
258
268
  ---
259
269
 
@@ -301,8 +311,9 @@ Please refer to DingTalk Open Platform documentation for the latest permission n
301
311
 
302
312
  - This is a community-maintained complement, not an official DingTalk project
303
313
  - Some APIs require enterprise approval on the DingTalk side
304
- - `search_wiki` is currently more of a search-entry helper than a full-text search implementation
305
- - Reading normal DingTalk document body content via official public API is still not implemented in this project
314
+ - `search_wiki` traverses directory tree by name (always available)
315
+ - `search_wiki_content` uses MiniSearch + SQLite for full-text search, requires `refresh_search_index` first
316
+ - Search index updates on write operations (create/update/rename/delete) via async hooks
306
317
  - Notable / `.able` support currently covers sheets and records, not arbitrary document-body export
307
318
 
308
319
  ---
package/README.zh-CN.md CHANGED
@@ -15,7 +15,8 @@
15
15
  ## 仓库要点
16
16
 
17
17
  - **官方 MCP 有空白**:没有覆盖 Wiki / Docs 读写
18
- - **这个项目补上了**:支持 workspace、node 浏览和文档创建
18
+ - **这个项目补上了**:支持 workspace、node 浏览、文档 CRUD 和全文搜索
19
+ - **本地搜索索引**:MiniSearch + SQLite — 全文搜索文档和 Notable 表格内容
19
20
  - **MCP 兼容**:兼容 stdio 模式的 MCP client
20
21
  - **Agent 友好**:自带 `SKILL.md`,可直接作为 skill 复用
21
22
 
@@ -124,6 +125,8 @@ node index.js
124
125
  | 浏览 workspace | 未覆盖 | ✅ |
125
126
  | 浏览 nodes / 目录 | 未覆盖 | ✅ |
126
127
  | 读取 Notable / `.able` 记录 | 未覆盖 | ✅ |
128
+ | 名称搜索 | 未覆盖 | ✅ |
129
+ | 全文内容搜索 | 未覆盖 | ✅ MiniSearch + SQLite |
127
130
  | MCP 客户端兼容性 | 官方范围内 | ✅ stdio 兼容 |
128
131
  | OpenClaw skill 化复用 | 无 | ✅ 自带 `SKILL.md` |
129
132
 
@@ -142,7 +145,9 @@ node index.js
142
145
  - `WORKBOOK`
143
146
  - `MIND`
144
147
  - `FOLDER`
145
- - 提供跳转式 Wiki 搜索能力
148
+ - 按名称搜索(`search_wiki`,BFS 遍历目录树,无需索引)
149
+ - 全文内容搜索(`search_wiki_content`,MiniSearch + SQLite 索引)
150
+ - 搜索索引管理(`refresh_search_index`)
146
151
  - 通过官方 API 读取 Notable / `.able` 的数据表和记录
147
152
 
148
153
  ### 组织架构
@@ -236,17 +241,22 @@ mcporter call --stdio "node ./index.js" create_wiki_doc workspace_id="your_works
236
241
 
237
242
  ## 可用 MCP 工具
238
243
 
239
- - `list_wiki_workspaces`
240
- - `get_wiki_workspace`
241
- - `list_wiki_nodes`
242
- - `get_wiki_node`
243
- - `create_wiki_doc`
244
- - `search_wiki`
245
- - `list_departments`
246
- - `get_department_users`
247
- - `get_user_info`
248
- - `list_notable_sheets`
249
- - `list_notable_records`
244
+ ### Wiki / Docs
245
+ - `list_wiki_workspaces` / `get_wiki_workspace`
246
+ - `list_wiki_nodes` / `get_wiki_node`
247
+ - `create_wiki_doc` / `delete_wiki_doc` / `rename_wiki_doc`
248
+ - `get_wiki_doc_content` / `update_wiki_doc_content`
249
+ - `search_wiki` — 名称搜索(BFS,无需索引)
250
+ - `search_wiki_content` — 全文搜索(需先建索引)
251
+ - `refresh_search_index` — 重建搜索索引
252
+
253
+ ### AI 表格(Notable)
254
+ - `list_notable_sheets` / `list_notable_records`
255
+ - `create_notable_record` / `update_notable_record` / `delete_notable_record`
256
+ - `create_notable_sheet` / `delete_notable_sheet`
257
+
258
+ ### 组织架构
259
+ - `list_departments` / `get_department_users` / `get_user_info`
250
260
 
251
261
  ---
252
262
 
@@ -295,9 +305,9 @@ mcporter call --stdio "node ./index.js" create_wiki_doc workspace_id="your_works
295
305
 
296
306
  - 本项目是社区补充实现,不是钉钉官方项目
297
307
  - 部分 API 需要企业侧权限审批
298
- - `search_wiki` 当前更偏向“跳转辅助”,不是完整全文检索实现
299
- - 目前项目仍未实现通过官方公开 API 读取普通 DingTalk 文档正文
300
- - 当前 Notable / `.able` 支持的是数据表和 records 读取,不是任意正文导出
308
+ - `search_wiki` 通过 BFS 遍历目录树按名称匹配(始终可用)
309
+ - `search_wiki_content` 使用 MiniSearch + SQLite 全文搜索,需先运行 `refresh_search_index`
310
+ - 搜索索引在写入操作(创建/更新/重命名/删除)时通过异步 hook 自动更新
301
311
 
302
312
  ---
303
313
 
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 配置
@@ -21,6 +23,7 @@ const path = require('path');
21
23
  const os = require('os');
22
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;
@@ -378,6 +596,7 @@ class DingTalkClient {
378
596
  }
379
597
 
380
598
  const dingtalk = new DingTalkClient();
599
+ const wikiIndex = new WikiSearchIndex();
381
600
 
382
601
  // MCP Server 定义
383
602
  const server = new Server(
@@ -503,7 +722,46 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
503
722
  },
504
723
  {
505
724
  name: 'search_wiki',
506
- description: '搜索知识库中的文档和文件夹(遍历目录树按名称匹配)',
725
+ description: '按名称搜索知识库中的文档和文件夹(遍历目录树,无需索引即可使用)',
726
+ inputSchema: {
727
+ type: 'object',
728
+ properties: {
729
+ keyword: {
730
+ type: 'string',
731
+ description: '搜索关键词'
732
+ },
733
+ workspace_id: {
734
+ type: 'string',
735
+ description: '指定知识库 ID(可选,不传则搜索所有知识库)'
736
+ },
737
+ max_results: {
738
+ type: 'number',
739
+ description: '返回条数上限(默认 20,最大 50)'
740
+ },
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 建立索引)',
507
765
  inputSchema: {
508
766
  type: 'object',
509
767
  properties: {
@@ -1052,6 +1310,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1052
1310
  lines.push(`📂 Workspace ID: ${doc.workspaceId}`);
1053
1311
  }
1054
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
+
1055
1329
  return {
1056
1330
  content: [{
1057
1331
  type: 'text',
@@ -1125,11 +1399,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1125
1399
  };
1126
1400
  }
1127
1401
 
1128
- const MAX_RESULTS = Math.min(max_results, 50);
1129
1402
  if (operator_id) {
1130
1403
  dingtalk.setOperatorId(operator_id);
1131
1404
  }
1132
1405
 
1406
+ const MAX_RESULTS = Math.min(max_results, 50);
1133
1407
  const wsResult = await dingtalk.wikiRequest('workspaces');
1134
1408
  let workspaces = wsResult.workspaces || [];
1135
1409
  if (workspace_id) {
@@ -1144,70 +1418,92 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1144
1418
  }
1145
1419
 
1146
1420
  const matchedNodes = [];
1147
-
1148
1421
  for (const ws of workspaces) {
1149
1422
  if (matchedNodes.length >= MAX_RESULTS) break;
1150
-
1151
1423
  const queue = [ws.rootNodeId];
1152
1424
  const visited = new Set();
1153
-
1154
1425
  while (queue.length > 0 && matchedNodes.length < MAX_RESULTS) {
1155
1426
  const dentryId = queue.shift();
1156
1427
  if (!dentryId || visited.has(dentryId)) continue;
1157
1428
  visited.add(dentryId);
1158
-
1159
1429
  try {
1160
1430
  const result = await dingtalk.docRequest('GET', `/v2.0/doc/spaces/${ws.workspaceId}/directories`, {
1161
- operatorId: operator_id || null,
1162
1431
  extraParams: dentryId !== ws.rootNodeId ? { dentryId, maxResults: 500 } : { maxResults: 500 }
1163
1432
  });
1164
-
1165
1433
  const children = result.children || [];
1166
1434
  for (const child of children) {
1167
1435
  const name = child.name || '';
1168
1436
  if (name.includes(keyword)) {
1169
- matchedNodes.push({
1170
- name,
1171
- nodeId: child.dentryId || child.nodeId || child.id,
1172
- workspaceId: ws.workspaceId,
1173
- workspaceName: ws.name,
1174
- type: child.contentType === 'folder' ? '文件夹' : '文档',
1175
- url: child.url || '',
1176
- hasChildren: child.hasChildren
1177
- });
1178
- }
1179
- if (child.hasChildren && matchedNodes.length < MAX_RESULTS) {
1180
- const childId = child.dentryId || child.nodeId || child.id;
1181
- if (childId && !visited.has(childId)) {
1182
- queue.push(childId);
1183
- }
1437
+ matchedNodes.push({ name, nodeId: child.dentryId || child.nodeId || child.id, workspaceId: ws.workspaceId, workspaceName: ws.name, type: child.contentType === 'folder' ? '文件夹' : '文档', url: child.url || '' });
1184
1438
  }
1439
+ const childId = child.dentryId || child.nodeId || child.id;
1440
+ if (child.hasChildren && childId && !visited.has(childId)) queue.push(childId);
1185
1441
  }
1186
- } catch (e) {
1187
- // 单个节点遍历失败跳过,不中断整体搜索
1188
- }
1442
+ } catch (e) { /* skip */ }
1189
1443
  }
1190
1444
  }
1191
1445
 
1192
- let output = `🔍 搜索 "${keyword}" (${matchedNodes.length})\n\n`;
1446
+ let output = `🔍 搜索 "${keyword}" (${matchedNodes.length}条,按名称匹配)\n\n`;
1193
1447
  matchedNodes.slice(0, MAX_RESULTS).forEach((item, i) => {
1194
1448
  const icon = item.type === '文件夹' ? '📁' : '📄';
1195
- output += `${i + 1}. ${icon} ${item.name}\n`;
1196
- output += ` 知识库: ${item.workspaceName} (${item.workspaceId})\n`;
1449
+ output += `${i + 1}. ${icon} ${item.name}\n 知识库: ${item.workspaceName} (${item.workspaceId})\n`;
1197
1450
  if (item.url) output += ` 链接: ${item.url}\n`;
1198
1451
  output += '\n';
1199
1452
  });
1200
-
1201
1453
  if (matchedNodes.length === 0) {
1202
1454
  output += '没有找到匹配的文档或文件夹。\n';
1203
- output += `\n💡 此方式通过遍历目录树按名称匹配,非全文搜索。`;
1204
- output += `如需全文搜索,请在钉钉客户端中操作:\n`;
1205
- output += `https://alidocs.dingtalk.com/i/search?keyword=${encodeURIComponent(keyword)}`;
1206
1455
  }
1207
1456
 
1208
- return {
1209
- content: [{ type: 'text', text: output }]
1210
- };
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
+ };
1467
+ }
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 }] };
1211
1507
  }
1212
1508
 
1213
1509
  case 'list_departments': {
@@ -1292,6 +1588,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1292
1588
  operatorId: operator_id || null,
1293
1589
  data: { content, contentType: 'markdown' }
1294
1590
  });
1591
+ setImmediate(() => { wikiIndex.update(docKey, { content }); });
1295
1592
  return {
1296
1593
  content: [{ type: 'text', text: '✅ 文档内容已更新' }]
1297
1594
  };
@@ -1306,6 +1603,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1306
1603
  operatorId: operator_id || null,
1307
1604
  data: { name }
1308
1605
  });
1606
+ setImmediate(() => { wikiIndex.update(node_id, { title: name }); });
1309
1607
  return {
1310
1608
  content: [{ type: 'text', text: `✅ 文档已重命名为: ${name}` }]
1311
1609
  };
@@ -1319,6 +1617,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1319
1617
  await dingtalk.docRequest('DELETE', `/v1.0/doc/workspaces/${workspace_id}/docs/${node_id}`, {
1320
1618
  operatorId: operator_id || null
1321
1619
  });
1620
+ setImmediate(() => { wikiIndex.remove(node_id); });
1322
1621
  return {
1323
1622
  content: [{
1324
1623
  type: 'text',
@@ -1383,6 +1682,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1383
1682
  operatorId: operator_id || null,
1384
1683
  data: { records }
1385
1684
  });
1685
+ setImmediate(() => reindexNotableBase(base_id));
1386
1686
  return {
1387
1687
  content: [{
1388
1688
  type: 'text',
@@ -1397,6 +1697,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1397
1697
  operatorId: operator_id || null,
1398
1698
  data: { records }
1399
1699
  });
1700
+ setImmediate(() => reindexNotableBase(base_id));
1400
1701
  return {
1401
1702
  content: [{
1402
1703
  type: 'text',
@@ -1411,6 +1712,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1411
1712
  operatorId: operator_id || null,
1412
1713
  data: { recordIds: record_ids }
1413
1714
  });
1715
+ setImmediate(() => reindexNotableBase(base_id));
1414
1716
  return {
1415
1717
  content: [{
1416
1718
  type: 'text',
@@ -1450,6 +1752,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1450
1752
  };
1451
1753
  }
1452
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
+
1453
1769
  default:
1454
1770
  throw new Error(`未知工具: ${name}`);
1455
1771
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dingtalk-wiki",
3
- "version": "1.1.6",
3
+ "version": "1.2.1",
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.search_wiki keyword="项目规划" next_token="your_next_token"
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