feishu-mcp 0.1.5 → 0.1.7
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/LICENSE +21 -21
- package/README.md +251 -221
- package/dist/mcp/feishuMcp.js +1 -1
- package/dist/mcp/tools/feishuBlockTools.js +127 -6
- package/dist/server.js +5 -1
- package/dist/services/baseService.js +13 -2
- package/dist/services/blockFactory.js +17 -0
- package/dist/services/callbackService.js +3 -0
- package/dist/services/feishuApiService.js +280 -85
- package/dist/services/feishuAuthService.js +107 -0
- package/dist/types/feishuSchema.js +30 -1
- package/dist/utils/auth/index.js +1 -0
- package/dist/utils/auth/tokenCacheManager.js +128 -0
- package/dist/utils/auth/tokenRefreshManager.js +172 -0
- package/dist/utils/document.js +114 -114
- package/dist/utils/error.js +24 -0
- package/package.json +75 -75
|
@@ -12,7 +12,7 @@ BlockConfigSchema, MediaIdSchema, MediaExtraSchema, ImagesArraySchema,
|
|
|
12
12
|
// MermaidCodeSchema,
|
|
13
13
|
// ImageWidthSchema,
|
|
14
14
|
// ImageHeightSchema
|
|
15
|
-
TableCreateSchema } from '../../types/feishuSchema.js';
|
|
15
|
+
TableCreateSchema, WhiteboardFillArraySchema } from '../../types/feishuSchema.js';
|
|
16
16
|
/**
|
|
17
17
|
* 注册飞书块相关的MCP工具
|
|
18
18
|
* @param server MCP服务器实例
|
|
@@ -47,7 +47,7 @@ export function registerFeishuBlockTools(server, feishuService) {
|
|
|
47
47
|
}
|
|
48
48
|
});
|
|
49
49
|
// 添加通用飞书块创建工具(支持文本、代码、标题)
|
|
50
|
-
server.tool('batch_create_feishu_blocks', 'PREFERRED: Efficiently creates multiple blocks (text, code, heading, list) in a single API call. USE THIS TOOL when creating multiple consecutive blocks at the same position - reduces API calls by up to 90%. KEY FEATURES: (1) Handles any number of blocks by auto-batching large requests (>50 blocks), (2) Creates blocks at consecutive positions in a document, (3) Supports direct heading level format (e.g. "heading1", "heading2") or standard "heading" type with level in options. CORRECT FORMAT: mcp_feishu_batch_create_feishu_blocks({documentId:"doc123",parentBlockId:"para123",startIndex:0,blocks:[{blockType:"text",options:{...}},{blockType:"heading1",options:{heading:{content:"Title"}}}]}). For separate positions, use individual block creation tools instead. For wiki links (https://xxx.feishu.cn/wiki/xxx), first convert with convert_feishu_wiki_to_document_id tool.', {
|
|
50
|
+
server.tool('batch_create_feishu_blocks', 'PREFERRED: Efficiently creates multiple blocks (text, code, heading, list, image, mermaid, whiteboard) in a single API call. USE THIS TOOL when creating multiple consecutive blocks at the same position - reduces API calls by up to 90%. KEY FEATURES: (1) Handles any number of blocks by auto-batching large requests (>50 blocks), (2) Creates blocks at consecutive positions in a document, (3) Supports direct heading level format (e.g. "heading1", "heading2") or standard "heading" type with level in options. CORRECT FORMAT: mcp_feishu_batch_create_feishu_blocks({documentId:"doc123",parentBlockId:"para123",startIndex:0,blocks:[{blockType:"text",options:{...}},{blockType:"heading1",options:{heading:{content:"Title"}}}]}). For whiteboard blocks, use blockType:"whiteboard" with options:{whiteboard:{align:1}}. After creating a whiteboard block, you will receive a token in the response (board.token field) which can be used with fill_whiteboard_with_plantuml tool. The fill_whiteboard_with_plantuml tool supports both PlantUML (syntax_type: 1) and Mermaid (syntax_type: 2) formats. For separate positions, use individual block creation tools instead. For wiki links (https://xxx.feishu.cn/wiki/xxx), first convert with convert_feishu_wiki_to_document_id tool.', {
|
|
51
51
|
documentId: DocumentIdSchema,
|
|
52
52
|
parentBlockId: ParentBlockIdSchema,
|
|
53
53
|
index: IndexSchema,
|
|
@@ -110,6 +110,9 @@ export function registerFeishuBlockTools(server, feishuService) {
|
|
|
110
110
|
// 检查是否有图片块(block_type=27)
|
|
111
111
|
const imageBlocks = result.children?.filter((child) => child.block_type === 27) || [];
|
|
112
112
|
const hasImageBlocks = imageBlocks.length > 0;
|
|
113
|
+
// 检查是否有画板块(block_type=43)
|
|
114
|
+
const whiteboardBlocks = result.children?.filter((child) => child.block_type === 43) || [];
|
|
115
|
+
const hasWhiteboardBlocks = whiteboardBlocks.length > 0;
|
|
113
116
|
const responseData = {
|
|
114
117
|
...result,
|
|
115
118
|
nextIndex: index + blockContents.length,
|
|
@@ -120,6 +123,17 @@ export function registerFeishuBlockTools(server, feishuService) {
|
|
|
120
123
|
blockIds: imageBlocks.map((block) => block.block_id),
|
|
121
124
|
reminder: "检测到图片块已创建!请使用 upload_and_bind_image_to_block 工具上传图片并绑定到对应的块ID。"
|
|
122
125
|
}
|
|
126
|
+
}),
|
|
127
|
+
...(hasWhiteboardBlocks && {
|
|
128
|
+
whiteboardBlocksInfo: {
|
|
129
|
+
count: whiteboardBlocks.length,
|
|
130
|
+
blocks: whiteboardBlocks.map((block) => ({
|
|
131
|
+
blockId: block.block_id,
|
|
132
|
+
token: block.board?.token,
|
|
133
|
+
align: block.board?.align
|
|
134
|
+
})),
|
|
135
|
+
reminder: "检测到画板块已创建!请使用 fill_whiteboard_with_plantuml 工具填充画板内容,使用返回的 token 作为 whiteboardId 参数。支持 PlantUML (syntax_type: 1) 和 Mermaid (syntax_type: 2) 两种格式。"
|
|
136
|
+
}
|
|
123
137
|
})
|
|
124
138
|
};
|
|
125
139
|
return {
|
|
@@ -201,12 +215,26 @@ export function registerFeishuBlockTools(server, feishuService) {
|
|
|
201
215
|
allImageBlocks.push(...imageBlocks);
|
|
202
216
|
});
|
|
203
217
|
const hasImageBlocks = allImageBlocks.length > 0;
|
|
204
|
-
|
|
218
|
+
// 检查所有批次中是否有画板块(block_type=43)
|
|
219
|
+
const allWhiteboardBlocks = [];
|
|
220
|
+
results.forEach(batchResult => {
|
|
221
|
+
const whiteboardBlocks = batchResult.children?.filter((child) => child.block_type === 43) || [];
|
|
222
|
+
allWhiteboardBlocks.push(...whiteboardBlocks);
|
|
223
|
+
});
|
|
224
|
+
const hasWhiteboardBlocks = allWhiteboardBlocks.length > 0;
|
|
225
|
+
let responseText = `所有飞书块创建成功,共分 ${totalBatches} 批创建了 ${createdBlocksCount} 个块。\n\n` +
|
|
205
226
|
`最后一批结果: ${JSON.stringify(results[results.length - 1], null, 2)}\n\n` +
|
|
206
|
-
`下一个索引位置: ${currentStartIndex},总创建块数: ${createdBlocksCount}
|
|
207
|
-
|
|
227
|
+
`下一个索引位置: ${currentStartIndex},总创建块数: ${createdBlocksCount}`;
|
|
228
|
+
if (hasImageBlocks) {
|
|
229
|
+
responseText += `\n\n⚠️ 检测到 ${allImageBlocks.length} 个图片块已创建!\n` +
|
|
208
230
|
`图片块IDs: ${allImageBlocks.map(block => block.block_id).join(', ')}\n` +
|
|
209
|
-
`请使用 upload_and_bind_image_to_block 工具上传图片并绑定到对应的块ID
|
|
231
|
+
`请使用 upload_and_bind_image_to_block 工具上传图片并绑定到对应的块ID。`;
|
|
232
|
+
}
|
|
233
|
+
if (hasWhiteboardBlocks) {
|
|
234
|
+
responseText += `\n\n⚠️ 检测到 ${allWhiteboardBlocks.length} 个画板块已创建!\n` +
|
|
235
|
+
`画板块信息:\n${allWhiteboardBlocks.map((block) => ` - blockId: ${block.block_id}, token: ${block.board?.token || 'N/A'}\n`).join('')}` +
|
|
236
|
+
`请使用 fill_whiteboard_with_plantuml 工具填充画板内容,使用返回的 token 作为 whiteboardId 参数。支持 PlantUML (syntax_type: 1) 和 Mermaid (syntax_type: 2) 两种格式。`;
|
|
237
|
+
}
|
|
210
238
|
return {
|
|
211
239
|
content: [
|
|
212
240
|
{
|
|
@@ -659,4 +687,97 @@ export function registerFeishuBlockTools(server, feishuService) {
|
|
|
659
687
|
};
|
|
660
688
|
}
|
|
661
689
|
});
|
|
690
|
+
// 添加批量填充画板工具(支持 PlantUML 和 Mermaid)
|
|
691
|
+
server.tool('fill_whiteboard_with_plantuml', 'Batch fills multiple whiteboard blocks with diagram content (PlantUML or Mermaid). Use this tool after creating whiteboard blocks with batch_create_feishu_blocks tool. Each item in the array should contain whiteboardId (the token from board.token field), code and syntax_type. Supports both PlantUML (syntax_type: 1) and Mermaid (syntax_type: 2) formats. Returns detailed results including which whiteboards were filled successfully and which failed, along with failure reasons. The same whiteboard can be filled multiple times.', {
|
|
692
|
+
whiteboards: WhiteboardFillArraySchema,
|
|
693
|
+
}, async ({ whiteboards }) => {
|
|
694
|
+
try {
|
|
695
|
+
if (!feishuService) {
|
|
696
|
+
return {
|
|
697
|
+
content: [{ type: 'text', text: '飞书服务未初始化,请检查配置' }],
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
if (!whiteboards || whiteboards.length === 0) {
|
|
701
|
+
return {
|
|
702
|
+
content: [{ type: 'text', text: '错误:画板数组不能为空' }],
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
Logger.info(`开始批量填充画板内容,共 ${whiteboards.length} 个画板`);
|
|
706
|
+
const results = [];
|
|
707
|
+
let successCount = 0;
|
|
708
|
+
let failCount = 0;
|
|
709
|
+
// 逐个处理每个画板
|
|
710
|
+
for (let i = 0; i < whiteboards.length; i++) {
|
|
711
|
+
const item = whiteboards[i];
|
|
712
|
+
const { whiteboardId, code, syntax_type } = item;
|
|
713
|
+
const syntaxTypeName = syntax_type === 1 ? 'PlantUML' : 'Mermaid';
|
|
714
|
+
Logger.info(`处理第 ${i + 1}/${whiteboards.length} 个画板,画板ID: ${whiteboardId},语法类型: ${syntaxTypeName}`);
|
|
715
|
+
try {
|
|
716
|
+
const result = await feishuService.createDiagramNode(whiteboardId, code, syntax_type);
|
|
717
|
+
Logger.info(`画板填充成功,画板ID: ${whiteboardId}`);
|
|
718
|
+
successCount++;
|
|
719
|
+
results.push({
|
|
720
|
+
whiteboardId: whiteboardId,
|
|
721
|
+
syntaxType: syntaxTypeName,
|
|
722
|
+
status: 'success',
|
|
723
|
+
nodeId: result.node_id,
|
|
724
|
+
result: result
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
Logger.error(`画板填充失败,画板ID: ${whiteboardId}`, error);
|
|
729
|
+
failCount++;
|
|
730
|
+
// 提取详细的错误信息
|
|
731
|
+
let errorMessage = formatErrorMessage(error);
|
|
732
|
+
let errorCode;
|
|
733
|
+
let logId;
|
|
734
|
+
if (error?.apiError) {
|
|
735
|
+
const apiError = error.apiError;
|
|
736
|
+
if (apiError.code !== undefined && apiError.msg) {
|
|
737
|
+
errorCode = apiError.code;
|
|
738
|
+
errorMessage = apiError.msg;
|
|
739
|
+
if (apiError.log_id) {
|
|
740
|
+
logId = apiError.log_id;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
else if (error?.err) {
|
|
745
|
+
errorMessage = error.err;
|
|
746
|
+
}
|
|
747
|
+
else if (error?.message) {
|
|
748
|
+
errorMessage = error.message;
|
|
749
|
+
}
|
|
750
|
+
results.push({
|
|
751
|
+
whiteboardId: whiteboardId,
|
|
752
|
+
syntaxType: syntaxTypeName,
|
|
753
|
+
status: 'failed',
|
|
754
|
+
error: {
|
|
755
|
+
message: errorMessage,
|
|
756
|
+
code: errorCode,
|
|
757
|
+
logId: logId,
|
|
758
|
+
details: error
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// 构建返回结果
|
|
764
|
+
const summary = {
|
|
765
|
+
total: whiteboards.length,
|
|
766
|
+
success: successCount,
|
|
767
|
+
failed: failCount,
|
|
768
|
+
results: results
|
|
769
|
+
};
|
|
770
|
+
Logger.info(`批量填充画板完成,成功: ${successCount},失败: ${failCount}`);
|
|
771
|
+
return {
|
|
772
|
+
content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }],
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
Logger.error(`批量填充画板内容失败:`, error);
|
|
777
|
+
const errorMessage = formatErrorMessage(error);
|
|
778
|
+
return {
|
|
779
|
+
content: [{ type: 'text', text: `批量填充画板内容失败: ${errorMessage}\n\n错误详情: ${JSON.stringify(error, null, 2)}` }],
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
});
|
|
662
783
|
}
|
package/dist/server.js
CHANGED
|
@@ -7,7 +7,7 @@ import { Logger } from './utils/logger.js';
|
|
|
7
7
|
import { SSEConnectionManager } from './manager/sseConnectionManager.js';
|
|
8
8
|
import { FeishuMcp } from './mcp/feishuMcp.js';
|
|
9
9
|
import { callback } from './services/callbackService.js';
|
|
10
|
-
import { UserAuthManager, UserContextManager, getBaseUrl, TokenCacheManager } from './utils/auth/index.js';
|
|
10
|
+
import { UserAuthManager, UserContextManager, getBaseUrl, TokenCacheManager, TokenRefreshManager } from './utils/auth/index.js';
|
|
11
11
|
export class FeishuMcpServer {
|
|
12
12
|
constructor() {
|
|
13
13
|
Object.defineProperty(this, "connectionManager", {
|
|
@@ -33,6 +33,10 @@ export class FeishuMcpServer {
|
|
|
33
33
|
this.userContextManager = UserContextManager.getInstance();
|
|
34
34
|
// 初始化TokenCacheManager,确保在启动时从文件加载缓存
|
|
35
35
|
TokenCacheManager.getInstance();
|
|
36
|
+
// 启动Token自动刷新管理器
|
|
37
|
+
const tokenRefreshManager = TokenRefreshManager.getInstance();
|
|
38
|
+
tokenRefreshManager.start();
|
|
39
|
+
Logger.info('Token自动刷新管理器已在服务器启动时初始化');
|
|
36
40
|
}
|
|
37
41
|
async connect(transport) {
|
|
38
42
|
const server = new FeishuMcp();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import axios, { AxiosError } from 'axios';
|
|
2
2
|
import FormData from 'form-data';
|
|
3
3
|
import { Logger } from '../utils/logger.js';
|
|
4
|
-
import { formatErrorMessage, AuthRequiredError } from '../utils/error.js';
|
|
4
|
+
import { formatErrorMessage, AuthRequiredError, ScopeInsufficientError } from '../utils/error.js';
|
|
5
5
|
import { Config } from '../utils/config.js';
|
|
6
6
|
import { TokenCacheManager, UserContextManager, AuthUtils } from '../utils/auth/index.js';
|
|
7
7
|
/**
|
|
@@ -126,6 +126,10 @@ export class BaseApiService {
|
|
|
126
126
|
}
|
|
127
127
|
catch (error) {
|
|
128
128
|
const config = Config.getInstance().feishu;
|
|
129
|
+
// 优先处理权限不足异常
|
|
130
|
+
if (error instanceof ScopeInsufficientError) {
|
|
131
|
+
return this.handleScopeInsufficientError(error);
|
|
132
|
+
}
|
|
129
133
|
// 处理授权异常
|
|
130
134
|
if (error instanceof AuthRequiredError) {
|
|
131
135
|
return this.handleAuthFailure(config.authType === "tenant", clientKey, baseUrl, userKey);
|
|
@@ -212,6 +216,13 @@ export class BaseApiService {
|
|
|
212
216
|
async delete(endpoint, data, needsAuth = true) {
|
|
213
217
|
return this.request(endpoint, 'DELETE', data, needsAuth);
|
|
214
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* 处理权限不足异常
|
|
221
|
+
* @param error 权限不足错误
|
|
222
|
+
*/
|
|
223
|
+
handleScopeInsufficientError(error) {
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
215
226
|
/**
|
|
216
227
|
* 处理认证失败
|
|
217
228
|
* @param tenant 是否是tenant
|
|
@@ -300,7 +311,7 @@ export class BaseApiService {
|
|
|
300
311
|
const { appId, appSecret } = Config.getInstance().feishu;
|
|
301
312
|
const clientKey = AuthUtils.generateClientKey(userKey);
|
|
302
313
|
const redirect_uri = `${baseUrl}/callback`;
|
|
303
|
-
const scope = encodeURIComponent('base:app:read bitable:app bitable:app:readonly board:whiteboard:node:read contact:user.employee_id:readonly docs:document.content:read docx:document docx:document.block:convert docx:document:create docx:document:readonly drive:drive drive:drive:readonly drive:file drive:file:upload sheets:spreadsheet sheets:spreadsheet:readonly space:document:retrieve space:folder:create wiki:space:read wiki:space:retrieve wiki:wiki wiki:wiki:readonly offline_access');
|
|
314
|
+
const scope = encodeURIComponent('base:app:read bitable:app bitable:app:readonly board:whiteboard:node:read board:whiteboard:node:create contact:user.employee_id:readonly docs:document.content:read docx:document docx:document.block:convert docx:document:create docx:document:readonly drive:drive drive:drive:readonly drive:file drive:file:upload sheets:spreadsheet sheets:spreadsheet:readonly space:document:retrieve space:folder:create wiki:space:read wiki:space:retrieve wiki:wiki wiki:wiki:readonly offline_access');
|
|
304
315
|
const state = AuthUtils.encodeState(appId, appSecret, clientKey, redirect_uri);
|
|
305
316
|
return `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${appId}&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${scope}&state=${state}`;
|
|
306
317
|
}
|
|
@@ -10,6 +10,7 @@ export var BlockType;
|
|
|
10
10
|
BlockType["LIST"] = "list";
|
|
11
11
|
BlockType["IMAGE"] = "image";
|
|
12
12
|
BlockType["MERMAID"] = "mermaid";
|
|
13
|
+
BlockType["WHITEBOARD"] = "whiteboard";
|
|
13
14
|
})(BlockType || (BlockType = {}));
|
|
14
15
|
/**
|
|
15
16
|
* 对齐方式枚举
|
|
@@ -78,6 +79,8 @@ export class BlockFactory {
|
|
|
78
79
|
return this.createImageBlock(options);
|
|
79
80
|
case BlockType.MERMAID:
|
|
80
81
|
return this.createMermaidBlock(options);
|
|
82
|
+
case BlockType.WHITEBOARD:
|
|
83
|
+
return this.createWhiteboardBlock(options);
|
|
81
84
|
default:
|
|
82
85
|
Logger.error(`不支持的块类型: ${type}`);
|
|
83
86
|
throw new Error(`不支持的块类型: ${type}`);
|
|
@@ -246,6 +249,20 @@ export class BlockFactory {
|
|
|
246
249
|
},
|
|
247
250
|
};
|
|
248
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* 创建画板块内容(空画板块,需要后续填充内容)
|
|
254
|
+
* @param options 画板块选项
|
|
255
|
+
* @returns 画板块内容对象
|
|
256
|
+
*/
|
|
257
|
+
createWhiteboardBlock(options = {}) {
|
|
258
|
+
const { align = AlignType.CENTER } = options;
|
|
259
|
+
return {
|
|
260
|
+
block_type: 43, // 43表示画板块
|
|
261
|
+
board: {
|
|
262
|
+
align: align // 1 居左,2 居中,3 居右
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
249
266
|
/**
|
|
250
267
|
* 创建表格块
|
|
251
268
|
* @param options 表格块选项
|
|
@@ -74,6 +74,9 @@ export async function callback(req, res) {
|
|
|
74
74
|
if (data.refresh_token_expires_in) {
|
|
75
75
|
data.refresh_token_expires_at = Math.floor(Date.now() / 1000) + data.refresh_token_expires_in;
|
|
76
76
|
}
|
|
77
|
+
// 添加client_id和client_secret,用于后续刷新token
|
|
78
|
+
data.client_id = appId;
|
|
79
|
+
data.client_secret = appSecret;
|
|
77
80
|
// 缓存token信息
|
|
78
81
|
const refreshTtl = data.refresh_token_expires_in || 3600 * 24 * 365; // 默认1年
|
|
79
82
|
tokenCacheManager.cacheUserToken(clientKey, data, refreshTtl);
|