UNPKG

feishu-mcp

Version:

Model Context Protocol server for Feishu integration

118 lines (117 loc) 4.42 kB
import { exec } from 'child_process'; import { createServer as createNetServer } from 'net'; import express from 'express'; import { Config } from '../utils/config.js'; import { AuthUtils, TokenCacheManager } from '../utils/auth/index.js'; // callbackService 需延迟导入,避免其模块级 Config.getInstance() 在 CLI 启动时提前触发 yargs const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 分钟 const POLL_INTERVAL_MS = 1000; /** * 在系统默认浏览器中打开 URL(跨平台) */ function openBrowser(url) { let cmd; if (process.platform === 'win32') { cmd = `cmd /c start "" "${url}"`; } else if (process.platform === 'darwin') { cmd = `open "${url}"`; } else { cmd = `xdg-open "${url}"`; } exec(cmd, (err) => { if (err) { process.stderr.write(`[feishu-mcp-tool] 无法自动打开浏览器,请手动访问上方授权链接\n`); } }); } /** * 检查端口是否可用 */ function isPortAvailable(port) { return new Promise((resolve) => { const server = createNetServer(); server.once('error', () => resolve(false)); server.once('listening', () => { server.close(); resolve(true); }); server.listen(port, '127.0.0.1'); }); } /** * 从起始端口开始寻找第一个可用端口(最多尝试 10 个) */ async function findAvailablePort(startPort) { for (let port = startPort; port < startPort + 10; port++) { if (await isPortAvailable(port)) return port; } return startPort; // fallback,让后续绑定时自然报错 } /** * 启动临时 callback express 服务器(延迟导入 callbackService 避免提前触发 Config) */ async function startCallbackServer(port) { const { callback } = await import('../services/callbackService.js'); const app = express(); app.get('/callback', callback); return new Promise((resolve, reject) => { const server = app.listen(port, '127.0.0.1', () => resolve(server)); server.once('error', reject); }); } /** * 轮询 TokenCacheManager,等待指定 clientKey 的有效 token 写入 */ async function waitForToken(clientKey, timeoutMs) { const deadline = Date.now() + timeoutMs; const cache = TokenCacheManager.getInstance(); while (Date.now() < deadline) { const status = cache.checkUserTokenStatus(clientKey); if (status.isValid) return true; await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } return false; } /** * 处理 AuthRequiredError:启动 callback 服务器,打开浏览器,等待 token * 成功后 token 已写入 TokenCacheManager,调用方可直接重试 ToolApi * @throws Error 超时或端口绑定失败 */ export async function handleAuthRequired(userKey) { const config = Config.getInstance(); const { appId, appSecret } = config.feishu; // 1. 寻找可用端口(从配置端口开始) const port = await findAvailablePort(config.server.port); const redirectUri = `http://localhost:${port}/callback`; // 2. 计算 clientKey 和 state const clientKey = AuthUtils.generateClientKey(userKey); const state = AuthUtils.encodeState(appId, appSecret, clientKey, redirectUri); // 3. 构造飞书 OAuth 授权 URL const authUrl = `https://open.feishu.cn/open-apis/authen/v1/index` + `?app_id=${encodeURIComponent(appId)}` + `&redirect_uri=${encodeURIComponent(redirectUri)}` + `&state=${encodeURIComponent(state)}`; // 4. 启动临时 callback 服务器 let server; try { server = await startCallbackServer(port); } catch (err) { throw new Error(`无法启动授权回调服务器(端口 ${port}):${err}`); } // 5. 打开浏览器并向 stderr 输出提示(不污染 stdout) openBrowser(authUrl); process.stderr.write(`\n[feishu-mcp-tool] 需要飞书授权,已在浏览器打开授权页(5 分钟内有效)\n` + `[feishu-mcp-tool] 授权链接:${authUrl}\n\n`); // 6. 等待 token 写入 const ok = await waitForToken(clientKey, AUTH_TIMEOUT_MS); // 7. 无论成功与否,关闭临时服务器 await new Promise((resolve) => server.close(() => resolve())); if (!ok) { throw new Error('飞书授权超时(5 分钟),请重新执行命令'); } }