# Directory Structure ``` ├── .env.example ├── .eslintrc ├── .github │ └── workflows │ └── codeql.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── doc │ ├── feishu_doc.md │ └── MCP服务实现分享.md ├── FEISHU_CONFIG.md ├── image │ ├── add_edit_permission.png │ ├── add_file_permission_1.png │ ├── add_robot.png │ ├── appid.png │ ├── change_permission_range.png │ ├── complete_permissions.png │ ├── create_group_and_add_application.gif │ ├── demo_1.png │ ├── demo.png │ ├── entry_application_detail.png │ ├── group_qr.jpg │ ├── Import_permissions.png │ ├── redirect_uri.png │ ├── register_application.png │ ├── release.png │ └── share_folder_to_group.png ├── LICENSE ├── package.json ├── pnpm-lock.yaml ├── README.md ├── src │ ├── cli.ts │ ├── index.ts │ ├── manager │ │ └── sseConnectionManager.ts │ ├── mcp │ │ ├── feishuMcp.ts │ │ └── tools │ │ ├── feishuBlockTools.ts │ │ ├── feishuFolderTools.ts │ │ └── feishuTools.ts │ ├── server.ts │ ├── services │ │ ├── baseService.ts │ │ ├── blockFactory.ts │ │ ├── callbackService.ts │ │ ├── feishuApiService.ts │ │ └── feishuAuthService.ts │ ├── types │ │ └── feishuSchema.ts │ └── utils │ ├── cache.ts │ ├── config.ts │ ├── document.ts │ ├── error.ts │ ├── logger.ts │ └── paramUtils.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- ``` 1 | v20.17.0 ``` -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- ``` 1 | { 2 | "singleQuote": true, 3 | "printWidth": 80, 4 | "tabWidth": 2 5 | } 6 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Dependencies 2 | node_modules 3 | .pnpm-store 4 | 5 | # Build output 6 | dist 7 | 8 | # Environment variables 9 | .env 10 | .env.local 11 | .env.*.local 12 | 13 | # IDE 14 | .vscode/* 15 | !.vscode/extensions.json 16 | !.vscode/settings.json 17 | .idea 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | 24 | # Logs 25 | logs 26 | *.log 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | pnpm-debug.log* 31 | 32 | # Testing 33 | coverage 34 | 35 | # OS 36 | .DS_Store 37 | Thumbs.db ``` -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- ``` 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier" 7 | ], 8 | "plugins": ["@typescript-eslint"], 9 | "parserOptions": { 10 | "ecmaVersion": 2022, 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "@typescript-eslint/explicit-function-return-type": "warn", 15 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 16 | "@typescript-eslint/no-explicit-any": "warn" 17 | } 18 | } ``` -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { resolve } from "path"; 4 | import { config } from "dotenv"; 5 | import { startServer } from "./index.js"; 6 | 7 | // Load .env from the current working directory 8 | config({ path: resolve(process.cwd(), ".env") }); 9 | 10 | startServer().catch((error: unknown) => { 11 | if (error instanceof Error) { 12 | console.error("Failed to start server:", error.message); 13 | } else { 14 | console.error("Failed to start server with unknown error:", error); 15 | } 16 | process.exit(1); 17 | }); 18 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": false, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": false, 15 | "outDir": "dist", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 2 | import { FeishuMcpServer } from "./server.js"; 3 | import { Config } from "./utils/config.js"; 4 | import { fileURLToPath } from 'url'; 5 | import { resolve } from 'path'; 6 | 7 | export async function startServer(): Promise<void> { 8 | // Check if we're running in stdio mode (e.g., via CLI) 9 | const isStdioMode = process.env.NODE_ENV === "cli" || process.argv.includes("--stdio"); 10 | 11 | // 获取配置实例 12 | const config = Config.getInstance(); 13 | 14 | // 打印配置信息 15 | config.printConfig(isStdioMode); 16 | 17 | // 验证配置 18 | if (!config.validate()) { 19 | console.error("配置验证失败,无法启动服务器"); 20 | process.exit(1); 21 | } 22 | 23 | // 创建MCP服务器 24 | const server = new FeishuMcpServer(); 25 | 26 | console.log(`isStdioMode:${isStdioMode}`) 27 | 28 | if (isStdioMode) { 29 | const transport = new StdioServerTransport(); 30 | await server.connect(transport); 31 | } else { 32 | console.log(`Initializing Feishu MCP Server in HTTP mode on port ${config.server.port}...`); 33 | await server.startHttpServer(config.server.port); 34 | } 35 | } 36 | 37 | // 跨平台兼容的方式检查是否直接运行 38 | const currentFilePath = fileURLToPath(import.meta.url); 39 | const executedFilePath = resolve(process.argv[1]); 40 | 41 | console.log(`meta.url:${currentFilePath} argv:${executedFilePath}` ); 42 | 43 | if (currentFilePath === executedFilePath) { 44 | console.log(`startServer`); 45 | startServer().catch((error) => { 46 | console.error('Failed to start server:', error); 47 | process.exit(1); 48 | }); 49 | } else { 50 | console.log(`not startServer`); 51 | } 52 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "feishu-mcp", 3 | "version": "0.1.2", 4 | "description": "Model Context Protocol server for Feishu integration", 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "bin": { 8 | "feishu-mcp": "./dist/cli.js" 9 | }, 10 | "files": [ 11 | "dist", 12 | "README.md" 13 | ], 14 | "scripts": { 15 | "build": "tsc && tsc-alias", 16 | "type-check": "tsc --noEmit", 17 | "start": "node dist/index.js", 18 | "start:cli": "cross-env NODE_ENV=cli node dist/index.js", 19 | "start:http": "node dist/index.js", 20 | "dev": "cross-env NODE_ENV=development tsx watch src/index.ts", 21 | "dev:cli": "cross-env NODE_ENV=development tsx watch src/index.ts --stdio", 22 | "lint": "eslint . --ext .ts", 23 | "format": "prettier --write \"src/**/*.ts\"", 24 | "inspect": "pnpx @modelcontextprotocol/inspector", 25 | "prepare": "pnpm run build", 26 | "pub:release": "pnpm build && npm publish" 27 | }, 28 | "engines": { 29 | "node": "^20.17.0" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/cso1z/Feishu-MCP.git" 34 | }, 35 | "keywords": [ 36 | "feishu", 37 | "lark", 38 | "mcp", 39 | "typescript" 40 | ], 41 | "author": "cso1z", 42 | "license": "MIT", 43 | "dependencies": { 44 | "@modelcontextprotocol/sdk": "^1.17.5", 45 | "@types/yargs": "^17.0.33", 46 | "axios": "^1.7.9", 47 | "cross-env": "^7.0.3", 48 | "dotenv": "^16.4.7", 49 | "express": "^4.21.2", 50 | "form-data": "^4.0.3", 51 | "remeda": "^2.20.1", 52 | "yargs": "^17.7.2", 53 | "zod": "^3.24.2" 54 | }, 55 | "devDependencies": { 56 | "@types/express": "^5.0.0", 57 | "@types/jest": "^29.5.11", 58 | "@types/node": "^20.17.0", 59 | "@typescript-eslint/eslint-plugin": "^8.24.0", 60 | "@typescript-eslint/parser": "^8.24.0", 61 | "eslint": "^9.20.1", 62 | "eslint-config-prettier": "^10.0.1", 63 | "jest": "^29.7.0", 64 | "prettier": "^3.5.0", 65 | "ts-jest": "^29.2.5", 66 | "tsc-alias": "^1.8.10", 67 | "tsx": "^4.19.2", 68 | "typescript": "^5.7.3" 69 | }, 70 | "pnpm": { 71 | "overrides": { 72 | "feishu-mcp": "link:" 73 | } 74 | } 75 | } 76 | ``` -------------------------------------------------------------------------------- /src/manager/sseConnectionManager.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Request, Response } from 'express'; 2 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 3 | import { Logger } from '../utils/logger.js'; 4 | 5 | /** 6 | * SSE连接管理器 - 负责管理所有的SSE长连接和心跳机制 7 | */ 8 | export class SSEConnectionManager { 9 | private transports: { [sessionId: string]: SSEServerTransport } = {}; 10 | private connections: Map<string, { res: Response }> = new Map(); 11 | private keepAliveIntervalId: NodeJS.Timeout | null = null; 12 | private readonly KEEP_ALIVE_INTERVAL_MS = 1000 * 25; // 25秒心跳间隔 13 | 14 | constructor() { 15 | this.startGlobalKeepAlive(); 16 | } 17 | 18 | /** 19 | * 启动全局心跳管理 20 | */ 21 | private startGlobalKeepAlive(): void { 22 | if (this.keepAliveIntervalId) { 23 | clearInterval(this.keepAliveIntervalId); 24 | } 25 | 26 | this.keepAliveIntervalId = setInterval(() => { 27 | for (const [sessionId, connection] of this.connections.entries()) { 28 | if (!connection.res.writableEnded) { 29 | connection.res.write(': keepalive\n\n'); 30 | } else { 31 | // 移除已关闭的连接 32 | this.removeConnection(sessionId); 33 | } 34 | } 35 | }, this.KEEP_ALIVE_INTERVAL_MS); 36 | } 37 | 38 | /** 39 | * 添加新的SSE连接 40 | */ 41 | public addConnection( 42 | sessionId: string, 43 | transport: SSEServerTransport, 44 | req: Request, 45 | res: Response, 46 | ): void { 47 | this.transports[sessionId] = transport; 48 | this.connections.set(sessionId, { res }); 49 | console.info(`[SSE Connection] Client connected: ${sessionId}`); 50 | req.on('close', () => { 51 | this.removeConnection(sessionId); 52 | }); 53 | } 54 | 55 | /** 56 | * 移除SSE连接 57 | */ 58 | public removeConnection(sessionId: string): void { 59 | const transport = this.transports[sessionId]; 60 | if (transport) { 61 | try { 62 | transport.close(); 63 | Logger.info(`[SSE Connection] Transport closed for: ${sessionId}`); 64 | } catch (error) { 65 | Logger.error(`[SSE Connection] Error closing transport for: ${sessionId}`, error); 66 | } 67 | } 68 | delete this.transports[sessionId]; 69 | this.connections.delete(sessionId); 70 | console.info(`[SSE Connection] Client disconnected: ${sessionId}`); 71 | } 72 | 73 | /** 74 | * 获取指定sessionId的传输实例 75 | */ 76 | public getTransport(sessionId: string): SSEServerTransport | undefined { 77 | console.info(`[SSE Connection] Getting transport for sessionId: ${sessionId}`); 78 | return this.transports[sessionId]; 79 | } 80 | 81 | /** 82 | * 关闭连接管理器 83 | */ 84 | public shutdown() { 85 | if (this.keepAliveIntervalId) { 86 | clearInterval(this.keepAliveIntervalId); 87 | this.keepAliveIntervalId = null; 88 | } 89 | 90 | // 关闭所有连接 91 | Logger.info(`[SSE Connection] Shutting down all connections (${this.connections.size} active)`); 92 | for (const sessionId of this.connections.keys()) { 93 | this.removeConnection(sessionId); 94 | } 95 | Logger.info(`[SSE Connection] All connections closed`); 96 | } 97 | } ``` -------------------------------------------------------------------------------- /src/services/callbackService.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Request, Response } from 'express'; 2 | import { AuthService } from './feishuAuthService.js'; 3 | import { Config } from '../utils/config.js'; 4 | import { CacheManager } from '../utils/cache.js'; 5 | import { renderFeishuAuthResultHtml } from '../utils/document.js'; 6 | 7 | // 通用响应码 8 | const CODE = { 9 | SUCCESS: 0, 10 | PARAM_ERROR: 400, 11 | CUSTOM: 500, 12 | }; 13 | 14 | // 封装响应方法 15 | function sendSuccess(res: Response, data: any) { 16 | const html = renderFeishuAuthResultHtml(data); 17 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 18 | res.status(200).send(html); 19 | } 20 | function sendFail(res: Response, msg: string, code: number = CODE.CUSTOM) { 21 | const html = renderFeishuAuthResultHtml({ error: msg, code }); 22 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 23 | res.status(200).send(html); 24 | } 25 | 26 | const authService = new AuthService(); 27 | const config = Config.getInstance(); 28 | 29 | export async function callback(req: Request, res: Response) { 30 | const code = req.query.code as string; 31 | const state = req.query.state as string; 32 | console.log(`[callback] query:`, req.query); 33 | if (!code) { 34 | console.log('[callback] 缺少code参数'); 35 | return sendFail(res, '缺少code参数', CODE.PARAM_ERROR); 36 | } 37 | // 校验state(clientKey) 38 | const client_id = config.feishu.appId; 39 | const client_secret = config.feishu.appSecret; 40 | const expectedClientKey = await CacheManager.getClientKey(client_id, client_secret); 41 | if (state !== expectedClientKey) { 42 | console.log('[callback] state(clientKey)不匹配'); 43 | return sendFail(res, 'state(clientKey)不匹配', CODE.PARAM_ERROR); 44 | } 45 | 46 | const redirect_uri = `http://localhost:${config.server.port}/callback`; 47 | const session = (req as any).session; 48 | const code_verifier = session?.code_verifier || undefined; 49 | 50 | try { 51 | // 获取 user_access_token 52 | const tokenResp = await authService.getUserTokenByCode({ 53 | client_id, 54 | client_secret, 55 | code, 56 | redirect_uri, 57 | code_verifier 58 | }); 59 | const data = (tokenResp && typeof tokenResp === 'object') ? tokenResp : undefined; 60 | console.log('[callback] feishu response:', data); 61 | if (!data || data.code !== 0 || !data.access_token) { 62 | return sendFail(res, `获取 access_token 失败,飞书返回: ${JSON.stringify(tokenResp)}`, CODE.CUSTOM); 63 | } 64 | // 获取用户信息 65 | const access_token = data.access_token; 66 | let userInfo = null; 67 | if (access_token) { 68 | userInfo = await authService.getUserInfo(access_token); 69 | console.log('[callback] feishu userInfo:', userInfo); 70 | } 71 | return sendSuccess(res, { ...data, userInfo }); 72 | } catch (e) { 73 | console.error('[callback] 请求飞书token或用户信息失败:', e); 74 | return sendFail(res, `请求飞书token或用户信息失败: ${e}`, CODE.CUSTOM); 75 | } 76 | } 77 | 78 | export async function getTokenByParams({ client_id, client_secret, token_type }: { client_id: string, client_secret: string, token_type?: string }) { 79 | const authService = new AuthService(); 80 | if (client_id) authService.config.feishu.appId = client_id; 81 | if (client_secret) authService.config.feishu.appSecret = client_secret; 82 | if (token_type) authService.config.feishu.authType = token_type === 'user' ? 'user' : 'tenant'; 83 | return await authService.getToken(); 84 | } ``` -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- ```yaml 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Advanced" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '20 5 * * 4' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze (${{ matrix.language }}) 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners (GitHub.com only) 29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | permissions: 32 | # required for all workflows 33 | security-events: write 34 | 35 | # required to fetch internal or private CodeQL packs 36 | packages: read 37 | 38 | # only required for workflows in private repositories 39 | actions: read 40 | contents: read 41 | 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | include: 46 | - language: javascript-typescript 47 | build-mode: none 48 | # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 49 | # Use `c-cpp` to analyze code written in C, C++ or both 50 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 51 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 52 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 53 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 54 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 55 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 56 | steps: 57 | - name: Checkout repository 58 | uses: actions/checkout@v4 59 | 60 | # Add any setup steps before running the `github/codeql-action/init` action. 61 | # This includes steps like installing compilers or runtimes (`actions/setup-node` 62 | # or others). This is typically only required for manual builds. 63 | # - name: Setup runtime (example) 64 | # uses: actions/setup-example@v1 65 | 66 | # Initializes the CodeQL tools for scanning. 67 | - name: Initialize CodeQL 68 | uses: github/codeql-action/init@v3 69 | with: 70 | languages: ${{ matrix.language }} 71 | build-mode: ${{ matrix.build-mode }} 72 | # If you wish to specify custom queries, you can do so here or in a config file. 73 | # By default, queries listed here will override any specified in a config file. 74 | # Prefix the list here with "+" to use these queries and those in the config file. 75 | 76 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 77 | # queries: security-extended,security-and-quality 78 | 79 | # If the analyze step fails for one of the languages you are analyzing with 80 | # "We were unable to automatically build your code", modify the matrix above 81 | # to set the build mode to "manual" for that language. Then modify this step 82 | # to build your code. 83 | # ℹ️ Command-line programs to run using the OS shell. 84 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 85 | - if: matrix.build-mode == 'manual' 86 | shell: bash 87 | run: | 88 | echo 'If you are using a "manual" build mode for one or more of the' \ 89 | 'languages you are analyzing, replace this with the commands to build' \ 90 | 'your code, for example:' 91 | echo ' make bootstrap' 92 | echo ' make release' 93 | exit 1 94 | 95 | - name: Perform CodeQL Analysis 96 | uses: github/codeql-action/analyze@v3 97 | with: 98 | category: "/language:${{matrix.language}}" 99 | ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript 1 | import express, { Request, Response } from 'express'; 2 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 3 | import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; 4 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' 5 | import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' 6 | import { randomUUID } from 'node:crypto' 7 | import { Logger } from './utils/logger.js'; 8 | import { SSEConnectionManager } from './manager/sseConnectionManager.js'; 9 | import { FeishuMcp } from './mcp/feishuMcp.js'; 10 | import { callback, getTokenByParams } from './services/callbackService.js'; 11 | 12 | export class FeishuMcpServer { 13 | private connectionManager: SSEConnectionManager; 14 | 15 | constructor() { 16 | this.connectionManager = new SSEConnectionManager(); 17 | } 18 | 19 | async connect(transport: Transport): Promise<void> { 20 | const server = new FeishuMcp(); 21 | await server.connect(transport); 22 | 23 | Logger.info = (...args: any[]) => { 24 | server.server.sendLoggingMessage({ level: 'info', data: args }); 25 | }; 26 | Logger.error = (...args: any[]) => { 27 | server.server.sendLoggingMessage({ level: 'error', data: args }); 28 | }; 29 | 30 | Logger.info('Server connected and ready to process requests'); 31 | } 32 | 33 | async startHttpServer(port: number): Promise<void> { 34 | const app = express(); 35 | 36 | const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {} 37 | 38 | // Parse JSON requests for the Streamable HTTP endpoint only, will break SSE endpoint 39 | app.use("/mcp", express.json()); 40 | 41 | app.post('/mcp', async (req, res) => { 42 | try { 43 | Logger.log("Received StreamableHTTP request", { 44 | method: req.method, 45 | url: req.url, 46 | headers: req.headers, 47 | body: req.body, 48 | query: req.query, 49 | params: req.params 50 | }); 51 | // Check for existing session ID 52 | const sessionId = req.headers['mcp-session-id'] as string | undefined 53 | let transport: StreamableHTTPServerTransport 54 | 55 | if (sessionId && transports[sessionId]) { 56 | // Reuse existing transport 57 | Logger.log("Reusing existing StreamableHTTP transport for sessionId", sessionId); 58 | transport = transports[sessionId] 59 | } else if (!sessionId && isInitializeRequest(req.body)) { 60 | // New initialization request 61 | transport = new StreamableHTTPServerTransport({ 62 | sessionIdGenerator: () => randomUUID(), 63 | onsessioninitialized: (sessionId) => { 64 | // Store the transport by session ID 65 | Logger.log(`[StreamableHTTP connection] ${sessionId}`); 66 | transports[sessionId] = transport 67 | } 68 | }) 69 | 70 | // Clean up transport and server when closed 71 | transport.onclose = () => { 72 | if (transport.sessionId) { 73 | Logger.log(`[StreamableHTTP delete] ${transports[transport.sessionId]}`); 74 | delete transports[transport.sessionId] 75 | } 76 | } 77 | 78 | // Create and connect server instance 79 | const server = new FeishuMcp(); 80 | await server.connect(transport); 81 | } else { 82 | // Invalid request 83 | res.status(400).json({ 84 | jsonrpc: '2.0', 85 | error: { 86 | code: -32000, 87 | message: 'Bad Request: No valid session ID provided', 88 | }, 89 | id: null, 90 | }) 91 | return 92 | } 93 | 94 | // Handle the request 95 | await transport.handleRequest(req, res, req.body) 96 | } catch (error) { 97 | console.error('Error handling MCP request:', error) 98 | if (!res.headersSent) { 99 | res.status(500).json({ 100 | jsonrpc: '2.0', 101 | error: { 102 | code: -32603, 103 | message: 'Internal server error', 104 | }, 105 | id: null, 106 | }) 107 | } 108 | } 109 | }) 110 | 111 | // Handle GET requests for server-to-client notifications via Streamable HTTP 112 | app.get('/mcp', async (req, res) => { 113 | try { 114 | Logger.log("Received StreamableHTTP request get" ) 115 | const sessionId = req.headers['mcp-session-id'] as string | undefined 116 | if (!sessionId || !transports[sessionId]) { 117 | res.status(400).send('Invalid or missing session ID') 118 | return 119 | } 120 | 121 | const transport = transports[sessionId] 122 | await transport.handleRequest(req, res) 123 | } catch (error) { 124 | console.error('Error handling GET request:', error) 125 | if (!res.headersSent) { 126 | res.status(500).send('Internal server error') 127 | } 128 | } 129 | }) 130 | 131 | // Handle DELETE requests for session termination 132 | app.delete('/mcp', async (req, res) => { 133 | try { 134 | const sessionId = req.headers['mcp-session-id'] as string | undefined 135 | if (!sessionId || !transports[sessionId]) { 136 | res.status(400).send('Invalid or missing session ID') 137 | return 138 | } 139 | 140 | const transport = transports[sessionId] 141 | await transport.handleRequest(req, res) 142 | 143 | // Clean up resources after session termination 144 | if (transport.sessionId) { 145 | delete transports[transport.sessionId] 146 | } 147 | } catch (error) { 148 | console.error('Error handling DELETE request:', error) 149 | if (!res.headersSent) { 150 | res.status(500).send('Internal server error') 151 | } 152 | } 153 | }) 154 | 155 | app.get('/sse', async (req: Request, res: Response) => { 156 | const sseTransport = new SSEServerTransport('/messages', res); 157 | const sessionId = sseTransport.sessionId; 158 | Logger.log(`[SSE Connection] New SSE connection established for sessionId ${sessionId} params:${JSON.stringify(req.params)} headers:${JSON.stringify(req.headers)} `,); 159 | this.connectionManager.addConnection(sessionId, sseTransport, req, res); 160 | try { 161 | const tempServer = new FeishuMcp(); 162 | await tempServer.connect(sseTransport); 163 | Logger.info(`[SSE Connection] Successfully connected transport for: ${sessionId}`,); 164 | } catch (error) { 165 | Logger.error(`[SSE Connection] Error connecting server to transport for ${sessionId}:`, error); 166 | this.connectionManager.removeConnection(sessionId); 167 | if (!res.writableEnded) { 168 | res.status(500).end('Failed to connect MCP server to transport'); 169 | } 170 | return; 171 | } 172 | }); 173 | 174 | app.post('/messages', async (req: Request, res: Response) => { 175 | const sessionId = req.query.sessionId as string; 176 | Logger.info(`[SSE messages] Received message with sessionId: ${sessionId}, params: ${JSON.stringify(req.query)}, body: ${JSON.stringify(req.body)}`,); 177 | 178 | if (!sessionId) { 179 | res.status(400).send('Missing sessionId query parameter'); 180 | return; 181 | } 182 | 183 | const transport = this.connectionManager.getTransport(sessionId); 184 | Logger.log(`[SSE messages] Retrieved transport for sessionId ${sessionId}: ${transport ? transport.sessionId : 'Transport not found'}`,); 185 | 186 | if (!transport) { 187 | res 188 | .status(404) 189 | .send(`No active connection found for sessionId: ${sessionId}`); 190 | return; 191 | } 192 | await transport.handlePostMessage(req, res); 193 | }); 194 | 195 | app.get('/callback', callback); 196 | 197 | app.get('/getToken', async (req: Request, res: Response) => { 198 | const { client_id, client_secret, token_type } = req.query; 199 | if (!client_id || !client_secret) { 200 | res.status(400).json({ code: 400, msg: '缺少 client_id 或 client_secret' }); 201 | return; 202 | } 203 | try { 204 | const tokenResult = await getTokenByParams({ 205 | client_id: client_id as string, 206 | client_secret: client_secret as string, 207 | token_type: token_type as string 208 | }); 209 | res.json({ code: 0, msg: 'success', data: tokenResult }); 210 | } catch (e: any) { 211 | res.status(500).json({ code: 500, msg: e.message || '获取token失败' }); 212 | } 213 | }); 214 | 215 | app.listen(port, '0.0.0.0', () => { 216 | Logger.info(`HTTP server listening on port ${port}`); 217 | Logger.info(`SSE endpoint available at http://localhost:${port}/sse`); 218 | Logger.info(`Message endpoint available at http://localhost:${port}/messages`); 219 | Logger.info(`StreamableHTTP endpoint available at http://localhost:${port}/mcp`); 220 | }); 221 | } 222 | } 223 | ``` -------------------------------------------------------------------------------- /src/services/feishuAuthService.ts: -------------------------------------------------------------------------------- ```typescript 1 | import axios from 'axios'; 2 | import { Config } from '../utils/config.js'; 3 | import { CacheManager } from '../utils/cache.js'; 4 | import { Logger } from '../utils/logger.js'; 5 | 6 | export class AuthService { 7 | public config = Config.getInstance(); 8 | private cache = CacheManager.getInstance(); 9 | 10 | // 获取token主入口 11 | public async getToken(options?: { 12 | client_id?: string; 13 | client_secret?: string; 14 | authType?: 'tenant' | 'user'; 15 | }): Promise<any> { 16 | Logger.warn('[AuthService] getToken called', options); 17 | const config = this.config.feishu; 18 | const client_id = options?.client_id || config.appId; 19 | const client_secret = options?.client_secret || config.appSecret; 20 | const authType = options?.authType || config.authType; 21 | const clientKey = await CacheManager.getClientKey(client_id, client_secret); 22 | Logger.warn('[AuthService] getToken resolved clientKey', clientKey, 'authType', authType); 23 | if (authType === 'tenant') { 24 | return this.getTenantToken(client_id, client_secret, clientKey); 25 | } else { 26 | let tokenObj = this.cache.getUserToken(clientKey); 27 | const now = Date.now() / 1000; 28 | if (!tokenObj || tokenObj.refresh_token_expires_at < now) { 29 | Logger.warn('[AuthService] No user token in cache, need user auth', clientKey); 30 | // 返回授权链接 31 | const redirect_uri = encodeURIComponent(`http://localhost:${this.config.server.port}/callback`); 32 | 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'); 33 | const state = clientKey; 34 | const url = `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${client_id}&redirect_uri=${redirect_uri}&scope=${scope}&state=${state}`; 35 | return { needAuth: true, url }; 36 | } 37 | Logger.debug('[AuthService] User token found in cache', tokenObj); 38 | if (tokenObj.expires_at && tokenObj.expires_at < now) { 39 | Logger.warn('[AuthService] User token expired, try refresh', tokenObj); 40 | if (tokenObj.refresh_token) { 41 | tokenObj = await this.refreshUserToken(tokenObj.refresh_token, clientKey, client_id, client_secret); 42 | } else { 43 | Logger.warn('[AuthService] No refresh_token, clear cache and require re-auth', clientKey); 44 | this.cache.cacheUserToken(clientKey, null, 0); 45 | return { needAuth: true, url: '请重新授权' }; 46 | } 47 | } 48 | Logger.warn('[AuthService] Return user access_token', tokenObj.access_token); 49 | // 计算剩余有效期(秒) 50 | const expires_in = tokenObj.expires_at ? Math.max(tokenObj.expires_at - now, 0) : undefined; 51 | return { access_token: tokenObj.access_token, expires_in, ...tokenObj }; 52 | } 53 | } 54 | 55 | // 获取tenant_access_token 56 | private async getTenantToken(client_id: string, client_secret: string, clientKey: string): Promise<any> { 57 | Logger.warn('[AuthService] getTenantToken called', { client_id, clientKey }); 58 | // 尝试从缓存获取 59 | const cacheKey = clientKey; 60 | const cachedTokenObj = this.cache.getTenantToken(cacheKey) as unknown as { tenant_access_token: string; expire_at: number }; 61 | if (cachedTokenObj) { 62 | Logger.warn('[AuthService] Tenant token cache hit', cacheKey); 63 | const { tenant_access_token, expire_at } = cachedTokenObj; 64 | const now = Math.floor(Date.now() / 1000); 65 | const expires_in = expire_at ? Math.max(expire_at - now, 0) : undefined; 66 | return { access_token: tenant_access_token, expires_in }; 67 | } 68 | try { 69 | const requestData = { 70 | app_id: client_id, 71 | app_secret: client_secret, 72 | }; 73 | const url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal'; 74 | const headers = { 'Content-Type': 'application/json' }; 75 | Logger.debug('[AuthService] Requesting tenant_access_token', url, requestData); 76 | const response = await axios.post(url, requestData, { headers }); 77 | const data = response.data; 78 | Logger.debug('[AuthService] tenant_access_token response', data); 79 | if (!data || typeof data !== 'object') { 80 | Logger.error('[AuthService] tenant_access_token invalid response', data); 81 | throw new Error('获取飞书访问令牌失败:响应格式无效'); 82 | } 83 | if (data.code !== 0) { 84 | Logger.error('[AuthService] tenant_access_token error', data); 85 | throw new Error(`获取飞书访问令牌失败:${data.msg || '未知错误'} (错误码: ${data.code})`); 86 | } 87 | if (!data.tenant_access_token) { 88 | Logger.error('[AuthService] tenant_access_token missing in response', data); 89 | throw new Error('获取飞书访问令牌失败:响应中没有token'); 90 | } 91 | // 计算绝对过期时间戳 92 | const expire_at = Math.floor(Date.now() / 1000) + (data.expire || 0); 93 | const tokenObj = { 94 | tenant_access_token: data.tenant_access_token, 95 | expire_at 96 | }; 97 | this.cache.cacheTenantToken(cacheKey, tokenObj, data.expire); 98 | Logger.warn('[AuthService] tenant_access_token cached', cacheKey); 99 | // 返回token对象和expires_in 100 | return { access_token: data.tenant_access_token, expires_in: data.expire, expire_at }; 101 | } catch (error) { 102 | Logger.error('[AuthService] getTenantToken error', error); 103 | throw new Error('获取飞书访问令牌失败: ' + (error instanceof Error ? error.message : String(error))); 104 | } 105 | } 106 | 107 | // 刷新user_access_token 108 | private async refreshUserToken(refresh_token: string, clientKey: string, client_id: string, client_secret: string): Promise<any> { 109 | Logger.warn('[AuthService] refreshUserToken called', { clientKey }); 110 | const body = { 111 | grant_type: 'refresh_token', 112 | client_id, 113 | client_secret, 114 | refresh_token 115 | }; 116 | Logger.debug('[AuthService] refreshUserToken request', body); 117 | const response = await axios.post('https://open.feishu.cn/open-apis/authen/v2/oauth/token', body, { headers: { 'Content-Type': 'application/json' } }); 118 | const data = response.data; 119 | Logger.debug('[AuthService] refreshUserToken response', data); 120 | if (data && data.access_token && data.expires_in) { 121 | data.expires_in = Math.floor(Date.now() / 1000) + data.expires_in; 122 | this.cache.cacheUserToken(clientKey, data, data.expires_in); 123 | Logger.warn('[AuthService] Refreshed user_access_token cached', clientKey); 124 | } else { 125 | Logger.warn('[AuthService] refreshUserToken failed', data); 126 | } 127 | return data; 128 | } 129 | 130 | // 获取用户信息 131 | public async getUserInfo(access_token: string): Promise<any> { 132 | Logger.warn('[AuthService] getUserInfo called'); 133 | try { 134 | const response = await axios.get( 135 | 'https://open.feishu.cn/open-apis/authen/v1/user_info', 136 | { headers: { Authorization: `Bearer ${access_token}` } } 137 | ); 138 | Logger.debug('[AuthService] getUserInfo response', response.data); 139 | return response.data; 140 | } catch (error) { 141 | Logger.error('[AuthService] getUserInfo error', error); 142 | throw error; 143 | } 144 | } 145 | 146 | // 通过授权码换取user_access_token 147 | public async getUserTokenByCode({ client_id, client_secret, code, redirect_uri, code_verifier }: { 148 | client_id: string; 149 | client_secret: string; 150 | code: string; 151 | redirect_uri: string; 152 | code_verifier?: string; 153 | }) { 154 | Logger.warn('[AuthService] getUserTokenByCode called', { client_id, code, redirect_uri }); 155 | const clientKey = await CacheManager.getClientKey(client_id, client_secret); 156 | const body: any = { 157 | grant_type: 'authorization_code', 158 | client_id, 159 | client_secret, 160 | code, 161 | redirect_uri 162 | }; 163 | if (code_verifier) body.code_verifier = code_verifier; 164 | Logger.debug('[AuthService] getUserTokenByCode request', body); 165 | const response = await fetch('https://open.feishu.cn/open-apis/authen/v2/oauth/token', { 166 | method: 'POST', 167 | headers: { 'Content-Type': 'application/json' }, 168 | body: JSON.stringify(body) 169 | }); 170 | const data = await response.json(); 171 | Logger.debug('[AuthService] getUserTokenByCode response', data); 172 | // 缓存user_access_token 173 | if (data && data.access_token && data.expires_in) { 174 | data.expires_at = Math.floor(Date.now() / 1000) + data.expires_in; 175 | data.refresh_token_expires_at = Math.floor(Date.now() / 1000) + data.refresh_token_expires_in; 176 | // 缓存时间应为 refresh_token 的有效期,防止缓存被提前清理 177 | const refreshTtl = data.refresh_expires_in || 3600 * 24 * 365; // 默认1年 178 | this.cache.cacheUserToken(clientKey, data, refreshTtl); 179 | Logger.warn('[AuthService] user_access_token cached', clientKey, 'refreshTtl', refreshTtl); 180 | } else { 181 | Logger.warn('[AuthService] getUserTokenByCode failed', data); 182 | } 183 | return data; 184 | } 185 | } ``` -------------------------------------------------------------------------------- /src/types/feishuSchema.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | // 文档ID或URL参数定义 4 | export const DocumentIdSchema = z.string().describe( 5 | 'Document ID or URL (required). Supports the following formats:\n' + 6 | '1. Standard document URL: https://xxx.feishu.cn/docs/xxx or https://xxx.feishu.cn/docx/xxx\n' + 7 | '2. Direct document ID: e.g., JcKbdlokYoPIe0xDzJ1cduRXnRf\n' + 8 | 'Note: Wiki links require conversion with convert_feishu_wiki_to_document_id first.' 9 | ); 10 | 11 | // 父块ID参数定义 12 | export const ParentBlockIdSchema = z.string().describe( 13 | 'Parent block ID (required). Target block ID where content will be added, without any URL prefix. ' + 14 | 'For page-level (root level) insertion, extract and use only the document ID portion (not the full URL) as parentBlockId. ' + 15 | 'Obtain existing block IDs using the get_feishu_document_blocks tool.' 16 | ); 17 | 18 | // 块ID参数定义 19 | export const BlockIdSchema = z.string().describe( 20 | 'Block ID (required). The ID of the specific block to get content from. You can obtain block IDs using the get_feishu_document_blocks tool.' 21 | ); 22 | 23 | // 插入位置索引参数定义 24 | export const IndexSchema = z.number().describe( 25 | 'Insertion position index (required). This index is relative to the children array of the specified parentBlockId block (not the whole document).\n' + 26 | 'If parentBlockId is the document root (i.e., the document ID), index refers to the position among the document content blocks (excluding the title block itself).\n' + 27 | '0 means to insert as the first content block after the title.\n' + 28 | 'If children is empty or missing, use 0 to insert the first content block.\n' + 29 | 'For nested blocks, index is relative to the parent block\'s children.\n' + 30 | '**index must satisfy 0 ≤ index ≤ parentBlock.children.length, otherwise the API will return an error.**\n'+ 31 | 'Note: The title block itself is not part of the children array and cannot be operated on with index.' + 32 | 'Specifies where the block should be inserted. Use 0 to insert at the beginning. ' + 33 | 'Use get_feishu_document_blocks tool to understand document structure if unsure. ' + 34 | 'For consecutive insertions, calculate next index as previous index + 1.' 35 | ); 36 | 37 | // 起始插入位置索引参数定义 38 | export const StartIndexSchema = z.number().describe( 39 | 'Starting insertion position index (required). This index is relative to the children array of the specified parentBlockId block.\n' + 40 | 'For the document root, this means the content blocks after the title. For other blocks, it means the sub-blocks under that block.\n' + 41 | 'The index does not include the title block itself.' + 42 | 'Specifies where the first block should be inserted or deleted. Use 0 to insert at the beginning. ' + 43 | 'Use get_feishu_document_blocks tool to understand document structure if unsure.' 44 | ); 45 | 46 | // 结束位置索引参数定义 47 | export const EndIndexSchema = z.number().describe( 48 | 'Ending position index (required). This index is relative to the children array of the specified parentBlockId block.\n' + 49 | 'For the document root, this means the content blocks after the title. For other blocks, it means the sub-blocks under that block.\n' + 50 | 'The index does not include the title block itself.' + 51 | 'Specifies the end of the range for deletion (exclusive). ' + 52 | 'For example, to delete blocks 2, 3, and 4, use startIndex=2, endIndex=5. ' + 53 | 'To delete a single block at position 2, use startIndex=2, endIndex=3.' 54 | ); 55 | 56 | // 文本对齐方式参数定义 57 | export const AlignSchema = z.number().optional().default(1).describe( 58 | 'Text alignment: 1 for left (default), 2 for center, 3 for right.' 59 | ); 60 | 61 | // 文本对齐方式参数定义(带验证) 62 | export const AlignSchemaWithValidation = z.number().optional().default(1).refine( 63 | val => val === 1 || val === 2 || val === 3, 64 | { message: "Alignment must be one of: 1 (left), 2 (center), or 3 (right)" } 65 | ).describe( 66 | 'Text alignment (optional): 1 for left (default), 2 for center, 3 for right. Only these three values are allowed.' 67 | ); 68 | 69 | // 文本样式属性定义 70 | export const TextStylePropertiesSchema = { 71 | bold: z.boolean().optional().describe('Whether to make text bold. Default is false, equivalent to **text** in Markdown.'), 72 | italic: z.boolean().optional().describe('Whether to make text italic. Default is false, equivalent to *text* in Markdown.'), 73 | underline: z.boolean().optional().describe('Whether to add underline. Default is false.'), 74 | strikethrough: z.boolean().optional().describe('Whether to add strikethrough. Default is false, equivalent to ~~text~~ in Markdown.'), 75 | inline_code: z.boolean().optional().describe('Whether to format as inline code. Default is false, equivalent to `code` in Markdown.'), 76 | text_color: z.number().optional().refine(val => !val || (val >= 0 && val <= 7), { 77 | message: "Text color must be between 0 and 7 inclusive" 78 | }).describe('Text color value. Default is 0 (black). Available values are only: 1 (gray), 2 (brown), 3 (orange), 4 (yellow), 5 (green), 6 (blue), 7 (purple). Values outside this range will cause an error.'), 79 | background_color: z.number().optional().refine(val => !val || (val >= 1 && val <= 7), { 80 | message: "Background color must be between 1 and 7 inclusive" 81 | }).describe('Background color value. Available values are only: 1 (gray), 2 (brown), 3 (orange), 4 (yellow), 5 (green), 6 (blue), 7 (purple). Values outside this range will cause an error.') 82 | }; 83 | 84 | // 文本样式对象定义 85 | export const TextStyleSchema = z.object(TextStylePropertiesSchema).optional().describe( 86 | 'Text style settings. Explicitly set style properties instead of relying on Markdown syntax conversion.' 87 | ); 88 | 89 | // 文本内容单元定义 - 支持普通文本和公式元素 90 | export const TextElementSchema = z.union([ 91 | z.object({ 92 | text: z.string().describe('Text content. Provide plain text without markdown syntax; use style object for formatting.'), 93 | style: TextStyleSchema 94 | }).describe('Regular text element with optional styling.'), 95 | z.object({ 96 | equation: z.string().describe('Mathematical equation content. The formula or expression to display. Format: LaTeX.'), 97 | style: TextStyleSchema 98 | }).describe('Mathematical equation element with optional styling.') 99 | ]); 100 | 101 | // 文本内容数组定义 102 | export const TextElementsArraySchema = z.array(TextElementSchema).describe( 103 | 'Array of text content objects. A block can contain multiple text segments with different styles. Example: [{text:"Hello",style:{bold:true}},{text:" World",style:{italic:true}}]' 104 | ); 105 | 106 | // 代码块语言参数定义 107 | export const CodeLanguageSchema = z.number().optional().default(1).describe( 108 | "Programming language code (optional). Common language codes:\n" + 109 | "1: PlainText; 2: ABAP; 3: Ada; 4: Apache; 5: Apex; 6: Assembly; 7: Bash; 8: CSharp; 9: C++; 10: C; " + 110 | "11: COBOL; 12: CSS; 13: CoffeeScript; 14: D; 15: Dart; 16: Delphi; 17: Django; 18: Dockerfile; 19: Erlang; 20: Fortran; " + 111 | "22: Go; 23: Groovy; 24: HTML; 25: HTMLBars; 26: HTTP; 27: Haskell; 28: JSON; 29: Java; 30: JavaScript; " + 112 | "31: Julia; 32: Kotlin; 33: LateX; 34: Lisp; 36: Lua; 37: MATLAB; 38: Makefile; 39: Markdown; 40: Nginx; " + 113 | "41: Objective-C; 43: PHP; 44: Perl; 46: PowerShell; 47: Prolog; 48: ProtoBuf; 49: Python; 50: R; " + 114 | "52: Ruby; 53: Rust; 54: SAS; 55: SCSS; 56: SQL; 57: Scala; 58: Scheme; 60: Shell; 61: Swift; 62: Thrift; " + 115 | "63: TypeScript; 64: VBScript; 65: Visual Basic; 66: XML; 67: YAML; 68: CMake; 69: Diff; 70: Gherkin; 71: GraphQL. " + 116 | "Default is 1 (PlainText)." 117 | ); 118 | 119 | // 代码块自动换行参数定义 120 | export const CodeWrapSchema = z.boolean().optional().default(false).describe( 121 | 'Whether to enable automatic line wrapping. Default is false.' 122 | ); 123 | 124 | // 文本样式段落定义 - 用于批量创建块工具 125 | export const TextStyleBlockSchema = z.object({ 126 | textStyles: z.array(TextElementSchema).describe('Array of text content objects with styles. A block can contain multiple text segments with different styles, including both regular text and equations. Example: [{text:"Hello",style:{bold:true}},{equation:"1+2=3",style:{}}]'), 127 | align: z.number().optional().default(1).describe('Text alignment: 1 for left (default), 2 for center, 3 for right.'), 128 | }); 129 | 130 | // 代码块内容定义 - 用于批量创建块工具 131 | export const CodeBlockSchema = z.object({ 132 | code: z.string().describe('Code content. The complete code text to display.'), 133 | language: CodeLanguageSchema, 134 | wrap: CodeWrapSchema, 135 | }); 136 | 137 | // 标题块内容定义 - 用于批量创建块工具 138 | export const HeadingBlockSchema = z.object({ 139 | level: z.number().min(1).max(9).describe('Heading level from 1 to 9, where 1 is the largest (h1) and 9 is the smallest (h9).'), 140 | content: z.string().describe('Heading text content. The actual text of the heading.'), 141 | align: AlignSchemaWithValidation, 142 | }); 143 | 144 | // 列表块内容定义 - 用于批量创建块工具 145 | export const ListBlockSchema = z.object({ 146 | content: z.string().describe('List item content. The actual text of the list item.'), 147 | isOrdered: z.boolean().optional().default(false).describe('Whether this is an ordered (numbered) list item. Default is false (bullet point/unordered).'), 148 | align: AlignSchemaWithValidation, 149 | }); 150 | 151 | // 块类型枚举 - 用于批量创建块工具 152 | export const BlockTypeEnum = z.string().describe( 153 | "Block type (required). Supports: 'text', 'code', 'heading', 'list', 'image','mermaid',as well as 'heading1' through 'heading9'. " + 154 | "For headings, we recommend using 'heading' with level property, but 'heading1'-'heading9' are also supported. " + 155 | "For images, use 'image' to create empty image blocks that can be filled later. " + 156 | "For text blocks, you can include both regular text and equation elements in the same block." 157 | ); 158 | 159 | // 图片宽度参数定义 160 | export const ImageWidthSchema = z.number().optional().describe( 161 | 'Image width in pixels (optional). If not provided, the original image width will be used.' 162 | ); 163 | 164 | // 图片高度参数定义 165 | export const ImageHeightSchema = z.number().optional().describe( 166 | 'Image height in pixels (optional). If not provided, the original image height will be used.' 167 | ); 168 | 169 | // 图片块内容定义 - 用于批量创建块工具 170 | export const ImageBlockSchema = z.object({ 171 | width: ImageWidthSchema, 172 | height: ImageHeightSchema 173 | }); 174 | 175 | // Mermaid代码参数定义 176 | export const MermaidCodeSchema = z.string().describe( 177 | 'Mermaid code (required). The complete Mermaid chart code, e.g. \'graph TD; A-->B;\'. ' + 178 | 'IMPORTANT: When node text contains special characters like parentheses (), brackets [], or arrows -->, ' + 179 | 'wrap the entire text in double quotes to prevent parsing errors. ' + 180 | 'Example: A["finish()/返回键"] instead of A[finish()/返回键].' 181 | ); 182 | 183 | export const MermaidBlockSchema = z.object({ 184 | code: MermaidCodeSchema, 185 | }); 186 | 187 | // 块配置定义 - 用于批量创建块工具 188 | export const BlockConfigSchema = z.object({ 189 | blockType: BlockTypeEnum, 190 | options: z.union([ 191 | z.object({ text: TextStyleBlockSchema }).describe("Text block options. Used when blockType is 'text'."), 192 | z.object({ code: CodeBlockSchema }).describe("Code block options. Used when blockType is 'code'."), 193 | z.object({ heading: HeadingBlockSchema }).describe("Heading block options. Used with both 'heading' and 'headingN' formats."), 194 | z.object({ list: ListBlockSchema }).describe("List block options. Used when blockType is 'list'."), 195 | z.object({ image: ImageBlockSchema }).describe("Image block options. Used when blockType is 'image'. Creates empty image blocks."), 196 | z.object({ mermaid: MermaidBlockSchema}).describe("Mermaid block options. Used when blockType is 'mermaid'."), 197 | z.record(z.any()).describe("Fallback for any other block options") 198 | ]).describe('Options for the specific block type. Provide the corresponding options object based on blockType.'), 199 | }); 200 | 201 | // 表格列数参数定义 202 | export const TableColumnSizeSchema = z.number().min(1).describe( 203 | 'Table column size (required). The number of columns in the table. Must be at least 1.' 204 | ); 205 | 206 | // 表格行数参数定义 207 | export const TableRowSizeSchema = z.number().min(1).describe( 208 | 'Table row size (required). The number of rows in the table. Must be at least 1.' 209 | ); 210 | 211 | // 表格单元格坐标参数定义 212 | export const TableCellCoordinateSchema = z.object({ 213 | row: z.number().min(0).describe('Row coordinate (0-based). The row position of the cell in the table.'), 214 | column: z.number().min(0).describe('Column coordinate (0-based). The column position of the cell in the table.') 215 | }); 216 | 217 | 218 | // 表格单元格内容配置定义 219 | export const TableCellContentSchema = z.object({ 220 | coordinate: TableCellCoordinateSchema, 221 | content: BlockConfigSchema 222 | }); 223 | 224 | // 表格创建参数定义 - 专门用于创建表格块工具 225 | export const TableCreateSchema = z.object({ 226 | columnSize: TableColumnSizeSchema, 227 | rowSize: TableRowSizeSchema, 228 | cells: z.array(TableCellContentSchema).optional().describe( 229 | 'Array of cell configurations (optional). Each cell specifies its position (row, column) and content block configuration. ' + 230 | 'If not provided, empty text blocks will be created for all cells. ' + 231 | 'IMPORTANT: Multiple cells can have the same coordinates (row, column) - when this happens, ' + 232 | 'the content blocks will be added sequentially to the same cell, allowing you to create rich content ' + 233 | 'with multiple blocks (text, code, images, etc.) within a single cell. ' + 234 | 'Example: [{coordinate:{row:0,column:0}, content:{blockType:"text", options:{text:{textStyles:[{text:"Header"}]}}}, ' + 235 | '{coordinate:{row:0,column:0}, content:{blockType:"code", options:{code:{code:"console.log(\'hello\')", language:30}}}}] ' + 236 | 'will add both a text block and a code block to cell (0,0).' 237 | ) 238 | }); 239 | 240 | // 媒体ID参数定义 241 | export const MediaIdSchema = z.string().describe( 242 | 'Media ID (required). The unique identifier for a media resource (image, file, etc.) in Feishu. ' + 243 | 'Usually obtained from image blocks or file references in documents. ' + 244 | 'Format is typically like "boxcnrHpsg1QDqXAAAyachabcef".' 245 | ); 246 | 247 | // 额外参数定义 - 用于媒体资源下载 248 | export const MediaExtraSchema = z.string().optional().describe( 249 | 'Extra parameters for media download (optional). ' + 250 | 'These parameters are passed directly to the Feishu API and can modify how the media is returned.' 251 | ); 252 | 253 | // 文件夹Token参数定义 254 | export const FolderTokenSchema = z.string().describe( 255 | 'Folder token (required). The unique identifier for a folder in Feishu. ' + 256 | 'Format is an alphanumeric string like "FWK2fMleClICfodlHHWc4Mygnhb".' 257 | ); 258 | 259 | // 文件夹名称参数定义 260 | export const FolderNameSchema = z.string().describe( 261 | 'Folder name (required). The name for the new folder to be created.' 262 | ); 263 | 264 | // 搜索关键字参数定义 265 | export const SearchKeySchema = z.string().describe( 266 | 'Search keyword (required). The keyword to search for in documents.' 267 | ); 268 | 269 | // 图片路径或URL参数定义 270 | export const ImagePathOrUrlSchema = z.string().describe( 271 | 'Image path or URL (required). Supports the following formats:\n' + 272 | '1. Local file absolute path: e.g., "C:\\path\\to\\image.jpg"\n' + 273 | '2. HTTP/HTTPS URL: e.g., "https://example.com/image.png"\n' + 274 | 'The tool will automatically detect the format and handle accordingly.' 275 | ); 276 | 277 | // 图片文件名参数定义 278 | export const ImageFileNameSchema = z.string().optional().describe( 279 | 'Image file name (optional). If not provided, a default name will be generated based on the source. ' + 280 | 'Should include the file extension, e.g., "image.png" or "photo.jpg".' 281 | ); 282 | 283 | 284 | // 批量图片上传绑定参数定义 285 | export const ImagesArraySchema = z.array(z.object({ 286 | blockId: BlockIdSchema, 287 | imagePathOrUrl: ImagePathOrUrlSchema, 288 | fileName: ImageFileNameSchema.optional(), 289 | })).describe( 290 | 'Array of image binding objects (required). Each object must include: blockId (target image block ID), imagePathOrUrl (local path or URL of the image), and optionally fileName (image file name, e.g., "image.png").' 291 | ); 292 | 293 | // 画板ID参数定义 294 | export const WhiteboardIdSchema = z.string().describe( 295 | 'Whiteboard ID (required). This is the token value from the board.token field when getting document blocks.\n' + 296 | 'When you find a block with block_type: 43, the whiteboard ID is located in board.token field.\n' + 297 | 'Example: "EPJKwvY5ghe3pVbKj9RcT2msnBX"' 298 | ); 299 | 300 | // 文档标题参数定义 301 | export const DocumentTitleSchema = z.string().describe('Document title (required). This will be displayed in the Feishu document list and document header.'); 302 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/feishuBlockTools.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import { z } from 'zod'; 3 | import { formatErrorMessage } from '../../utils/error.js'; 4 | import { FeishuApiService } from '../../services/feishuApiService.js'; 5 | import { Logger } from '../../utils/logger.js'; 6 | import { detectMimeType } from '../../utils/document.js'; 7 | import { 8 | DocumentIdSchema, 9 | ParentBlockIdSchema, 10 | BlockIdSchema, 11 | IndexSchema, 12 | StartIndexSchema, 13 | EndIndexSchema, 14 | // AlignSchema, 15 | // AlignSchemaWithValidation, 16 | TextElementsArraySchema, 17 | // CodeLanguageSchema, 18 | // CodeWrapSchema, 19 | BlockConfigSchema, 20 | MediaIdSchema, 21 | MediaExtraSchema, 22 | ImagesArraySchema, 23 | // MermaidCodeSchema, 24 | // ImageWidthSchema, 25 | // ImageHeightSchema 26 | TableCreateSchema 27 | } from '../../types/feishuSchema.js'; 28 | 29 | /** 30 | * 注册飞书块相关的MCP工具 31 | * @param server MCP服务器实例 32 | * @param feishuService 飞书API服务实例 33 | */ 34 | export function registerFeishuBlockTools(server: McpServer, feishuService: FeishuApiService | null): void { 35 | // 添加更新块文本内容工具 36 | server.tool( 37 | 'update_feishu_block_text', 38 | 'Updates the text content and styling of a specific block in a Feishu document. Can be used to modify content in existing text, code, or heading blocks while preserving the block type and other properties. 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.', 39 | { 40 | documentId: DocumentIdSchema, 41 | blockId: BlockIdSchema, 42 | textElements: TextElementsArraySchema, 43 | }, 44 | async ({ documentId, blockId, textElements }) => { 45 | try { 46 | if (!feishuService) { 47 | return { 48 | content: [{ type: 'text', text: '飞书服务未初始化,请检查配置' }], 49 | }; 50 | } 51 | 52 | Logger.info(`开始更新飞书块文本内容,文档ID: ${documentId},块ID: ${blockId}`); 53 | const result = await feishuService.updateBlockTextContent(documentId, blockId, textElements); 54 | Logger.info(`飞书块文本内容更新成功`); 55 | 56 | return { 57 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], 58 | }; 59 | } catch (error) { 60 | Logger.error(`更新飞书块文本内容失败:`, error); 61 | const errorMessage = formatErrorMessage(error); 62 | return { 63 | content: [{ type: 'text', text: `更新飞书块文本内容失败: ${errorMessage}` }], 64 | }; 65 | } 66 | }, 67 | ); 68 | 69 | // 添加通用飞书块创建工具(支持文本、代码、标题) 70 | server.tool( 71 | 'batch_create_feishu_blocks', 72 | '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.', 73 | { 74 | documentId: DocumentIdSchema, 75 | parentBlockId: ParentBlockIdSchema, 76 | index: IndexSchema, 77 | blocks: z.array(BlockConfigSchema).describe('Array of block configurations. CRITICAL: Must be a JSON array object, NOT a string. CORRECT: blocks:[{...}] - WITHOUT quotes around array. INCORRECT: blocks:"[{...}]". Example: [{blockType:"text",options:{text:{textStyles:[{text:"Hello",style:{bold:true}}]}}},{blockType:"heading1",options:{heading:{content:"My Title"}}}]. Auto-batches requests when exceeding 50 blocks.'), 78 | }, 79 | async ({ documentId, parentBlockId, index = 0, blocks }) => { 80 | try { 81 | if (!feishuService) { 82 | return { 83 | content: [ 84 | { 85 | type: 'text', 86 | text: 'Feishu service is not initialized. Please check the configuration', 87 | }, 88 | ], 89 | }; 90 | } 91 | 92 | // 类型检查:确保blocks是数组而不是字符串 93 | if (typeof blocks === 'string') { 94 | return { 95 | content: [ 96 | { 97 | type: 'text', 98 | text: 'ERROR: The "blocks" parameter was passed as a string instead of an array. Please provide a proper JSON array without quotes. Example: {blocks:[{blockType:"text",options:{...}}]} instead of {blocks:"[{...}]"}', 99 | }, 100 | ], 101 | }; 102 | } 103 | 104 | // 如果块数量不超过50,直接调用一次API 105 | if (blocks.length <= 50) { 106 | Logger.info( 107 | `开始批量创建飞书块,文档ID: ${documentId},父块ID: ${parentBlockId},块数量: ${blocks.length},起始插入位置: ${index}`); 108 | 109 | // 准备要创建的块内容数组 110 | const blockContents = []; 111 | 112 | // 处理每个块配置 113 | for (const blockConfig of blocks) { 114 | const { blockType, options = {} } = blockConfig; 115 | 116 | // 创建块内容 117 | try { 118 | const blockContent = feishuService.createBlockContent(blockType, options); 119 | 120 | if (blockContent) { 121 | blockContents.push(blockContent); 122 | Logger.info(`已准备${blockType}块,内容: ${JSON.stringify(blockContent).substring(0, 100)}...`); 123 | } else { 124 | Logger.warn(`创建${blockType}块失败,跳过此块`); 125 | } 126 | } catch (error) { 127 | Logger.error(`处理块类型${blockType}时出错: ${error}`); 128 | return { 129 | content: [{ 130 | type: 'text', 131 | text: `处理块类型"${blockType}"时出错: ${error}\n请检查该块类型的配置是否正确。` 132 | }], 133 | }; 134 | } 135 | } 136 | 137 | // 批量创建所有块 138 | const result = await feishuService.createDocumentBlocks(documentId, parentBlockId, blockContents, index); 139 | Logger.info(`飞书块批量创建成功,共创建 ${blockContents.length} 个块`); 140 | 141 | // 检查是否有图片块(block_type=27) 142 | const imageBlocks = result.children?.filter((child: any) => child.block_type === 27) || []; 143 | const hasImageBlocks = imageBlocks.length > 0; 144 | 145 | const responseData = { 146 | ...result, 147 | nextIndex: index + blockContents.length, 148 | totalBlocksCreated: blockContents.length, 149 | ...(hasImageBlocks && { 150 | imageBlocksInfo: { 151 | count: imageBlocks.length, 152 | blockIds: imageBlocks.map((block: any) => block.block_id), 153 | reminder: "检测到图片块已创建!请使用 upload_and_bind_image_to_block 工具上传图片并绑定到对应的块ID。" 154 | } 155 | }) 156 | }; 157 | 158 | return { 159 | content: [{ type: 'text', text: JSON.stringify(responseData, null, 2) }], 160 | }; 161 | } else { 162 | // 如果块数量超过50,需要分批处理 163 | Logger.info( 164 | `块数量(${blocks.length})超过50,将分批创建`); 165 | 166 | const batchSize = 50; // 每批最大50个 167 | const totalBatches = Math.ceil(blocks.length / batchSize); 168 | const results = []; 169 | let currentStartIndex = index; 170 | let createdBlocksCount = 0; 171 | let allBatchesSuccess = true; 172 | 173 | // 分批创建块 174 | for (let batchNum = 0; batchNum < totalBatches; batchNum++) { 175 | const batchStart = batchNum * batchSize; 176 | const batchEnd = Math.min((batchNum + 1) * batchSize, blocks.length); 177 | const currentBatch = blocks.slice(batchStart, batchEnd); 178 | 179 | Logger.info( 180 | `处理第 ${batchNum + 1}/${totalBatches} 批,起始位置: ${currentStartIndex},块数量: ${currentBatch.length}`); 181 | 182 | try { 183 | // 准备当前批次的块内容 184 | const batchBlockContents = []; 185 | for (const blockConfig of currentBatch) { 186 | const { blockType, options = {} } = blockConfig; 187 | try { 188 | const blockContent = feishuService.createBlockContent(blockType, options); 189 | if (blockContent) { 190 | batchBlockContents.push(blockContent); 191 | } else { 192 | Logger.warn(`创建${blockType}块失败,跳过此块`); 193 | } 194 | } catch (error) { 195 | Logger.error(`处理块类型${blockType}时出错: ${error}`); 196 | return { 197 | content: [{ 198 | type: 'text', 199 | text: `处理块类型"${blockType}"时出错: ${error}\n请检查该块类型的配置是否正确。` 200 | }], 201 | }; 202 | } 203 | } 204 | 205 | // 批量创建当前批次的块 206 | const batchResult = await feishuService.createDocumentBlocks( 207 | documentId, 208 | parentBlockId, 209 | batchBlockContents, 210 | currentStartIndex 211 | ); 212 | 213 | results.push(batchResult); 214 | 215 | // 计算下一批的起始位置(当前位置+已创建块数量) 216 | // 注意:每批成功创建后,需要将起始索引更新为当前索引 + 已创建块数量 217 | createdBlocksCount += batchBlockContents.length; 218 | currentStartIndex = index + createdBlocksCount; 219 | 220 | Logger.info( 221 | `第 ${batchNum + 1}/${totalBatches} 批创建成功,当前已创建 ${createdBlocksCount} 个块`); 222 | } catch (error) { 223 | Logger.error(`第 ${batchNum + 1}/${totalBatches} 批创建失败:`, error); 224 | allBatchesSuccess = false; 225 | 226 | // 如果有批次失败,返回详细错误信息 227 | const errorMessage = formatErrorMessage(error); 228 | return { 229 | content: [ 230 | { 231 | type: 'text', 232 | text: `批量创建飞书块部分失败:第 ${batchNum + 1}/${totalBatches} 批处理时出错。\n\n` + 233 | `已成功创建 ${createdBlocksCount} 个块,但还有 ${blocks.length - createdBlocksCount} 个块未能创建。\n\n` + 234 | `错误信息: ${errorMessage}\n\n` + 235 | `建议使用 get_feishu_document_blocks 工具获取文档最新状态,确认已创建的内容,然后从索引位置 ${currentStartIndex} 继续创建剩余块。` 236 | } 237 | ], 238 | }; 239 | } 240 | } 241 | 242 | if (allBatchesSuccess) { 243 | Logger.info(`所有批次创建成功,共创建 ${createdBlocksCount} 个块`); 244 | 245 | // 检查所有批次中是否有图片块(block_type=27) 246 | const allImageBlocks: any[] = []; 247 | results.forEach(batchResult => { 248 | const imageBlocks = batchResult.children?.filter((child: any) => child.block_type === 27) || []; 249 | allImageBlocks.push(...imageBlocks); 250 | }); 251 | const hasImageBlocks = allImageBlocks.length > 0; 252 | 253 | const responseText = `所有飞书块创建成功,共分 ${totalBatches} 批创建了 ${createdBlocksCount} 个块。\n\n` + 254 | `最后一批结果: ${JSON.stringify(results[results.length - 1], null, 2)}\n\n` + 255 | `下一个索引位置: ${currentStartIndex},总创建块数: ${createdBlocksCount}` + 256 | (hasImageBlocks ? `\n\n⚠️ 检测到 ${allImageBlocks.length} 个图片块已创建!\n` + 257 | `图片块IDs: ${allImageBlocks.map(block => block.block_id).join(', ')}\n` + 258 | `请使用 upload_and_bind_image_to_block 工具上传图片并绑定到对应的块ID。` : ''); 259 | 260 | return { 261 | content: [ 262 | { 263 | type: 'text', 264 | text: responseText 265 | } 266 | ], 267 | }; 268 | } 269 | } 270 | 271 | // 这个return语句是为了避免TypeScript错误,实际上代码永远不会执行到这里 272 | return { 273 | content: [{ type: 'text', text: '操作完成' }], 274 | }; 275 | } catch (error) { 276 | Logger.error(`批量创建飞书块失败:`, error); 277 | const errorMessage = formatErrorMessage(error); 278 | return { 279 | content: [ 280 | { 281 | type: 'text', 282 | text: `批量创建飞书块失败: ${errorMessage}\n\n` + 283 | `建议使用 get_feishu_document_blocks 工具获取文档当前状态,确认是否有部分内容已创建成功。` 284 | } 285 | ], 286 | }; 287 | } 288 | }, 289 | ); 290 | 291 | // 添加创建飞书文本块工具 292 | // server.tool( 293 | // "create_feishu_text_block", 294 | // "Creates a new text block with precise style control. Unlike markdown-based formatting, this tool lets you explicitly set text styles for each text segment. Ideal for formatted documents where exact styling control is needed. NOTE: If creating multiple blocks at once, use batch_create_feishu_blocks tool instead for better efficiency. 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.", 295 | // { 296 | // documentId: DocumentIdSchema, 297 | // parentBlockId: ParentBlockIdSchema, 298 | // textContents: TextElementsArraySchema, 299 | // align: AlignSchema, 300 | // index: IndexSchema 301 | // }, 302 | // async ({ documentId, parentBlockId, textContents, align = 1, index }) => { 303 | // try { 304 | // if (!feishuService) { 305 | // return { 306 | // content: [{ type: "text", text: "Feishu service is not initialized. Please check the configuration" }], 307 | // }; 308 | // } 309 | // 310 | // Logger.info(`开始创建飞书文本块,文档ID: ${documentId},父块ID: ${parentBlockId},对齐方式: ${align},插入位置: ${index}`); 311 | // const result = await feishuService.createTextBlock(documentId, parentBlockId, textContents, align, index); 312 | // Logger.info(`飞书文本块创建成功`); 313 | // 314 | // return { 315 | // content: [{ type: "text", text: JSON.stringify(result, null, 2) }], 316 | // }; 317 | // } catch (error) { 318 | // Logger.error(`创建飞书文本块失败:`, error); 319 | // const errorMessage = formatErrorMessage(error); 320 | // return { 321 | // content: [{ type: "text", text: `创建飞书文本块失败: ${errorMessage}` }], 322 | // }; 323 | // } 324 | // }, 325 | // ); 326 | // 327 | // // 添加创建飞书代码块工具 328 | // server.tool( 329 | // "create_feishu_code_block", 330 | // "Creates a new code block with syntax highlighting and formatting options. Ideal for technical documentation, tutorials, or displaying code examples with proper formatting and language-specific highlighting. NOTE: If creating multiple blocks at once, use batch_create_feishu_blocks tool instead for better efficiency. 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.", 331 | // { 332 | // documentId: DocumentIdSchema, 333 | // parentBlockId: ParentBlockIdSchema, 334 | // code: z.string().describe("Code content (required). The complete code text to display."), 335 | // language: CodeLanguageSchema, 336 | // wrap: CodeWrapSchema, 337 | // index: IndexSchema 338 | // }, 339 | // async ({ documentId, parentBlockId, code, language = 1, wrap = false, index = 0 }) => { 340 | // try { 341 | // if (!feishuService) { 342 | // return { 343 | // content: [{ type: "text", text: "Feishu service is not initialized. Please check the configuration" }], 344 | // }; 345 | // } 346 | // 347 | // Logger.info(`开始创建飞书代码块,文档ID: ${documentId},父块ID: ${parentBlockId},语言: ${language},自动换行: ${wrap},插入位置: ${index}`); 348 | // const result = await feishuService.createCodeBlock(documentId, parentBlockId, code, language, wrap, index); 349 | // Logger.info(`飞书代码块创建成功`); 350 | // 351 | // return { 352 | // content: [{ type: "text", text: JSON.stringify(result, null, 2) }], 353 | // }; 354 | // } catch (error) { 355 | // Logger.error(`创建飞书代码块失败:`, error); 356 | // const errorMessage = formatErrorMessage(error); 357 | // return { 358 | // content: [{ type: "text", text: `创建飞书代码块失败: ${errorMessage}` }], 359 | // }; 360 | // } 361 | // }, 362 | // ); 363 | // 364 | // // 添加创建飞书标题块工具 365 | // server.tool( 366 | // "create_feishu_heading_block", 367 | // "Creates a heading block with customizable level and alignment. Use this tool to add section titles, chapter headings, or any hierarchical structure elements to your document. Supports nine heading levels for different emphasis needs. NOTE: If creating multiple blocks at once, use batch_create_feishu_blocks tool instead for better efficiency. 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.", 368 | // { 369 | // documentId: DocumentIdSchema, 370 | // parentBlockId: ParentBlockIdSchema, 371 | // level: z.number().min(1).max(9).describe("Heading level (required). Integer between 1 and 9, where 1 is the largest heading (h1) and 9 is the smallest (h9)."), 372 | // content: z.string().describe("Heading text content (required). The actual text of the heading."), 373 | // align: AlignSchemaWithValidation, 374 | // index: IndexSchema 375 | // }, 376 | // async ({ documentId, parentBlockId, level, content, align = 1, index = 0 }) => { 377 | // try { 378 | // if (!feishuService) { 379 | // return { 380 | // content: [{ type: "text", text: "Feishu service is not initialized. Please check the configuration" }], 381 | // }; 382 | // } 383 | // 384 | // // 确保align值在合法范围内(1-3) 385 | // if (align !== 1 && align !== 2 && align !== 3) { 386 | // return { 387 | // content: [{ type: "text", text: "错误: 对齐方式(align)参数必须是1(居左)、2(居中)或3(居右)中的一个值。" }], 388 | // }; 389 | // } 390 | // 391 | // Logger.info(`开始创建飞书标题块,文档ID: ${documentId},父块ID: ${parentBlockId},标题级别: ${level},对齐方式: ${align},插入位置: ${index}`); 392 | // const result = await feishuService.createHeadingBlock(documentId, parentBlockId, content, level, index, align); 393 | // Logger.info(`飞书标题块创建成功`); 394 | // 395 | // return { 396 | // content: [{ type: "text", text: JSON.stringify(result, null, 2) }], 397 | // }; 398 | // } catch (error) { 399 | // Logger.error(`创建飞书标题块失败:`, error); 400 | // const errorMessage = formatErrorMessage(error); 401 | // return { 402 | // content: [{ type: "text", text: `创建飞书标题块失败: ${errorMessage}` }], 403 | // }; 404 | // } 405 | // }, 406 | // ); 407 | // 408 | // // 添加创建飞书列表块工具 409 | // server.tool( 410 | // "create_feishu_list_block", 411 | // "Creates a list item block (either ordered or unordered). Perfect for creating hierarchical and structured content with bullet points or numbered lists. NOTE: If creating multiple blocks at once, use batch_create_feishu_blocks tool instead for better efficiency. 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.", 412 | // { 413 | // documentId: DocumentIdSchema, 414 | // parentBlockId: ParentBlockIdSchema, 415 | // content: z.string().describe("List item content (required). The actual text of the list item."), 416 | // isOrdered: z.boolean().optional().default(false).describe("Whether this is an ordered (numbered) list item. Default is false (bullet point/unordered)."), 417 | // align: AlignSchemaWithValidation, 418 | // index: IndexSchema 419 | // }, 420 | // async ({ documentId, parentBlockId, content, isOrdered = false, align = 1, index = 0 }) => { 421 | // try { 422 | // if (!feishuService) { 423 | // return { 424 | // content: [{ type: "text", text: "Feishu service is not initialized. Please check the configuration" }], 425 | // }; 426 | // } 427 | // 428 | // // 确保align值在合法范围内(1-3) 429 | // if (align !== 1 && align !== 2 && align !== 3) { 430 | // return { 431 | // content: [{ type: "text", text: "错误: 对齐方式(align)参数必须是1(居左)、2(居中)或3(居右)中的一个值。" }], 432 | // }; 433 | // } 434 | // 435 | // const listType = isOrdered ? "有序" : "无序"; 436 | // Logger.info(`开始创建飞书${listType}列表块,文档ID: ${documentId},父块ID: ${parentBlockId},对齐方式: ${align},插入位置: ${index}`); 437 | // const result = await feishuService.createListBlock(documentId, parentBlockId, content, isOrdered, index, align); 438 | // Logger.info(`飞书${listType}列表块创建成功`); 439 | // 440 | // return { 441 | // content: [{ type: "text", text: JSON.stringify(result, null, 2) }], 442 | // }; 443 | // } catch (error) { 444 | // Logger.error(`创建飞书列表块失败:`, error); 445 | // const errorMessage = formatErrorMessage(error); 446 | // return { 447 | // content: [{ type: "text", text: `创建飞书列表块失败: ${errorMessage}` }], 448 | // }; 449 | // } 450 | // }, 451 | // ); 452 | 453 | // 添加创建飞书Mermaid块工具 454 | // server.tool( 455 | // "create_feishu_mermaid_block", 456 | // "Creates a new Mermaid block in a Feishu document. This tool allows you to insert a Mermaid diagram by specifying the Mermaid code. 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.", 457 | // { 458 | // documentId: DocumentIdSchema, 459 | // parentBlockId: ParentBlockIdSchema, 460 | // mermaidCode: MermaidCodeSchema, 461 | // index: IndexSchema 462 | // }, 463 | // async ({ documentId, parentBlockId, mermaidCode, index }) => { 464 | // try { 465 | // if (!feishuService) { 466 | // return { 467 | // content: [{ type: "text", text: "Feishu service is not initialized. Please check the configuration" }], 468 | // }; 469 | // } 470 | // Logger.info(`开始创建飞书Mermaid块,文档ID: ${documentId},父块ID: ${parentBlockId},插入位置: ${index}`); 471 | // const result = await (feishuService as any).createMermaidBlock(documentId, parentBlockId, mermaidCode, index); 472 | // Logger.info(`飞书Mermaid块创建成功`); 473 | // return { 474 | // content: [{ type: "text", text: JSON.stringify(result, null, 2) }], 475 | // }; 476 | // } catch (error) { 477 | // Logger.error(`创建飞书Mermaid块失败:`, error); 478 | // const errorMessage = formatErrorMessage(error); 479 | // return { 480 | // content: [{ type: "text", text: `创建飞书Mermaid块失败: ${errorMessage}` }], 481 | // }; 482 | // } 483 | // }, 484 | // ); 485 | 486 | // 添加飞书Wiki文档ID转换工具 487 | server.tool( 488 | 'convert_feishu_wiki_to_document_id', 489 | 'Converts a Feishu Wiki document link to a compatible document ID. This conversion is required before using wiki links with any other Feishu document tools.', 490 | { 491 | wikiUrl: z.string().describe('Wiki URL or Token (required). Supports complete URL formats like https://xxx.feishu.cn/wiki/xxxxx or direct use of the Token portion'), 492 | }, 493 | async ({ wikiUrl }) => { 494 | try { 495 | if (!feishuService) { 496 | return { 497 | content: [{ type: 'text', text: '飞书服务未初始化,请检查配置' }], 498 | }; 499 | } 500 | 501 | Logger.info(`开始转换Wiki文档链接,输入: ${wikiUrl}`); 502 | const documentId = await feishuService.convertWikiToDocumentId(wikiUrl); 503 | 504 | Logger.info(`Wiki文档转换成功,可用的文档ID为: ${documentId}`); 505 | 506 | return { 507 | content: [ 508 | { type: 'text', text: `Converted Wiki link to Document ID: ${documentId}\n\nUse this Document ID with other Feishu document tools.` } 509 | ], 510 | }; 511 | } catch (error) { 512 | Logger.error(`转换Wiki文档链接失败:`, error); 513 | const errorMessage = formatErrorMessage(error); 514 | return { 515 | content: [{ type: 'text', text: `转换Wiki文档链接失败: ${errorMessage}` }], 516 | }; 517 | } 518 | }, 519 | ); 520 | 521 | // 添加删除文档块工具 522 | server.tool( 523 | 'delete_feishu_document_blocks', 524 | 'Deletes one or more consecutive blocks from a Feishu document. Use this tool to remove unwanted content, clean up document structure, or clear space before inserting new content. Supports batch deletion for efficiency. 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.', 525 | { 526 | documentId: DocumentIdSchema, 527 | parentBlockId: ParentBlockIdSchema, 528 | startIndex: StartIndexSchema, 529 | endIndex: EndIndexSchema, 530 | }, 531 | async ({ documentId, parentBlockId, startIndex, endIndex }) => { 532 | try { 533 | if (!feishuService) { 534 | return { 535 | content: [{ type: 'text', text: 'Feishu service is not initialized. Please check the configuration' }], 536 | }; 537 | } 538 | 539 | Logger.info(`开始删除飞书文档块,文档ID: ${documentId},父块ID: ${parentBlockId},索引范围: ${startIndex}-${endIndex}`); 540 | const result = await feishuService.deleteDocumentBlocks(documentId, parentBlockId, startIndex, endIndex); 541 | Logger.info(`飞书文档块删除成功,文档修订ID: ${result.document_revision_id}`); 542 | 543 | return { 544 | content: [{ type: 'text', text: `Successfully deleted blocks from index ${startIndex} to ${endIndex - 1}` }], 545 | }; 546 | } catch (error) { 547 | Logger.error(`删除飞书文档块失败:`, error); 548 | const errorMessage = formatErrorMessage(error); 549 | return { 550 | content: [{ type: 'text', text: `Failed to delete document blocks: ${errorMessage}` }], 551 | }; 552 | } 553 | }, 554 | ); 555 | 556 | // 添加获取图片资源工具 557 | server.tool( 558 | 'get_feishu_image_resource', 559 | 'Downloads an image resource from Feishu by its media ID. Use this to retrieve images referenced in document blocks or other Feishu resources. Returns the binary image data that can be saved or processed further. For example, extract the media_id from an image block in a document, then use this tool to download the actual image.', 560 | { 561 | mediaId: MediaIdSchema, 562 | extra: MediaExtraSchema, 563 | }, 564 | async ({ mediaId, extra = '' }) => { 565 | try { 566 | if (!feishuService) { 567 | return { 568 | content: [{ type: 'text', text: 'Feishu service is not initialized. Please check the configuration' }], 569 | }; 570 | } 571 | 572 | Logger.info(`开始获取飞书图片资源,媒体ID: ${mediaId}`); 573 | const imageBuffer = await feishuService.getImageResource(mediaId, extra); 574 | Logger.info(`飞书图片资源获取成功,大小: ${imageBuffer.length} 字节`); 575 | 576 | // 将图片数据转为Base64编码,以便在MCP协议中传输 577 | const base64Image = imageBuffer.toString('base64'); 578 | const mimeType = detectMimeType(imageBuffer); 579 | 580 | return { 581 | content: [{ 582 | type: 'image', 583 | mimeType: mimeType, 584 | data: base64Image 585 | }], 586 | }; 587 | } catch (error) { 588 | Logger.error(`获取飞书图片资源失败:`, error); 589 | const errorMessage = formatErrorMessage(error); 590 | return { 591 | content: [{ type: 'text', text: `Failed to get image resource: ${errorMessage}` }], 592 | }; 593 | } 594 | }, 595 | ); 596 | 597 | // 添加创建飞书图片块工具 598 | // server.tool( 599 | // 'create_feishu_image_block', 600 | // 'Creates a complete image block in a Feishu document by uploading an image from a local path or URL and setting it to the block. This tool handles the entire 3-step process: (1) Creates an empty image block, (2) Downloads/reads the image and uploads it as media resource, (3) Sets the image content to the block. Supports local file paths and HTTP/HTTPS URLs. Use this when you want to insert images into Feishu documents. 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.', 601 | // { 602 | // documentId: DocumentIdSchema, 603 | // parentBlockId: ParentBlockIdSchema, 604 | // imagePathOrUrl: ImagePathOrUrlSchema, 605 | // fileName: ImageFileNameSchema, 606 | // width: ImageWidthSchema, 607 | // height: ImageHeightSchema, 608 | // index: IndexSchema 609 | // }, 610 | // async ({ documentId, parentBlockId, imagePathOrUrl, fileName, width, height, index = 0 }) => { 611 | // try { 612 | // if (!feishuService) { 613 | // return { 614 | // content: [{ type: 'text', text: 'Feishu service is not initialized. Please check the configuration' }], 615 | // }; 616 | // } 617 | // 618 | // Logger.info(`开始创建飞书图片块,文档ID: ${documentId},父块ID: ${parentBlockId},图片源: ${imagePathOrUrl},插入位置: ${index}`); 619 | // 620 | // const result = await feishuService.createImageBlock(documentId, parentBlockId, imagePathOrUrl, { 621 | // fileName, 622 | // width, 623 | // height, 624 | // index 625 | // }); 626 | // 627 | // Logger.info(`飞书图片块创建成功,块ID: ${result.imageBlockId}`); 628 | // 629 | // return { 630 | // content: [{ 631 | // type: 'text', 632 | // text: `图片块创建成功!\n\n块ID: ${result.imageBlockId}\n文件Token: ${result.fileToken}\n文档修订ID: ${result.documentRevisionId}\n\n完整结果:\n${JSON.stringify(result, null, 2)}` 633 | // }], 634 | // }; 635 | // } catch (error) { 636 | // Logger.error(`创建飞书图片块失败:`, error); 637 | // const errorMessage = formatErrorMessage(error); 638 | // return { 639 | // content: [{ type: 'text', text: `创建飞书图片块失败: ${errorMessage}` }], 640 | // }; 641 | // } 642 | // }, 643 | // ); 644 | 645 | // 添加图片上传绑定工具 646 | server.tool( 647 | 'upload_and_bind_image_to_block', 648 | 'Uploads images from local paths or URLs and binds them to existing empty image blocks. This tool is used after creating image blocks with batch_create_feishu_blocks tool. It handles uploading the image media and setting the image content to the specified block IDs. Supports local file paths and HTTP/HTTPS URLs. Each image upload and binding is processed independently, and all results are returned in order.', 649 | { 650 | documentId: DocumentIdSchema, 651 | images:ImagesArraySchema, 652 | }, 653 | async ({ documentId, images }) => { 654 | try { 655 | if (!feishuService) { 656 | return { 657 | content: [{ type: 'text', text: 'Feishu service is not initialized. Please check the configuration' }], 658 | }; 659 | } 660 | const results = []; 661 | for (const { blockId, imagePathOrUrl, fileName } of images) { 662 | Logger.info(`开始上传图片并绑定到块,文档ID: ${documentId},块ID: ${blockId},图片源: ${imagePathOrUrl}`); 663 | try { 664 | const { base64: imageBase64, fileName: detectedFileName } = await (feishuService as any).getImageBase64FromPathOrUrl(imagePathOrUrl); 665 | const finalFileName = fileName || detectedFileName; 666 | Logger.info('第1步:上传图片素材'); 667 | const uploadResult = await feishuService.uploadImageMedia( 668 | imageBase64, 669 | finalFileName, 670 | blockId, 671 | ); 672 | if (!uploadResult?.file_token) { 673 | throw new Error('上传图片素材失败:无法获取file_token'); 674 | } 675 | Logger.info(`图片素材上传成功,file_token: ${uploadResult.file_token}`); 676 | Logger.info('第2步:设置图片块内容'); 677 | const setContentResult = await feishuService.setImageBlockContent( 678 | documentId, 679 | blockId, 680 | uploadResult.file_token, 681 | ); 682 | Logger.info('图片上传并绑定完成'); 683 | results.push({ 684 | blockId, 685 | fileToken: uploadResult.file_token, 686 | uploadResult, 687 | setContentResult, 688 | documentRevisionId: setContentResult.document_revision_id 689 | }); 690 | } catch (err) { 691 | Logger.error(`上传图片并绑定到块失败:`, err); 692 | results.push({ 693 | blockId, 694 | error: err instanceof Error ? err.message : String(err) 695 | }); 696 | } 697 | } 698 | return { 699 | content: [{ type: 'text', text: `批量图片上传绑定结果:\n${JSON.stringify(results, null, 2)}` }], 700 | }; 701 | } catch (error) { 702 | Logger.error(`批量上传图片并绑定到块失败:`, error); 703 | const errorMessage = formatErrorMessage(error); 704 | return { 705 | content: [{ type: 'text', text: `批量上传图片并绑定到块失败: ${errorMessage}` }], 706 | }; 707 | } 708 | }, 709 | ); 710 | 711 | // 添加创建飞书表格工具 712 | server.tool( 713 | 'create_feishu_table', 714 | '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.', 715 | { 716 | documentId: DocumentIdSchema, 717 | parentBlockId: ParentBlockIdSchema, 718 | index: IndexSchema, 719 | tableConfig: TableCreateSchema, 720 | }, 721 | async ({ documentId, parentBlockId, index = 0, tableConfig }) => { 722 | try { 723 | if (!feishuService) { 724 | return { 725 | content: [{ type: 'text', text: '飞书服务未初始化,请检查配置' }], 726 | }; 727 | } 728 | 729 | Logger.info(`开始创建飞书表格,文档ID: ${documentId},父块ID: ${parentBlockId},表格大小: ${tableConfig.rowSize}x${tableConfig.columnSize},插入位置: ${index}`); 730 | 731 | const result = await feishuService.createTableBlock( 732 | documentId, 733 | parentBlockId, 734 | tableConfig, 735 | index 736 | ); 737 | 738 | // 构建返回信息 739 | let resultText = `表格创建成功!\n\n表格大小: ${tableConfig.rowSize}x${tableConfig.columnSize}\n`; 740 | 741 | // 如果有图片token,显示图片信息 742 | if (result.imageTokens && result.imageTokens.length > 0) { 743 | resultText += `\n\n📸 发现 ${result.imageTokens.length} 个图片:\n`; 744 | result.imageTokens.forEach((imageToken: any, index: number) => { 745 | resultText += `${index + 1}. 坐标(${imageToken.row}, ${imageToken.column}) - blockId: ${imageToken.blockId}\n`; 746 | }); 747 | resultText +="你需要使用upload_and_bind_image_to_block工具绑定图片" 748 | } 749 | 750 | resultText += `\n\n完整结果:\n${JSON.stringify(result, null, 2)}`; 751 | 752 | return { 753 | content: [{ 754 | type: 'text', 755 | text: resultText 756 | }], 757 | }; 758 | } catch (error) { 759 | Logger.error(`创建飞书表格失败:`, error); 760 | const errorMessage = formatErrorMessage(error); 761 | return { 762 | content: [{ type: 'text', text: `创建飞书表格失败: ${errorMessage}` }], 763 | }; 764 | } 765 | }, 766 | ); 767 | } ```