feishu-mcp 0.2.2 → 0.2.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 +356 -266
- package/dist/cli/auth.js +117 -0
- package/dist/cli/commands/auth.js +141 -0
- package/dist/cli/commands/config.js +86 -0
- package/dist/cli/commands/guide.js +68 -0
- package/dist/cli/dispatcher.js +96 -0
- package/dist/cli/index.js +95 -0
- package/dist/manager/sseConnectionManager.js +2 -0
- package/dist/mcp/feishuMcp.js +25 -25
- package/dist/mcp/tools/blockTools.js +295 -0
- package/dist/mcp/tools/documentTools.js +105 -0
- package/dist/mcp/tools/feishuBlockTools.js +1 -285
- package/dist/mcp/tools/feishuTools.js +1 -67
- package/dist/mcp/tools/folderTools.js +99 -0
- package/dist/mcp/tools/toolHelpers.js +155 -0
- package/dist/modules/FeatureModule.js +1 -0
- package/dist/modules/ModuleRegistry.js +63 -0
- package/dist/modules/calendar/index.js +11 -0
- package/dist/modules/calendar/services/FeishuCalendarService.js +6 -0
- package/dist/modules/calendar/tools/calendarTools.js +6 -0
- package/dist/modules/document/index.js +15 -0
- package/dist/modules/document/services/FeishuBlockService.js +410 -0
- package/dist/modules/document/services/FeishuDocumentService.js +110 -0
- package/dist/modules/document/services/FeishuFoldService.js +187 -0
- package/dist/modules/document/services/FeishuSearchService.js +232 -0
- package/dist/modules/document/services/FeishuWhiteboardService.js +80 -0
- package/dist/modules/document/services/blockFactory.js +521 -0
- package/dist/modules/document/toolApi/blockToolApi.js +160 -0
- package/dist/modules/document/toolApi/documentToolApi.js +65 -0
- package/dist/modules/document/toolApi/folderToolApi.js +73 -0
- package/dist/modules/document/toolApi/index.js +3 -0
- package/dist/modules/document/tools/blockTools.js +138 -0
- package/dist/modules/document/tools/documentTools.js +64 -0
- package/dist/modules/document/tools/folderTools.js +46 -0
- package/dist/modules/document/tools/toolHelpers.js +155 -0
- package/dist/modules/index.js +5 -0
- package/dist/modules/member/index.js +11 -0
- package/dist/modules/member/services/FeishuMemberService.js +41 -0
- package/dist/modules/member/toolApi/index.js +1 -0
- package/dist/modules/member/toolApi/memberToolApi.js +54 -0
- package/dist/modules/member/tools/memberTools.js +26 -0
- package/dist/modules/task/index.js +11 -0
- package/dist/modules/task/services/FeishuTaskService.js +271 -0
- package/dist/modules/task/toolApi/__tests__/taskToolApi.test.js +98 -0
- package/dist/modules/task/toolApi/index.js +1 -0
- package/dist/modules/task/toolApi/taskToolApi.js +128 -0
- package/dist/modules/task/tools/taskTools.js +93 -0
- package/dist/server.js +43 -24
- package/dist/services/baseService.js +11 -2
- package/dist/services/blockFactory.js +167 -0
- package/dist/services/callbackService.js +1 -1
- package/dist/services/constants/feishuScopes.js +94 -0
- package/dist/services/feishu/FeishuBaseApiService.js +47 -0
- package/dist/services/feishu/FeishuBlockService.js +410 -0
- package/dist/services/feishu/FeishuDocumentBlockService.js +403 -0
- package/dist/services/feishu/FeishuDocumentService.js +110 -0
- package/dist/services/feishu/FeishuDriveService.js +62 -0
- package/dist/services/feishu/FeishuFoldService.js +187 -0
- package/dist/services/feishu/FeishuImageService.js +219 -0
- package/dist/services/feishu/FeishuScopeValidator.js +177 -0
- package/dist/services/feishu/FeishuSearchService.js +232 -0
- package/dist/services/feishu/FeishuWhiteboardService.js +80 -0
- package/dist/services/feishu/FeishuWikiService.js +134 -0
- package/dist/services/feishuApiService.js +246 -1760
- package/dist/services/feishuAuthService.js +43 -0
- package/dist/types/documentSchema.js +232 -0
- package/dist/types/feishuSchema.js +54 -77
- package/dist/types/memberSchema.js +35 -0
- package/dist/types/taskSchema.js +166 -0
- package/dist/utils/auth/tokenCacheManager.js +16 -3
- package/dist/utils/auth/tokenRefreshManager.js +2 -1
- package/dist/utils/config.js +47 -0
- package/dist/utils/document.js +116 -116
- package/dist/utils/error.js +0 -11
- package/dist/utils/paramUtils.js +0 -31
- package/package.json +77 -76
package/dist/cli/auth.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { createServer as createNetServer } from 'net';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { Config } from '../utils/config.js';
|
|
5
|
+
import { AuthUtils, TokenCacheManager } from '../utils/auth/index.js';
|
|
6
|
+
// callbackService 需延迟导入,避免其模块级 Config.getInstance() 在 CLI 启动时提前触发 yargs
|
|
7
|
+
const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 分钟
|
|
8
|
+
const POLL_INTERVAL_MS = 1000;
|
|
9
|
+
/**
|
|
10
|
+
* 在系统默认浏览器中打开 URL(跨平台)
|
|
11
|
+
*/
|
|
12
|
+
function openBrowser(url) {
|
|
13
|
+
let cmd;
|
|
14
|
+
if (process.platform === 'win32') {
|
|
15
|
+
cmd = `cmd /c start "" "${url}"`;
|
|
16
|
+
}
|
|
17
|
+
else if (process.platform === 'darwin') {
|
|
18
|
+
cmd = `open "${url}"`;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
cmd = `xdg-open "${url}"`;
|
|
22
|
+
}
|
|
23
|
+
exec(cmd, (err) => {
|
|
24
|
+
if (err) {
|
|
25
|
+
process.stderr.write(`[feishu-mcp-tool] 无法自动打开浏览器,请手动访问上方授权链接\n`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 检查端口是否可用
|
|
31
|
+
*/
|
|
32
|
+
function isPortAvailable(port) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const server = createNetServer();
|
|
35
|
+
server.once('error', () => resolve(false));
|
|
36
|
+
server.once('listening', () => {
|
|
37
|
+
server.close();
|
|
38
|
+
resolve(true);
|
|
39
|
+
});
|
|
40
|
+
server.listen(port, '127.0.0.1');
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 从起始端口开始寻找第一个可用端口(最多尝试 10 个)
|
|
45
|
+
*/
|
|
46
|
+
async function findAvailablePort(startPort) {
|
|
47
|
+
for (let port = startPort; port < startPort + 10; port++) {
|
|
48
|
+
if (await isPortAvailable(port))
|
|
49
|
+
return port;
|
|
50
|
+
}
|
|
51
|
+
return startPort; // fallback,让后续绑定时自然报错
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 启动临时 callback express 服务器(延迟导入 callbackService 避免提前触发 Config)
|
|
55
|
+
*/
|
|
56
|
+
async function startCallbackServer(port) {
|
|
57
|
+
const { callback } = await import('../services/callbackService.js');
|
|
58
|
+
const app = express();
|
|
59
|
+
app.get('/callback', callback);
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const server = app.listen(port, '127.0.0.1', () => resolve(server));
|
|
62
|
+
server.once('error', reject);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 轮询 TokenCacheManager,等待指定 clientKey 的有效 token 写入
|
|
67
|
+
*/
|
|
68
|
+
async function waitForToken(clientKey, timeoutMs) {
|
|
69
|
+
const deadline = Date.now() + timeoutMs;
|
|
70
|
+
const cache = TokenCacheManager.getInstance();
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
const status = cache.checkUserTokenStatus(clientKey);
|
|
73
|
+
if (status.isValid)
|
|
74
|
+
return true;
|
|
75
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 处理 AuthRequiredError:启动 callback 服务器,打开浏览器,等待 token
|
|
81
|
+
* 成功后 token 已写入 TokenCacheManager,调用方可直接重试 ToolApi
|
|
82
|
+
* @throws Error 超时或端口绑定失败
|
|
83
|
+
*/
|
|
84
|
+
export async function handleAuthRequired(userKey) {
|
|
85
|
+
const config = Config.getInstance();
|
|
86
|
+
const { appId, appSecret } = config.feishu;
|
|
87
|
+
// 1. 寻找可用端口(从配置端口开始)
|
|
88
|
+
const port = await findAvailablePort(config.server.port);
|
|
89
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
90
|
+
// 2. 计算 clientKey 和 state
|
|
91
|
+
const clientKey = AuthUtils.generateClientKey(userKey);
|
|
92
|
+
const state = AuthUtils.encodeState(appId, appSecret, clientKey, redirectUri);
|
|
93
|
+
// 3. 构造飞书 OAuth 授权 URL
|
|
94
|
+
const authUrl = `https://open.feishu.cn/open-apis/authen/v1/index` +
|
|
95
|
+
`?app_id=${encodeURIComponent(appId)}` +
|
|
96
|
+
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
|
97
|
+
`&state=${encodeURIComponent(state)}`;
|
|
98
|
+
// 4. 启动临时 callback 服务器
|
|
99
|
+
let server;
|
|
100
|
+
try {
|
|
101
|
+
server = await startCallbackServer(port);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
throw new Error(`无法启动授权回调服务器(端口 ${port}):${err}`);
|
|
105
|
+
}
|
|
106
|
+
// 5. 打开浏览器并向 stderr 输出提示(不污染 stdout)
|
|
107
|
+
openBrowser(authUrl);
|
|
108
|
+
process.stderr.write(`\n[feishu-mcp-tool] 需要飞书授权,已在浏览器打开授权页(5 分钟内有效)\n` +
|
|
109
|
+
`[feishu-mcp-tool] 授权链接:${authUrl}\n\n`);
|
|
110
|
+
// 6. 等待 token 写入
|
|
111
|
+
const ok = await waitForToken(clientKey, AUTH_TIMEOUT_MS);
|
|
112
|
+
// 7. 无论成功与否,关闭临时服务器
|
|
113
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
114
|
+
if (!ok) {
|
|
115
|
+
throw new Error('飞书授权超时(5 分钟),请重新执行命令');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { createServer as createNetServer } from 'net';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { Config } from '../../utils/config.js';
|
|
5
|
+
import { AuthUtils, TokenCacheManager } from '../../utils/auth/index.js';
|
|
6
|
+
// callbackService 需延迟导入,避免其模块级 Config.getInstance() 在 CLI 启动时提前触发 yargs
|
|
7
|
+
const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 分钟
|
|
8
|
+
const POLL_INTERVAL_MS = 1000;
|
|
9
|
+
/**
|
|
10
|
+
* 在系统默认浏览器中打开 URL(跨平台)
|
|
11
|
+
*/
|
|
12
|
+
export function openBrowser(url) {
|
|
13
|
+
let cmd;
|
|
14
|
+
if (process.platform === 'win32') {
|
|
15
|
+
cmd = `cmd /c start "" "${url}"`;
|
|
16
|
+
}
|
|
17
|
+
else if (process.platform === 'darwin') {
|
|
18
|
+
cmd = `open "${url}"`;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
cmd = `xdg-open "${url}"`;
|
|
22
|
+
}
|
|
23
|
+
exec(cmd, (err) => {
|
|
24
|
+
if (err) {
|
|
25
|
+
process.stderr.write(`[feishu-mcp-tool] 无法自动打开浏览器,请手动访问上方授权链接\n`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 检查端口是否可用
|
|
31
|
+
*/
|
|
32
|
+
function isPortAvailable(port) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const server = createNetServer();
|
|
35
|
+
server.once('error', () => resolve(false));
|
|
36
|
+
server.once('listening', () => {
|
|
37
|
+
server.close();
|
|
38
|
+
resolve(true);
|
|
39
|
+
});
|
|
40
|
+
server.listen(port, '127.0.0.1');
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 从起始端口开始寻找第一个可用端口(最多尝试 10 个)
|
|
45
|
+
*/
|
|
46
|
+
async function findAvailablePort(startPort) {
|
|
47
|
+
for (let port = startPort; port < startPort + 10; port++) {
|
|
48
|
+
if (await isPortAvailable(port))
|
|
49
|
+
return port;
|
|
50
|
+
}
|
|
51
|
+
return startPort; // fallback,让后续绑定时自然报错
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 启动临时 callback express 服务器(延迟导入 callbackService 避免提前触发 Config)
|
|
55
|
+
*/
|
|
56
|
+
async function startCallbackServer(port) {
|
|
57
|
+
const { callback } = await import('../../services/callbackService.js');
|
|
58
|
+
const app = express();
|
|
59
|
+
app.get('/callback', callback);
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const server = app.listen(port, '127.0.0.1', () => resolve(server));
|
|
62
|
+
server.once('error', reject);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 轮询 TokenCacheManager,等待指定 clientKey 的有效 token 写入
|
|
67
|
+
*/
|
|
68
|
+
async function waitForToken(clientKey, timeoutMs) {
|
|
69
|
+
const deadline = Date.now() + timeoutMs;
|
|
70
|
+
const cache = TokenCacheManager.getInstance();
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
const status = cache.checkUserTokenStatus(clientKey);
|
|
73
|
+
if (status.isValid)
|
|
74
|
+
return true;
|
|
75
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 处理 AuthRequiredError:启动 callback 服务器,打开浏览器,等待 token
|
|
81
|
+
* 成功后 token 已写入 TokenCacheManager,调用方可直接重试 ToolApi
|
|
82
|
+
* @throws Error 超时或端口绑定失败
|
|
83
|
+
*/
|
|
84
|
+
export async function handleAuthRequired(userKey) {
|
|
85
|
+
const config = Config.getInstance();
|
|
86
|
+
const { appId, appSecret } = config.feishu;
|
|
87
|
+
// 1. 寻找可用端口(从配置端口开始)
|
|
88
|
+
const port = await findAvailablePort(config.server.port);
|
|
89
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
90
|
+
// 2. 计算 clientKey 和 state
|
|
91
|
+
const clientKey = AuthUtils.generateClientKey(userKey);
|
|
92
|
+
const state = AuthUtils.encodeState(appId, appSecret, clientKey, redirectUri);
|
|
93
|
+
// 3. 构造飞书 OAuth 授权 URL
|
|
94
|
+
const authUrl = `https://open.feishu.cn/open-apis/authen/v1/index` +
|
|
95
|
+
`?app_id=${encodeURIComponent(appId)}` +
|
|
96
|
+
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
|
97
|
+
`&state=${encodeURIComponent(state)}`;
|
|
98
|
+
// 4. 启动临时 callback 服务器
|
|
99
|
+
let server;
|
|
100
|
+
try {
|
|
101
|
+
server = await startCallbackServer(port);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
throw new Error(`无法启动授权回调服务器(端口 ${port}):${err}`);
|
|
105
|
+
}
|
|
106
|
+
// 5. 打开浏览器并向 stderr 输出提示(不污染 stdout)
|
|
107
|
+
openBrowser(authUrl);
|
|
108
|
+
process.stderr.write(`\n[feishu-mcp-tool] 需要飞书授权,已在浏览器打开授权页(5 分钟内有效)\n` +
|
|
109
|
+
`[feishu-mcp-tool] 授权链接:${authUrl}\n\n`);
|
|
110
|
+
// 6. 等待 token 写入
|
|
111
|
+
const ok = await waitForToken(clientKey, AUTH_TIMEOUT_MS);
|
|
112
|
+
// 7. 延迟关闭临时服务器,确保 /callback 的 HTTP 响应已发送完毕
|
|
113
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
114
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
115
|
+
if (!ok) {
|
|
116
|
+
throw new Error('飞书授权超时(5 分钟),请重新执行命令');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** 展示当前 token 授权状态 */
|
|
120
|
+
export async function handleAuthStatus() {
|
|
121
|
+
const config = Config.getInstance();
|
|
122
|
+
const { authType, userKey } = config.feishu;
|
|
123
|
+
if (authType !== 'user') {
|
|
124
|
+
process.stdout.write(JSON.stringify({ authType, status: 'tenant 模式无需用户授权' }) + '\n');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const clientKey = AuthUtils.generateClientKey(userKey);
|
|
128
|
+
const status = TokenCacheManager.getInstance().checkUserTokenStatus(clientKey);
|
|
129
|
+
process.stdout.write(JSON.stringify({ authType, userKey, ...status }) + '\n');
|
|
130
|
+
}
|
|
131
|
+
/** 清除当前用户 token 缓存 */
|
|
132
|
+
export async function handleAuthLogout() {
|
|
133
|
+
const config = Config.getInstance();
|
|
134
|
+
const { userKey } = config.feishu;
|
|
135
|
+
const clientKey = AuthUtils.generateClientKey(userKey);
|
|
136
|
+
const ok = TokenCacheManager.getInstance().removeUserToken(clientKey);
|
|
137
|
+
process.stdout.write(JSON.stringify({
|
|
138
|
+
ok,
|
|
139
|
+
message: ok ? '已退出登录,token 已清除' : '未找到缓存的 token',
|
|
140
|
+
}) + '\n');
|
|
141
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { resolve } from 'path';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
4
|
+
export const GLOBAL_CONFIG_DIR = resolve(homedir(), '.cache', 'feishu-mcp');
|
|
5
|
+
export const GLOBAL_CONFIG_FILE = resolve(GLOBAL_CONFIG_DIR, '.env');
|
|
6
|
+
const CONFIG_KEYS = {
|
|
7
|
+
FEISHU_APP_ID: '飞书应用 App ID',
|
|
8
|
+
FEISHU_APP_SECRET: '飞书应用 App Secret',
|
|
9
|
+
FEISHU_AUTH_TYPE: '认证类型:tenant(应用身份)或 user(用户身份,支持 task/member)',
|
|
10
|
+
FEISHU_ENABLED_MODULES: '启用的功能模块,逗号分隔,可选值: document,task,member,calendar,all',
|
|
11
|
+
FEISHU_BASE_URL: '飞书 API 基础地址,默认 https://open.feishu.cn/open-apis',
|
|
12
|
+
FEISHU_SCOPE_VALIDATION: '是否启用权限校验:true 或 false,默认 true',
|
|
13
|
+
PORT: '服务监听端口,默认 3333',
|
|
14
|
+
};
|
|
15
|
+
/** 读取并解析 .env 文件为 key-value 对象 */
|
|
16
|
+
export function readEnvFile(filePath) {
|
|
17
|
+
if (!existsSync(filePath))
|
|
18
|
+
return {};
|
|
19
|
+
const result = {};
|
|
20
|
+
for (const line of readFileSync(filePath, 'utf-8').split('\n')) {
|
|
21
|
+
const trimmed = line.trim();
|
|
22
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
23
|
+
continue;
|
|
24
|
+
const idx = trimmed.indexOf('=');
|
|
25
|
+
if (idx === -1)
|
|
26
|
+
continue;
|
|
27
|
+
result[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim();
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
/** 向 .env 文件写入或更新一个 key */
|
|
32
|
+
export function writeEnvKey(filePath, key, value) {
|
|
33
|
+
mkdirSync(resolve(filePath, '..'), { recursive: true });
|
|
34
|
+
let content = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : '';
|
|
35
|
+
const regex = new RegExp(`^${key}=.*$`, 'm');
|
|
36
|
+
if (regex.test(content)) {
|
|
37
|
+
content = content.replace(regex, `${key}=${value}`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
content += (content && !content.endsWith('\n') ? '\n' : '') + `${key}=${value}\n`;
|
|
41
|
+
}
|
|
42
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
43
|
+
}
|
|
44
|
+
/** 展示当前生效配置 */
|
|
45
|
+
export function handleConfigShow(envPath) {
|
|
46
|
+
const fileConfig = readEnvFile(GLOBAL_CONFIG_FILE);
|
|
47
|
+
const output = {
|
|
48
|
+
configFile: existsSync(GLOBAL_CONFIG_FILE) ? GLOBAL_CONFIG_FILE : null,
|
|
49
|
+
loadedFrom: envPath ?? null,
|
|
50
|
+
config: {
|
|
51
|
+
FEISHU_APP_ID: process.env.FEISHU_APP_ID ?? '(未设置)',
|
|
52
|
+
FEISHU_APP_SECRET: process.env.FEISHU_APP_SECRET
|
|
53
|
+
? `${process.env.FEISHU_APP_SECRET.slice(0, 3)}****` : '(未设置)',
|
|
54
|
+
FEISHU_AUTH_TYPE: process.env.FEISHU_AUTH_TYPE ?? 'tenant (默认)',
|
|
55
|
+
FEISHU_ENABLED_MODULES: process.env.FEISHU_ENABLED_MODULES ?? 'document (默认)',
|
|
56
|
+
PORT: process.env.PORT ?? '3333 (默认)',
|
|
57
|
+
},
|
|
58
|
+
globalConfigFile: Object.keys(fileConfig).length ? fileConfig : '(文件不存在)',
|
|
59
|
+
};
|
|
60
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
61
|
+
}
|
|
62
|
+
/** 向配置文件写入 key=value,写入目标与当前加载来源保持一致 */
|
|
63
|
+
export function handleConfigSet(key, value, envPath) {
|
|
64
|
+
if (!key || !value) {
|
|
65
|
+
process.stdout.write(JSON.stringify({
|
|
66
|
+
usage: 'feishu-mcp-tool config set <KEY> <VALUE>',
|
|
67
|
+
availableKeys: CONFIG_KEYS,
|
|
68
|
+
}, null, 2) + '\n');
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
if (!(key in CONFIG_KEYS)) {
|
|
72
|
+
process.stdout.write(JSON.stringify({
|
|
73
|
+
error: `未知配置项: ${key}`,
|
|
74
|
+
availableKeys: CONFIG_KEYS,
|
|
75
|
+
}) + '\n');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
// 写入目标与读取来源一致:已有加载文件则写入该文件,否则写入全局配置文件
|
|
79
|
+
const targetFile = envPath ?? GLOBAL_CONFIG_FILE;
|
|
80
|
+
writeEnvKey(targetFile, key, value);
|
|
81
|
+
process.stdout.write(JSON.stringify({
|
|
82
|
+
ok: true,
|
|
83
|
+
file: targetFile,
|
|
84
|
+
set: { [key]: key.includes('SECRET') ? `${value.slice(0, 3)}****` : value },
|
|
85
|
+
}) + '\n');
|
|
86
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { openBrowser } from './auth.js';
|
|
2
|
+
const GUIDE_URL = 'https://github.com/cso1z/Feishu-MCP/blob/cli/FEISHU_CONFIG.md';
|
|
3
|
+
// 各模块的权限说明(摘要,完整列表见 FEISHU_CONFIG.md)
|
|
4
|
+
const MODULE_SCOPE_DESC = {
|
|
5
|
+
document: { authTypes: 'tenant + user', desc: '文档读写、云盘、白板、知识库相关权限' },
|
|
6
|
+
task: { authTypes: 'user only', desc: 'task:task:write' },
|
|
7
|
+
member: { authTypes: 'user only', desc: 'contact:user.base:readonly 等联系人权限' },
|
|
8
|
+
};
|
|
9
|
+
/** 飞书 MCP 配置指南 */
|
|
10
|
+
export function handleGuide() {
|
|
11
|
+
const authType = (process.env.FEISHU_AUTH_TYPE ?? 'tenant');
|
|
12
|
+
const rawModules = process.env.FEISHU_ENABLED_MODULES ?? 'document';
|
|
13
|
+
const enabledModules = rawModules.split(',').map(s => s.trim()).filter(Boolean);
|
|
14
|
+
const allModules = enabledModules.includes('all') ? Object.keys(MODULE_SCOPE_DESC) : enabledModules;
|
|
15
|
+
const port = process.env.PORT ?? '3333';
|
|
16
|
+
const guide = {
|
|
17
|
+
title: '飞书 MCP 配置指南',
|
|
18
|
+
detailedGuide: GUIDE_URL,
|
|
19
|
+
tip: `完整配置说明(含截图)请查阅:${GUIDE_URL},已自动在浏览器打开,也可将此链接提供给用户手动访问`,
|
|
20
|
+
currentConfig: { authType, enabledModules },
|
|
21
|
+
steps: [
|
|
22
|
+
{
|
|
23
|
+
step: 1,
|
|
24
|
+
title: '创建飞书应用,获取 App ID 和 App Secret',
|
|
25
|
+
actions: [
|
|
26
|
+
'访问飞书开放平台:https://open.feishu.cn/app',
|
|
27
|
+
'点击「创建企业自建应用」',
|
|
28
|
+
'进入应用详情 → 凭证与基础信息 → 获取 App ID 和 App Secret',
|
|
29
|
+
],
|
|
30
|
+
commands: [
|
|
31
|
+
'feishu-mcp-tool config set FEISHU_APP_ID <your-app-id>',
|
|
32
|
+
'feishu-mcp-tool config set FEISHU_APP_SECRET <your-app-secret>',
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
step: 2,
|
|
37
|
+
title: '申请应用权限',
|
|
38
|
+
note: `按需添加,仅需为已启用模块申请权限,完整 scopes 列表见:${GUIDE_URL}`,
|
|
39
|
+
enabledModules: allModules
|
|
40
|
+
.filter(m => m in MODULE_SCOPE_DESC)
|
|
41
|
+
.map(m => ({ module: m, ...MODULE_SCOPE_DESC[m] })),
|
|
42
|
+
},
|
|
43
|
+
...(authType === 'user' ? [{
|
|
44
|
+
step: 3,
|
|
45
|
+
title: '配置 OAuth 回调地址',
|
|
46
|
+
actions: [
|
|
47
|
+
`飞书开放平台 → 安全设置 → 重定向 URL → 添加:http://localhost:${port}/callback`,
|
|
48
|
+
],
|
|
49
|
+
}] : []),
|
|
50
|
+
{
|
|
51
|
+
step: authType === 'user' ? 4 : 3,
|
|
52
|
+
title: '发布应用版本',
|
|
53
|
+
actions: ['可用范围选择「全部员工」', '提交审批,等待管理员通过'],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
step: authType === 'user' ? 5 : 4,
|
|
57
|
+
title: '验证配置',
|
|
58
|
+
commands: [
|
|
59
|
+
'feishu-mcp-tool config # 查看当前配置',
|
|
60
|
+
'feishu-mcp-tool auth # 查看 token 状态',
|
|
61
|
+
"feishu-mcp-tool get_feishu_root_folder_info '{}' # 测试接口连通性",
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
process.stdout.write(JSON.stringify(guide, null, 2) + '\n');
|
|
67
|
+
openBrowser(GUIDE_URL);
|
|
68
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { FeishuApiService } from '../services/feishuApiService.js';
|
|
2
|
+
import { UserContextManager, TokenCacheManager, AuthUtils } from '../utils/auth/index.js';
|
|
3
|
+
import { Config } from '../utils/config.js';
|
|
4
|
+
import { handleAuthRequired } from './commands/auth.js';
|
|
5
|
+
// Document toolApis
|
|
6
|
+
import { createDocument, getDocumentInfo, getDocumentBlocks, searchDocuments, batchUpdateBlockText, batchCreateBlocks, deleteDocumentBlocks, getImageResource, uploadAndBindImageToBlock, createTable, getWhiteboardContent, fillWhiteboardWithPlantuml, getRootFolderInfo, getFolderFiles, createFolder, } from '../modules/document/toolApi/index.js';
|
|
7
|
+
// Task toolApis
|
|
8
|
+
import { createTasks, listTasks, updateTask, deleteTasks } from '../modules/task/toolApi/index.js';
|
|
9
|
+
// Member toolApis
|
|
10
|
+
import { getUsers } from '../modules/member/toolApi/index.js';
|
|
11
|
+
/**
|
|
12
|
+
* 按模块组织的工具注册表,与 src/modules 目录划分保持一致
|
|
13
|
+
*/
|
|
14
|
+
const MODULE_REGISTRY = {
|
|
15
|
+
document: {
|
|
16
|
+
authType: 'tenant',
|
|
17
|
+
tools: {
|
|
18
|
+
create_feishu_document: (p, s) => createDocument(p, s),
|
|
19
|
+
get_feishu_document_info: (p, s) => getDocumentInfo(p, s),
|
|
20
|
+
get_feishu_document_blocks: (p, s) => getDocumentBlocks(p.documentId, s),
|
|
21
|
+
search_feishu_documents: (p, s) => searchDocuments(p, s),
|
|
22
|
+
batch_update_feishu_block_text: (p, s) => batchUpdateBlockText(p, s),
|
|
23
|
+
batch_create_feishu_blocks: (p, s) => batchCreateBlocks(p, s),
|
|
24
|
+
delete_feishu_document_blocks: (p, s) => deleteDocumentBlocks(p, s),
|
|
25
|
+
get_feishu_image_resource: (p, s) => getImageResource(p.mediaId, p.extra ?? '', s),
|
|
26
|
+
upload_and_bind_image_to_block: (p, s) => uploadAndBindImageToBlock(p, s),
|
|
27
|
+
create_feishu_table: (p, s) => createTable(p, s),
|
|
28
|
+
get_feishu_whiteboard_content: (p, s) => getWhiteboardContent(p.whiteboardId, s),
|
|
29
|
+
fill_whiteboard_with_plantuml: (p, s) => fillWhiteboardWithPlantuml(p, s),
|
|
30
|
+
get_feishu_root_folder_info: (_p, s) => getRootFolderInfo(s),
|
|
31
|
+
get_feishu_folder_files: (p, s) => getFolderFiles(p, s),
|
|
32
|
+
create_feishu_folder: (p, s) => createFolder(p, s),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
task: {
|
|
36
|
+
authType: 'user',
|
|
37
|
+
tools: {
|
|
38
|
+
list_feishu_tasks: (p, s) => listTasks(p, s),
|
|
39
|
+
create_feishu_task: (p, s) => createTasks(p.tasks, s),
|
|
40
|
+
update_feishu_task: (p, s) => updateTask(p, s),
|
|
41
|
+
delete_feishu_task: (p, s) => deleteTasks(p.taskGuids, s),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
member: {
|
|
45
|
+
authType: 'user',
|
|
46
|
+
tools: {
|
|
47
|
+
get_feishu_users: (p, s) => getUsers(p, s),
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
/** 将 MODULE_REGISTRY 展平为 toolName → handler 的查找表 */
|
|
52
|
+
function buildFlatMap() {
|
|
53
|
+
const flat = {};
|
|
54
|
+
for (const mod of Object.values(MODULE_REGISTRY)) {
|
|
55
|
+
Object.assign(flat, mod.tools);
|
|
56
|
+
}
|
|
57
|
+
return flat;
|
|
58
|
+
}
|
|
59
|
+
const FLAT_TOOL_MAP = buildFlatMap();
|
|
60
|
+
/**
|
|
61
|
+
* 返回支持的工具名称列表,按认证类型过滤
|
|
62
|
+
* tenant 模式下排除需要 user 认证的模块工具
|
|
63
|
+
*/
|
|
64
|
+
export function listTools(authType) {
|
|
65
|
+
const tools = [];
|
|
66
|
+
for (const mod of Object.values(MODULE_REGISTRY)) {
|
|
67
|
+
if (authType === 'tenant' && mod.authType === 'user')
|
|
68
|
+
continue;
|
|
69
|
+
tools.push(...Object.keys(mod.tools));
|
|
70
|
+
}
|
|
71
|
+
return tools;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 调度指定工具,注入用户上下文,处理 AuthRequiredError 并自动重试一次
|
|
75
|
+
*/
|
|
76
|
+
export async function dispatch(toolName, params) {
|
|
77
|
+
const handler = FLAT_TOOL_MAP[toolName];
|
|
78
|
+
if (!handler) {
|
|
79
|
+
throw new Error(`未知工具: "${toolName}"。可用工具:\n${listTools().join('\n')}`);
|
|
80
|
+
}
|
|
81
|
+
const config = Config.getInstance();
|
|
82
|
+
const userKey = config.feishu.userKey;
|
|
83
|
+
const userContextManager = UserContextManager.getInstance();
|
|
84
|
+
const apiService = FeishuApiService.getInstance();
|
|
85
|
+
const baseUrl = `http://localhost:${config.server.port}`;
|
|
86
|
+
const invoke = () => userContextManager.run({ userKey, baseUrl }, () => handler(params, apiService));
|
|
87
|
+
// 在 user 模式下,预先检查 token 是否有效,无效则触发授权流程
|
|
88
|
+
if (config.feishu.authType === 'user') {
|
|
89
|
+
const clientKey = AuthUtils.generateClientKey(userKey);
|
|
90
|
+
const status = TokenCacheManager.getInstance().checkUserTokenStatus(clientKey);
|
|
91
|
+
if (!status.isValid && !status.canRefresh) {
|
|
92
|
+
await handleAuthRequired(userKey);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return await invoke();
|
|
96
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { config as loadDotEnv } from 'dotenv';
|
|
6
|
+
import { Logger } from '../utils/logger.js';
|
|
7
|
+
import { dispatch, listTools } from './dispatcher.js';
|
|
8
|
+
import { handleConfigShow, handleConfigSet } from './commands/config.js';
|
|
9
|
+
import { handleAuthStatus, handleAuthLogout } from './commands/auth.js';
|
|
10
|
+
import { handleGuide } from './commands/guide.js';
|
|
11
|
+
// 按优先级查找 .env:CWD → ~/.cache/feishu-mcp(与 token 缓存同目录)
|
|
12
|
+
const envCandidates = [
|
|
13
|
+
resolve(process.cwd(), '.env'),
|
|
14
|
+
resolve(homedir(), '.cache', 'feishu-mcp', '.env'),
|
|
15
|
+
];
|
|
16
|
+
const envPath = envCandidates.find(p => existsSync(p));
|
|
17
|
+
if (envPath)
|
|
18
|
+
loadDotEnv({ path: envPath });
|
|
19
|
+
// 禁用所有日志输出,保持 stdout 纯 JSON
|
|
20
|
+
Logger.setEnabled(false);
|
|
21
|
+
// key 为空字符串表示默认子命令(即直接执行父命令时)
|
|
22
|
+
const COMMAND_REGISTRY = {
|
|
23
|
+
config: {
|
|
24
|
+
'': { description: '查看当前生效配置', handler: (_) => handleConfigShow(envPath) },
|
|
25
|
+
set: { description: '修改配置 <KEY> <VALUE>(不带参数可查看所有可用 KEY)', handler: (args) => handleConfigSet(args[0], args[1], envPath) },
|
|
26
|
+
},
|
|
27
|
+
auth: {
|
|
28
|
+
'': { description: '查看 token 授权状态', handler: (_) => handleAuthStatus() },
|
|
29
|
+
logout: { description: '清除已缓存的 token', handler: (_) => handleAuthLogout() },
|
|
30
|
+
},
|
|
31
|
+
guide: {
|
|
32
|
+
'': { description: '飞书应用配置指南(步骤说明)', handler: (_) => handleGuide() },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
async function main() {
|
|
36
|
+
const [, , cmd, sub, ...rest] = process.argv;
|
|
37
|
+
// 子命令路由:在注册表中查找并执行
|
|
38
|
+
if (cmd && cmd in COMMAND_REGISTRY) {
|
|
39
|
+
const subMap = COMMAND_REGISTRY[cmd];
|
|
40
|
+
// sub 命中具名子命令则执行,否则 fallback 到默认('')
|
|
41
|
+
const subKey = sub && sub in subMap ? sub : '';
|
|
42
|
+
const def = subMap[subKey];
|
|
43
|
+
if (def) {
|
|
44
|
+
const args = subKey ? rest : (sub ? [sub, ...rest] : []);
|
|
45
|
+
await def.handler(args);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const toolName = cmd;
|
|
50
|
+
const rawParams = sub ?? '{}';
|
|
51
|
+
// 无参数时打印帮助(按认证类型过滤工具列表,自动从注册表生成子命令说明)
|
|
52
|
+
if (!toolName || toolName === '--help' || toolName === '-h') {
|
|
53
|
+
const authType = (process.env.FEISHU_AUTH_TYPE ?? 'tenant');
|
|
54
|
+
const subcommands = {};
|
|
55
|
+
for (const [c, subMap] of Object.entries(COMMAND_REGISTRY)) {
|
|
56
|
+
for (const [s, def] of Object.entries(subMap)) {
|
|
57
|
+
subcommands[s ? `${c} ${s}` : c] = def.description;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
process.stdout.write(JSON.stringify({
|
|
61
|
+
usage: "feishu-mcp-tool <tool-name> '<json-params>'",
|
|
62
|
+
subcommands,
|
|
63
|
+
authType,
|
|
64
|
+
toolsNote: authType === 'tenant'
|
|
65
|
+
? 'task/member 工具需 FEISHU_AUTH_TYPE=user 才可用'
|
|
66
|
+
: '所有工具均可用',
|
|
67
|
+
tools: listTools(authType),
|
|
68
|
+
}, null, 2) + '\n');
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
// 解析参数
|
|
72
|
+
let params;
|
|
73
|
+
try {
|
|
74
|
+
params = JSON.parse(rawParams);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
process.stdout.write(JSON.stringify({ error: `参数解析失败,请提供合法的 JSON 字符串: ${rawParams}` }) + '\n');
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
// 调度工具
|
|
81
|
+
try {
|
|
82
|
+
const result = await dispatch(toolName, params);
|
|
83
|
+
// 若工具已返回序列化字符串(如 get_feishu_document_blocks),直接输出避免双重编码
|
|
84
|
+
process.stdout.write((typeof result === 'string' ? result : JSON.stringify(result)) + '\n');
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
const message = err instanceof Error ? err.message
|
|
89
|
+
: typeof err === 'object' && err !== null ? JSON.stringify(err)
|
|
90
|
+
: String(err);
|
|
91
|
+
process.stdout.write(JSON.stringify({ error: message }) + '\n');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
main().catch(() => process.exit(1));
|