feishu-mcp 0.1.1 → 0.1.3
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 +25 -64
- package/dist/mcp/feishuMcp.js +1 -1
- package/dist/mcp/tools/feishuBlockTools.js +43 -2
- package/dist/mcp/tools/feishuTools.js +20 -3
- package/dist/server.js +42 -22
- package/dist/services/baseService.js +122 -34
- package/dist/services/blockFactory.js +88 -0
- package/dist/services/callbackService.js +37 -22
- package/dist/services/feishuApiService.js +225 -19
- package/dist/services/feishuAuthService.js +0 -137
- package/dist/types/feishuSchema.js +27 -0
- package/dist/utils/auth/authUtils.js +71 -0
- package/dist/utils/auth/index.js +4 -0
- package/dist/utils/auth/tokenCacheManager.js +420 -0
- package/dist/utils/auth/userAuthManager.js +104 -0
- package/dist/utils/auth/userContextManager.js +81 -0
- package/dist/utils/cache.js +0 -98
- package/dist/utils/error.js +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
你可以通过以下视频了解 MCP 的实际使用效果和操作流程:
|
|
22
22
|
|
|
23
23
|
<a href="https://www.bilibili.com/video/BV1z7MdzoEfu/?vd_source=94c14da5a71aeb01f665f159dd3d89c8">
|
|
24
|
-
<img src="image/demo.png" alt="飞书 MCP 使用演示" width="
|
|
24
|
+
<img src="image/demo.png" alt="飞书 MCP 使用演示" width="300"/>
|
|
25
|
+
</a>
|
|
26
|
+
|
|
27
|
+
<a href="https://www.bilibili.com/video/BV18z3gzdE1w/?vd_source=94c14da5a71aeb01f665f159dd3d89c8">
|
|
28
|
+
<img src="image/demo_1.png" alt="飞书 MCP 使用演示" width="300"/>
|
|
25
29
|
</a>
|
|
26
30
|
|
|
27
31
|
> ⭐ **Star 本项目,第一时间获取最新功能和重要更新!** 关注项目可以让你不错过任何新特性、修复和优化,助你持续高效使用。你的支持也将帮助我们更好地完善和发展项目。⭐
|
|
@@ -44,13 +48,12 @@
|
|
|
44
48
|
| **工具功能** | `convert_feishu_wiki_to_document_id` | Wiki链接转换 | 将Wiki链接转为文档ID | ✅ 已完成 |
|
|
45
49
|
| | `get_feishu_image_resource` | 获取图片资源 | 下载文档中的图片 | ✅ 已完成 |
|
|
46
50
|
| | `get_feishu_whiteboard_content` | 获取画板内容 | 获取画板中的图形元素和结构(流程图、思维导图等) | ✅ 已完成 |
|
|
47
|
-
| **高级功能** |
|
|
48
|
-
| | 图表插入 | 支持各类数据可视化图表 | 数据展示和分析 | 🚧 计划中 |
|
|
51
|
+
| **高级功能** | `create_feishu_table` | 创建和编辑表格 | 结构化数据展示 | ✅ 已完成 |
|
|
49
52
|
| | 流程图插入 | 支持流程图和思维导图 | 流程梳理和可视化 | ✅ 已完成 |
|
|
50
53
|
| 图片插入 | `upload_and_bind_image_to_block` | 支持插入本地和远程图片 | 修改文档内容 | ✅ 已完成 |
|
|
51
54
|
| | 公式支持 | 支持数学公式 | 学术和技术文档 | ✅ 已完成 |
|
|
52
55
|
|
|
53
|
-
### 🎨
|
|
56
|
+
### 🎨 支持的样式功能(基本支持md所有格式)
|
|
54
57
|
|
|
55
58
|
- **文本样式**:粗体、斜体、下划线、删除线、行内代码
|
|
56
59
|
- **文本颜色**:灰色、棕色、橙色、黄色、绿色、蓝色、紫色
|
|
@@ -61,6 +64,7 @@
|
|
|
61
64
|
- **图片**:支持本地图片和网络图片
|
|
62
65
|
- **公式**:在文本块中插入数学公式,支持LaTeX语法
|
|
63
66
|
- **mermaid图表**:支持流程图、时序图、思维导图、类图、饼图等等
|
|
67
|
+
- **表格**:支持创建多行列表格,单元格可包含文本、标题、列表、代码块等多种内容类型
|
|
64
68
|
|
|
65
69
|
---
|
|
66
70
|
|
|
@@ -71,8 +75,10 @@
|
|
|
71
75
|
- ~~**批量增强**:新增批量更新、批量图片上传,单次操作效率提升50%~~ 0.0.15 ✅
|
|
72
76
|
- **流程优化**:减少多步调用,实现一键完成复杂任务
|
|
73
77
|
- ~~**支持多种凭证类型**:包括 tenant_access_token和 user_access_token,满足不同场景下的认证需求~~ (飞书应用配置发生变更) 0.0.16 ✅。
|
|
74
|
-
-
|
|
78
|
+
- ~~**支持cursor用户登录**:方便在cursor平台用户认证 不做了,没必要 ❌~~
|
|
75
79
|
- ~~**支持mermaid图表**:流程图、时序图等等,丰富文档内容~~ 0.1.11 ✅
|
|
80
|
+
- ~~**支持表格创建**:创建包含各种块类型的复杂表格,支持样式控制~~ 0.1.2 ✅
|
|
81
|
+
- ~~**支持飞书多用户user认证**:一人部署,可以多人使用~~ 0.1.3 ✅
|
|
76
82
|
|
|
77
83
|
---
|
|
78
84
|
|
|
@@ -91,29 +97,10 @@
|
|
|
91
97
|
### 方式一:使用 NPM 快速运行
|
|
92
98
|
|
|
93
99
|
```bash
|
|
94
|
-
npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret=<你的飞书应用密钥>
|
|
100
|
+
npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret=<你的飞书应用密钥> --feishu_auth_type=<tenant/user>
|
|
95
101
|
```
|
|
96
102
|
|
|
97
|
-
###
|
|
98
|
-
|
|
99
|
-
**已发布到 Smithery 平台,可访问:** https://smithery.ai/server/@cso1z/feishu-mcp
|
|
100
|
-
|
|
101
|
-
### 方式三:本地运行
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
#### 🌿 分支说明
|
|
105
|
-
|
|
106
|
-
本项目采用主分支(main)+功能分支(feature/xxx)协作模式:
|
|
107
|
-
|
|
108
|
-
- **main**
|
|
109
|
-
稳定主线分支,始终保持可用、可部署状态。所有已验证和正式发布的功能都会合并到 main 分支。
|
|
110
|
-
|
|
111
|
-
- **multi-user-token**
|
|
112
|
-
|
|
113
|
-
多用户隔离与按用户授权的 Feishu Token 获取功能开发分支。该分支支持 userKey 参数、按用户获取和缓存 Token、自定义 Token 服务等高级特性,适用于需要多用户隔离和授权场景的开发与测试。
|
|
114
|
-
> ⚠️ 该分支为 beta 版本,功能更新相较 main 分支可能会有延后。如有相关需求请在 issue 区留言,我会优先同步最新功能到该分支。
|
|
115
|
-
|
|
116
|
-
|
|
103
|
+
### 方式二:本地运行
|
|
117
104
|
1. **克隆仓库**
|
|
118
105
|
```bash
|
|
119
106
|
git clone https://github.com/cso1z/Feishu-MCP.git
|
|
@@ -126,16 +113,6 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
|
|
|
126
113
|
```
|
|
127
114
|
|
|
128
115
|
3. **配置环境变量(复制一份.env.example保存为.env文件)**
|
|
129
|
-
|
|
130
|
-
**macOS/Linux:**
|
|
131
|
-
```bash
|
|
132
|
-
cp .env.example .env
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
**Windows:**
|
|
136
|
-
```cmd
|
|
137
|
-
copy .env.example .env
|
|
138
|
-
```
|
|
139
116
|
|
|
140
117
|
4. **编辑 .env 文件**
|
|
141
118
|
在项目根目录下找到并用任意文本编辑器打开 `.env` 文件,填写你的飞书应用凭证:
|
|
@@ -143,6 +120,7 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
|
|
|
143
120
|
FEISHU_APP_ID=cli_xxxxx
|
|
144
121
|
FEISHU_APP_SECRET=xxxxx
|
|
145
122
|
PORT=3333
|
|
123
|
+
FEISHU_AUTH_TYPE=tenant/user
|
|
146
124
|
```
|
|
147
125
|
|
|
148
126
|
5. **运行服务器**
|
|
@@ -154,32 +132,12 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
|
|
|
154
132
|
|
|
155
133
|
### 环境变量配置
|
|
156
134
|
|
|
157
|
-
| 变量名 | 必需 | 描述
|
|
158
|
-
|
|
159
|
-
| `FEISHU_APP_ID` | ✅ | 飞书应用 ID
|
|
160
|
-
| `FEISHU_APP_SECRET` | ✅ | 飞书应用密钥
|
|
161
|
-
| `PORT` | ❌ | 服务器端口
|
|
162
|
-
| `FEISHU_AUTH_TYPE` | ❌ |
|
|
163
|
-
| `FEISHU_TOKEN_ENDPOINT` | ❌ | 获取 token 的接口地址,仅当自定义 token 管理时需要 | `http://localhost:3333/getToken` |
|
|
164
|
-
|
|
165
|
-
> **注意:**
|
|
166
|
-
> - 只有本地运行服务时支持 `user` 凭证,否则需配置 `FEISHU_TOKEN_ENDPOINT`,自行实现 token 获取与管理(可参考 `callbackService`、`feishuAuthService`)。
|
|
167
|
-
> - `FEISHU_TOKEN_ENDPOINT` 接口参数:`client_id`, `client_secret`, `token_type`(可选,tenant/user);返回参数:`access_token`, `needAuth`, `url`(需授权时), `expires_in`(单位:s)。
|
|
168
|
-
|
|
169
|
-
### 命令行参数
|
|
170
|
-
|
|
171
|
-
| 参数 | 描述 | 默认值 |
|
|
172
|
-
|------|------|-------|
|
|
173
|
-
| `--port` | 服务器监听端口 | `3333` |
|
|
174
|
-
| `--log-level` | 日志级别 (debug/info/log/warn/error/none) | `info` |
|
|
175
|
-
| `--feishu-app-id` | 飞书应用 ID | - |
|
|
176
|
-
| `--feishu-app-secret` | 飞书应用密钥 | - |
|
|
177
|
-
| `--feishu-base-url` | 飞书API基础URL | `https://open.feishu.cn/open-apis` |
|
|
178
|
-
| `--cache-enabled` | 是否启用缓存 | `true` |
|
|
179
|
-
| `--cache-ttl` | 缓存生存时间(秒) | `3600` |
|
|
180
|
-
| `--stdio` | 命令模式运行 | - |
|
|
181
|
-
| `--help` | 显示帮助菜单 | - |
|
|
182
|
-
| `--version` | 显示版本号 | - |
|
|
135
|
+
| 变量名 | 必需 | 描述 | 默认值 |
|
|
136
|
+
|--------|------|--------------------------------------------------------------------|-------|
|
|
137
|
+
| `FEISHU_APP_ID` | ✅ | 飞书应用 ID | - |
|
|
138
|
+
| `FEISHU_APP_SECRET` | ✅ | 飞书应用密钥 | - |
|
|
139
|
+
| `PORT` | ❌ | 服务器端口 | `3333` |
|
|
140
|
+
| `FEISHU_AUTH_TYPE` | ❌ | 认证凭证类型,使用 `user`(用户级,使用时是用户的身份操作飞书文档,需OAuth授权),使用 `tenant`(应用级,默认) | `tenant` |
|
|
183
141
|
|
|
184
142
|
### 配置文件方式(适用于 Cursor、Cline 等)
|
|
185
143
|
|
|
@@ -191,11 +149,12 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
|
|
|
191
149
|
"args": ["-y", "feishu-mcp", "--stdio"],
|
|
192
150
|
"env": {
|
|
193
151
|
"FEISHU_APP_ID": "<你的飞书应用ID>",
|
|
194
|
-
"FEISHU_APP_SECRET": "<你的飞书应用密钥>"
|
|
152
|
+
"FEISHU_APP_SECRET": "<你的飞书应用密钥>",
|
|
153
|
+
"FEISHU_AUTH_TYPE": "<tenant/user>"
|
|
195
154
|
}
|
|
196
155
|
},
|
|
197
156
|
"feishu_local": {
|
|
198
|
-
"url": "http://localhost:3333/sse"
|
|
157
|
+
"url": "http://localhost:3333/sse?:userKey=123456"
|
|
199
158
|
}
|
|
200
159
|
}
|
|
201
160
|
}
|
|
@@ -216,6 +175,8 @@ npx feishu-mcp@latest --feishu-app-id=<你的飞书应用ID> --feishu-app-secret
|
|
|
216
175
|
3. ### **公式使用说明**:
|
|
217
176
|
在文本块中可以混合使用普通文本和公式元素。公式使用LaTeX语法,如:`1+2=3`、`\frac{a}{b}`、`\sqrt{x}`等。支持在同一文本块中包含多个公式和普通文本。
|
|
218
177
|
|
|
178
|
+
4. ### **使用飞书user认证**:
|
|
179
|
+
user认证与tenant认证在增加权限时是有区分的,所以**在初次由tenant切换到user时需要注意配置的权限**;为了区分不同的用户需要在配置mcp server服务的url增加query参数:userKey,**该值是用户的唯一标识 所以最好在设置时越随机越好**
|
|
219
180
|
---
|
|
220
181
|
|
|
221
182
|
## 🚨 故障排查
|
package/dist/mcp/feishuMcp.js
CHANGED
|
@@ -6,7 +6,7 @@ import { registerFeishuBlockTools } from './tools/feishuBlockTools.js';
|
|
|
6
6
|
import { registerFeishuFolderTools } from './tools/feishuFolderTools.js';
|
|
7
7
|
const serverInfo = {
|
|
8
8
|
name: "Feishu MCP Server",
|
|
9
|
-
version: "0.1.
|
|
9
|
+
version: "0.1.3",
|
|
10
10
|
};
|
|
11
11
|
const serverOptions = {
|
|
12
12
|
capabilities: { logging: {}, tools: {} },
|
|
@@ -8,11 +8,11 @@ import { DocumentIdSchema, ParentBlockIdSchema, BlockIdSchema, IndexSchema, Star
|
|
|
8
8
|
TextElementsArraySchema,
|
|
9
9
|
// CodeLanguageSchema,
|
|
10
10
|
// CodeWrapSchema,
|
|
11
|
-
BlockConfigSchema, MediaIdSchema, MediaExtraSchema, ImagesArraySchema,
|
|
11
|
+
BlockConfigSchema, MediaIdSchema, MediaExtraSchema, ImagesArraySchema,
|
|
12
12
|
// MermaidCodeSchema,
|
|
13
13
|
// ImageWidthSchema,
|
|
14
14
|
// ImageHeightSchema
|
|
15
|
-
} from '../../types/feishuSchema.js';
|
|
15
|
+
TableCreateSchema } from '../../types/feishuSchema.js';
|
|
16
16
|
/**
|
|
17
17
|
* 注册飞书块相关的MCP工具
|
|
18
18
|
* @param server MCP服务器实例
|
|
@@ -618,4 +618,45 @@ export function registerFeishuBlockTools(server, feishuService) {
|
|
|
618
618
|
};
|
|
619
619
|
}
|
|
620
620
|
});
|
|
621
|
+
// 添加创建飞书表格工具
|
|
622
|
+
server.tool('create_feishu_table', 'Creates a table block in a Feishu document with specified rows and columns. Each cell can contain different types of content blocks (text, lists, code, etc.). This tool creates the complete table structure including table cells and their content. Note: For Feishu wiki links (https://xxx.feishu.cn/wiki/xxx) you must first use convert_feishu_wiki_to_document_id tool to obtain a compatible document ID.', {
|
|
623
|
+
documentId: DocumentIdSchema,
|
|
624
|
+
parentBlockId: ParentBlockIdSchema,
|
|
625
|
+
index: IndexSchema,
|
|
626
|
+
tableConfig: TableCreateSchema,
|
|
627
|
+
}, async ({ documentId, parentBlockId, index = 0, tableConfig }) => {
|
|
628
|
+
try {
|
|
629
|
+
if (!feishuService) {
|
|
630
|
+
return {
|
|
631
|
+
content: [{ type: 'text', text: '飞书服务未初始化,请检查配置' }],
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
Logger.info(`开始创建飞书表格,文档ID: ${documentId},父块ID: ${parentBlockId},表格大小: ${tableConfig.rowSize}x${tableConfig.columnSize},插入位置: ${index}`);
|
|
635
|
+
const result = await feishuService.createTableBlock(documentId, parentBlockId, tableConfig, index);
|
|
636
|
+
// 构建返回信息
|
|
637
|
+
let resultText = `表格创建成功!\n\n表格大小: ${tableConfig.rowSize}x${tableConfig.columnSize}\n`;
|
|
638
|
+
// 如果有图片token,显示图片信息
|
|
639
|
+
if (result.imageTokens && result.imageTokens.length > 0) {
|
|
640
|
+
resultText += `\n\n📸 发现 ${result.imageTokens.length} 个图片:\n`;
|
|
641
|
+
result.imageTokens.forEach((imageToken, index) => {
|
|
642
|
+
resultText += `${index + 1}. 坐标(${imageToken.row}, ${imageToken.column}) - blockId: ${imageToken.blockId}\n`;
|
|
643
|
+
});
|
|
644
|
+
resultText += "你需要使用upload_and_bind_image_to_block工具绑定图片";
|
|
645
|
+
}
|
|
646
|
+
resultText += `\n\n完整结果:\n${JSON.stringify(result, null, 2)}`;
|
|
647
|
+
return {
|
|
648
|
+
content: [{
|
|
649
|
+
type: 'text',
|
|
650
|
+
text: resultText
|
|
651
|
+
}],
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
Logger.error(`创建飞书表格失败:`, error);
|
|
656
|
+
const errorMessage = formatErrorMessage(error);
|
|
657
|
+
return {
|
|
658
|
+
content: [{ type: 'text', text: `创建飞书表格失败: ${errorMessage}` }],
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
});
|
|
621
662
|
}
|
|
@@ -107,11 +107,14 @@ export function registerFeishuTools(server, feishuService) {
|
|
|
107
107
|
// 检查是否有 block_type 为 43 的块(画板块)
|
|
108
108
|
const whiteboardBlocks = blocks.filter((block) => block.block_type === 43);
|
|
109
109
|
const hasWhiteboardBlocks = whiteboardBlocks.length > 0;
|
|
110
|
+
// 检查是否有 block_type 为 27 的块(图片块)
|
|
111
|
+
const imageBlocks = blocks.filter((block) => block.block_type === 27);
|
|
112
|
+
const hasImageBlocks = imageBlocks.length > 0;
|
|
110
113
|
let responseText = JSON.stringify(blocks, null, 2);
|
|
111
114
|
if (hasWhiteboardBlocks) {
|
|
112
115
|
responseText += '\n\n⚠️ 检测到画板块 (block_type: 43)!\n';
|
|
113
|
-
responseText += `发现 ${whiteboardBlocks.length}
|
|
114
|
-
responseText += '
|
|
116
|
+
responseText += `发现 ${whiteboardBlocks.length} 个画板块。\n`;
|
|
117
|
+
responseText += '💡 提示:如果您需要获取画板的具体内容(如流程图、思维导图等),可以使用 get_feishu_whiteboard_content 工具。\n';
|
|
115
118
|
responseText += '画板信息:\n';
|
|
116
119
|
whiteboardBlocks.forEach((block, index) => {
|
|
117
120
|
responseText += ` ${index + 1}. 块ID: ${block.block_id}`;
|
|
@@ -120,7 +123,21 @@ export function registerFeishuTools(server, feishuService) {
|
|
|
120
123
|
}
|
|
121
124
|
responseText += '\n';
|
|
122
125
|
});
|
|
123
|
-
responseText += '
|
|
126
|
+
responseText += '📝 注意:只有在需要分析画板内容时才调用上述工具,仅了解文档结构时无需获取。';
|
|
127
|
+
}
|
|
128
|
+
if (hasImageBlocks) {
|
|
129
|
+
responseText += '\n\n🖼️ 检测到图片块 (block_type: 27)!\n';
|
|
130
|
+
responseText += `发现 ${imageBlocks.length} 个图片块。\n`;
|
|
131
|
+
responseText += '💡 提示:如果您需要查看图片的具体内容,可以使用 get_feishu_image_resource 工具下载图片。\n';
|
|
132
|
+
responseText += '图片信息:\n';
|
|
133
|
+
imageBlocks.forEach((block, index) => {
|
|
134
|
+
responseText += ` ${index + 1}. 块ID: ${block.block_id}`;
|
|
135
|
+
if (block.image && block.image.token) {
|
|
136
|
+
responseText += `, 媒体ID: ${block.image.token}`;
|
|
137
|
+
}
|
|
138
|
+
responseText += '\n';
|
|
139
|
+
});
|
|
140
|
+
responseText += '📝 注意:只有在需要查看图片内容时才调用上述工具,仅了解文档结构时无需获取。';
|
|
124
141
|
}
|
|
125
142
|
return {
|
|
126
143
|
content: [{ type: 'text', text: responseText }],
|
package/dist/server.js
CHANGED
|
@@ -6,7 +6,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
6
6
|
import { Logger } from './utils/logger.js';
|
|
7
7
|
import { SSEConnectionManager } from './manager/sseConnectionManager.js';
|
|
8
8
|
import { FeishuMcp } from './mcp/feishuMcp.js';
|
|
9
|
-
import { callback
|
|
9
|
+
import { callback } from './services/callbackService.js';
|
|
10
|
+
import { UserAuthManager, UserContextManager, getBaseUrl, TokenCacheManager } from './utils/auth';
|
|
10
11
|
export class FeishuMcpServer {
|
|
11
12
|
constructor() {
|
|
12
13
|
Object.defineProperty(this, "connectionManager", {
|
|
@@ -15,7 +16,23 @@ export class FeishuMcpServer {
|
|
|
15
16
|
writable: true,
|
|
16
17
|
value: void 0
|
|
17
18
|
});
|
|
19
|
+
Object.defineProperty(this, "userAuthManager", {
|
|
20
|
+
enumerable: true,
|
|
21
|
+
configurable: true,
|
|
22
|
+
writable: true,
|
|
23
|
+
value: void 0
|
|
24
|
+
});
|
|
25
|
+
Object.defineProperty(this, "userContextManager", {
|
|
26
|
+
enumerable: true,
|
|
27
|
+
configurable: true,
|
|
28
|
+
writable: true,
|
|
29
|
+
value: void 0
|
|
30
|
+
});
|
|
18
31
|
this.connectionManager = new SSEConnectionManager();
|
|
32
|
+
this.userAuthManager = UserAuthManager.getInstance();
|
|
33
|
+
this.userContextManager = UserContextManager.getInstance();
|
|
34
|
+
// 初始化TokenCacheManager,确保在启动时从文件加载缓存
|
|
35
|
+
TokenCacheManager.getInstance();
|
|
19
36
|
}
|
|
20
37
|
async connect(transport) {
|
|
21
38
|
const server = new FeishuMcp();
|
|
@@ -143,9 +160,18 @@ export class FeishuMcpServer {
|
|
|
143
160
|
}
|
|
144
161
|
});
|
|
145
162
|
app.get('/sse', async (req, res) => {
|
|
163
|
+
// 获取 userKey 参数
|
|
164
|
+
let userKey = req.query.userKey;
|
|
146
165
|
const sseTransport = new SSEServerTransport('/messages', res);
|
|
147
166
|
const sessionId = sseTransport.sessionId;
|
|
148
|
-
|
|
167
|
+
// 如果 userKey 为空,使用 sessionId 替代
|
|
168
|
+
if (!userKey) {
|
|
169
|
+
userKey = sessionId;
|
|
170
|
+
}
|
|
171
|
+
Logger.log(`[SSE Connection] New SSE connection established for sessionId ${sessionId}, userKey: ${userKey}, params:${JSON.stringify(req.params)} headers:${JSON.stringify(req.headers)} `);
|
|
172
|
+
// 创建用户会话映射
|
|
173
|
+
this.userAuthManager.createSession(sessionId, userKey);
|
|
174
|
+
Logger.log(`[UserAuth] Created session mapping: sessionId=${sessionId}, userKey=${userKey}`);
|
|
149
175
|
this.connectionManager.addConnection(sessionId, sseTransport, req, res);
|
|
150
176
|
try {
|
|
151
177
|
const tempServer = new FeishuMcp();
|
|
@@ -155,6 +181,8 @@ export class FeishuMcpServer {
|
|
|
155
181
|
catch (error) {
|
|
156
182
|
Logger.error(`[SSE Connection] Error connecting server to transport for ${sessionId}:`, error);
|
|
157
183
|
this.connectionManager.removeConnection(sessionId);
|
|
184
|
+
// 清理用户会话映射
|
|
185
|
+
this.userAuthManager.removeSession(sessionId);
|
|
158
186
|
if (!res.writableEnded) {
|
|
159
187
|
res.status(500).end('Failed to connect MCP server to transport');
|
|
160
188
|
}
|
|
@@ -163,7 +191,9 @@ export class FeishuMcpServer {
|
|
|
163
191
|
});
|
|
164
192
|
app.post('/messages', async (req, res) => {
|
|
165
193
|
const sessionId = req.query.sessionId;
|
|
166
|
-
|
|
194
|
+
// 通过 sessionId 获取 userKey
|
|
195
|
+
const userKey = this.userAuthManager.getUserKeyBySessionId(sessionId);
|
|
196
|
+
Logger.info(`[SSE messages] Received message with sessionId: ${sessionId}, userKey: ${userKey}, params: ${JSON.stringify(req.query)}, body: ${JSON.stringify(req.body)}`);
|
|
167
197
|
if (!sessionId) {
|
|
168
198
|
res.status(400).send('Missing sessionId query parameter');
|
|
169
199
|
return;
|
|
@@ -176,27 +206,17 @@ export class FeishuMcpServer {
|
|
|
176
206
|
.send(`No active connection found for sessionId: ${sessionId}`);
|
|
177
207
|
return;
|
|
178
208
|
}
|
|
179
|
-
|
|
209
|
+
// 获取 baseUrl
|
|
210
|
+
const baseUrl = getBaseUrl(req);
|
|
211
|
+
// 在用户上下文中执行 transport.handlePostMessage
|
|
212
|
+
this.userContextManager.run({
|
|
213
|
+
userKey: userKey || '',
|
|
214
|
+
baseUrl: baseUrl
|
|
215
|
+
}, async () => {
|
|
216
|
+
await transport.handlePostMessage(req, res);
|
|
217
|
+
});
|
|
180
218
|
});
|
|
181
219
|
app.get('/callback', callback);
|
|
182
|
-
app.get('/getToken', async (req, res) => {
|
|
183
|
-
const { client_id, client_secret, token_type } = req.query;
|
|
184
|
-
if (!client_id || !client_secret) {
|
|
185
|
-
res.status(400).json({ code: 400, msg: '缺少 client_id 或 client_secret' });
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
try {
|
|
189
|
-
const tokenResult = await getTokenByParams({
|
|
190
|
-
client_id: client_id,
|
|
191
|
-
client_secret: client_secret,
|
|
192
|
-
token_type: token_type
|
|
193
|
-
});
|
|
194
|
-
res.json({ code: 0, msg: 'success', data: tokenResult });
|
|
195
|
-
}
|
|
196
|
-
catch (e) {
|
|
197
|
-
res.status(500).json({ code: 500, msg: e.message || '获取token失败' });
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
220
|
app.listen(port, '0.0.0.0', () => {
|
|
201
221
|
Logger.info(`HTTP server listening on port ${port}`);
|
|
202
222
|
Logger.info(`SSE endpoint available at http://localhost:${port}/sse`);
|
|
@@ -1,26 +1,14 @@
|
|
|
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 } from '../utils/error.js';
|
|
4
|
+
import { formatErrorMessage, AuthRequiredError } from '../utils/error.js';
|
|
5
|
+
import { Config } from '../utils/config.js';
|
|
6
|
+
import { TokenCacheManager, UserContextManager, AuthUtils } from '../utils/auth';
|
|
5
7
|
/**
|
|
6
8
|
* API服务基类
|
|
7
9
|
* 提供通用的HTTP请求处理和认证功能
|
|
8
10
|
*/
|
|
9
11
|
export class BaseApiService {
|
|
10
|
-
constructor() {
|
|
11
|
-
Object.defineProperty(this, "accessToken", {
|
|
12
|
-
enumerable: true,
|
|
13
|
-
configurable: true,
|
|
14
|
-
writable: true,
|
|
15
|
-
value: ''
|
|
16
|
-
});
|
|
17
|
-
Object.defineProperty(this, "tokenExpireTime", {
|
|
18
|
-
enumerable: true,
|
|
19
|
-
configurable: true,
|
|
20
|
-
writable: true,
|
|
21
|
-
value: null
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
12
|
/**
|
|
25
13
|
* 处理API错误
|
|
26
14
|
* @param error 错误对象
|
|
@@ -66,9 +54,16 @@ export class BaseApiService {
|
|
|
66
54
|
* @param needsAuth 是否需要认证
|
|
67
55
|
* @param additionalHeaders 附加请求头
|
|
68
56
|
* @param responseType 响应类型
|
|
57
|
+
* @param retry 是否允许重试,默认为false
|
|
69
58
|
* @returns 响应数据
|
|
70
59
|
*/
|
|
71
|
-
async request(endpoint, method = 'GET', data, needsAuth = true, additionalHeaders, responseType) {
|
|
60
|
+
async request(endpoint, method = 'GET', data, needsAuth = true, additionalHeaders, responseType, retry = false) {
|
|
61
|
+
// 获取用户上下文
|
|
62
|
+
const userContextManager = UserContextManager.getInstance();
|
|
63
|
+
const userKey = userContextManager.getUserKey();
|
|
64
|
+
const baseUrl = userContextManager.getBaseUrl();
|
|
65
|
+
const clientKey = AuthUtils.generateClientKey(userKey);
|
|
66
|
+
Logger.debug(`[BaseService] Request context - userKey: ${userKey}, baseUrl: ${baseUrl}`);
|
|
72
67
|
try {
|
|
73
68
|
// 构建请求URL
|
|
74
69
|
const url = `${this.getBaseUrl()}${endpoint}`;
|
|
@@ -86,7 +81,7 @@ export class BaseApiService {
|
|
|
86
81
|
}
|
|
87
82
|
// 添加认证令牌
|
|
88
83
|
if (needsAuth) {
|
|
89
|
-
const accessToken = await this.getAccessToken();
|
|
84
|
+
const accessToken = await this.getAccessToken(userKey);
|
|
90
85
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
91
86
|
}
|
|
92
87
|
// 记录请求信息
|
|
@@ -130,25 +125,26 @@ export class BaseApiService {
|
|
|
130
125
|
return response.data.data;
|
|
131
126
|
}
|
|
132
127
|
catch (error) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
this.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
128
|
+
const config = Config.getInstance().feishu;
|
|
129
|
+
// 处理授权异常
|
|
130
|
+
if (error instanceof AuthRequiredError) {
|
|
131
|
+
return this.handleAuthFailure(config.authType === "tenant", clientKey, baseUrl, userKey);
|
|
132
|
+
}
|
|
133
|
+
// 处理认证相关错误(401, 403等)
|
|
134
|
+
if (error instanceof AxiosError && error.response && (error.response.status >= 400 || error.response.status <= 499)) {
|
|
135
|
+
Logger.warn(`认证失败 (${error.response.status}): ${endpoint}`);
|
|
136
|
+
// 获取配置和token缓存管理器
|
|
137
|
+
const tokenCacheManager = TokenCacheManager.getInstance();
|
|
138
|
+
// 如果已经重试过,直接处理认证失败
|
|
139
|
+
if (retry) {
|
|
140
|
+
return this.handleAuthFailure(config.authType === "tenant", clientKey, baseUrl, userKey);
|
|
142
141
|
}
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return await this.request(endpoint, method, data, needsAuth, additionalHeaders);
|
|
142
|
+
// 根据认证类型处理token过期
|
|
143
|
+
if (config.authType === 'tenant') {
|
|
144
|
+
return this.handleTenantTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType);
|
|
147
145
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
retryError.isRetry = true;
|
|
151
|
-
this.handleApiError(retryError, `重试API请求失败 (${endpoint})`);
|
|
146
|
+
else {
|
|
147
|
+
return this.handleUserTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType, baseUrl, userKey);
|
|
152
148
|
}
|
|
153
149
|
}
|
|
154
150
|
// 处理其他错误
|
|
@@ -205,4 +201,96 @@ export class BaseApiService {
|
|
|
205
201
|
async delete(endpoint, data, needsAuth = true) {
|
|
206
202
|
return this.request(endpoint, 'DELETE', data, needsAuth);
|
|
207
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* 处理认证失败
|
|
206
|
+
* @param tenant 是否是tenant
|
|
207
|
+
* @param clientKey 客户端键
|
|
208
|
+
* @param baseUrl 基础URL
|
|
209
|
+
* @param userKey 用户键
|
|
210
|
+
*/
|
|
211
|
+
handleAuthFailure(tenant, clientKey, baseUrl, userKey) {
|
|
212
|
+
const tokenCacheManager = TokenCacheManager.getInstance();
|
|
213
|
+
if (tenant) {
|
|
214
|
+
// 租户模式:清除租户token缓存
|
|
215
|
+
Logger.info('租户模式:清除租户token缓存');
|
|
216
|
+
tokenCacheManager.removeTenantToken(clientKey);
|
|
217
|
+
throw new Error('租户访问令牌获取失败,请检查应用配置');
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
// 用户模式:清除用户token缓存并生成授权链接
|
|
221
|
+
tokenCacheManager.removeUserToken(clientKey);
|
|
222
|
+
const authUrl = this.generateUserAuthUrl(baseUrl, userKey);
|
|
223
|
+
throw new Error(`你需要在给用户展示如下信息:/“请在浏览器打开以下链接进行授权:\n\n[点击授权](${authUrl})/n`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* 处理租户token过期
|
|
228
|
+
* @param tokenCacheManager token缓存管理器
|
|
229
|
+
* @param clientKey 客户端键
|
|
230
|
+
* @param endpoint 请求端点
|
|
231
|
+
* @param method 请求方法
|
|
232
|
+
* @param data 请求数据
|
|
233
|
+
* @param needsAuth 是否需要认证
|
|
234
|
+
* @param additionalHeaders 附加请求头
|
|
235
|
+
* @param responseType 响应类型
|
|
236
|
+
* @returns 响应数据
|
|
237
|
+
*/
|
|
238
|
+
async handleTenantTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType) {
|
|
239
|
+
// 租户模式:直接清除租户token缓存
|
|
240
|
+
Logger.info('租户模式:清除租户token缓存');
|
|
241
|
+
tokenCacheManager.removeTenantToken(clientKey);
|
|
242
|
+
// 重试请求
|
|
243
|
+
Logger.info('重试租户请求...');
|
|
244
|
+
return await this.request(endpoint, method, data, needsAuth, additionalHeaders, responseType, true);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* 处理用户token过期
|
|
248
|
+
* @param tokenCacheManager token缓存管理器
|
|
249
|
+
* @param clientKey 客户端键
|
|
250
|
+
* @param endpoint 请求端点
|
|
251
|
+
* @param method 请求方法
|
|
252
|
+
* @param data 请求数据
|
|
253
|
+
* @param needsAuth 是否需要认证
|
|
254
|
+
* @param additionalHeaders 附加请求头
|
|
255
|
+
* @param responseType 响应类型
|
|
256
|
+
* @returns 响应数据
|
|
257
|
+
*/
|
|
258
|
+
async handleUserTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType, baseUrl, userKey) {
|
|
259
|
+
// 用户模式:检查用户token状态
|
|
260
|
+
const tokenStatus = tokenCacheManager.checkUserTokenStatus(clientKey);
|
|
261
|
+
Logger.debug(`用户token状态:`, tokenStatus);
|
|
262
|
+
if (tokenStatus.canRefresh && !tokenStatus.isExpired) {
|
|
263
|
+
// 有有效的refresh_token,设置token为过期状态,让下次请求时刷新
|
|
264
|
+
Logger.info('用户模式:token过期,将在下次请求时刷新');
|
|
265
|
+
const tokenInfo = tokenCacheManager.getUserTokenInfo(clientKey);
|
|
266
|
+
if (tokenInfo) {
|
|
267
|
+
// 设置access_token为过期,但保留refresh_token
|
|
268
|
+
tokenInfo.expires_at = Math.floor(Date.now() / 1000) - 1;
|
|
269
|
+
tokenCacheManager.cacheUserToken(clientKey, tokenInfo);
|
|
270
|
+
}
|
|
271
|
+
// 重试请求
|
|
272
|
+
Logger.info('重试用户请求...');
|
|
273
|
+
return await this.request(endpoint, method, data, needsAuth, additionalHeaders, responseType, true);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// refresh_token已过期或不存在,直接清除缓存
|
|
277
|
+
Logger.warn('用户模式:refresh_token已过期,清除用户token缓存');
|
|
278
|
+
tokenCacheManager.removeUserToken(clientKey);
|
|
279
|
+
return this.handleAuthFailure(true, clientKey, baseUrl, userKey);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* 生成用户授权URL
|
|
284
|
+
* @param baseUrl 基础URL
|
|
285
|
+
* @param userKey 用户键
|
|
286
|
+
* @returns 授权URL
|
|
287
|
+
*/
|
|
288
|
+
generateUserAuthUrl(baseUrl, userKey) {
|
|
289
|
+
const { appId, appSecret } = Config.getInstance().feishu;
|
|
290
|
+
const clientKey = AuthUtils.generateClientKey(userKey);
|
|
291
|
+
const redirect_uri = `${baseUrl}/callback`;
|
|
292
|
+
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');
|
|
293
|
+
const state = AuthUtils.encodeState(appId, appSecret, clientKey, redirect_uri);
|
|
294
|
+
return `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${appId}&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${scope}&state=${state}`;
|
|
295
|
+
}
|
|
208
296
|
}
|