UNPKG

dingtalk-department-mcp

Version:

DingTalk Department Management MCP Server - TypeScript implementation for AI assistants

391 lines (390 loc) 14.8 kB
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import axios from 'axios'; import * as yaml from 'js-yaml'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export class DingTalkDepartmentMCPServer { server; accessToken; appId; appSecret; tools = []; tokenCacheFile; tokenCacheData = null; constructor() { this.server = new Server({ name: 'dingtalk-department', version: '1.0.0', }); // 从环境变量获取配置 this.accessToken = process.env.DINGTALK_ACCESS_TOKEN; this.appId = process.env.DINGTALK_Client_ID; this.appSecret = process.env.DINGTALK_Client_Secret; // Token缓存配置 this.tokenCacheFile = path.join(__dirname, '..', '.dingtalk_token_cache.json'); this.loadConfig(); this.loadTokenCache(); this.setupHandlers(); } /** * 加载YAML配置文件 */ loadConfig() { try { const configPath = path.join(__dirname, '..', 'dingtalk_department_mcp.yaml'); const config = yaml.load(fs.readFileSync(configPath, 'utf8')); // 过滤掉无用的工具和参数 this.tools = (config.tools || []).map(tool => ({ ...tool, args: (tool.args || []).filter(arg => arg.name && arg.name !== '名称' && arg.description && arg.description !== '描述') })); console.error(`Loaded ${this.tools.length} department management tools from config`); console.error(`Tools: ${this.tools.map(t => t.name).join(', ')}`); } catch (error) { console.error('Failed to load config:', error.message); this.tools = []; } } /** * 加载本地缓存的access_token */ loadTokenCache() { try { if (fs.existsSync(this.tokenCacheFile)) { const cacheData = JSON.parse(fs.readFileSync(this.tokenCacheFile, 'utf8')); // 检查缓存是否有效 if (this.isTokenCacheValid(cacheData)) { this.tokenCacheData = cacheData; this.accessToken = cacheData.access_token; console.error('Loaded valid access token from cache'); console.error(`Token expires at: ${new Date(cacheData.expires_at).toISOString()}`); } else { console.error('Cached token expired, will refresh when needed'); this.clearTokenCache(); } } else { console.error('No token cache found'); } } catch (error) { console.error('Failed to load token cache:', error.message); this.clearTokenCache(); } } /** * 检查token缓存是否有效(未过期) */ isTokenCacheValid(cacheData) { if (!cacheData || !cacheData.access_token || !cacheData.expires_at) { return false; } // 提前5分钟刷新token,避免在请求过程中过期 const bufferTime = 5 * 60 * 1000; // 5分钟 const now = Date.now(); return now < (cacheData.expires_at - bufferTime); } /** * 保存access_token到本地缓存 */ saveTokenCache(accessToken, expiresIn = 7200) { try { const now = Date.now(); const expiresAt = now + (expiresIn * 1000); // 转换为毫秒 this.tokenCacheData = { access_token: accessToken, expires_in: expiresIn, expires_at: expiresAt, created_at: now, app_id: this.appId // 记录对应的appId,避免不同应用混用 }; fs.writeFileSync(this.tokenCacheFile, JSON.stringify(this.tokenCacheData, null, 2)); console.error('Access token saved to cache'); console.error(`Token expires at: ${new Date(expiresAt).toISOString()}`); } catch (error) { console.error('Failed to save token cache:', error.message); } } /** * 清除token缓存 */ clearTokenCache() { try { if (fs.existsSync(this.tokenCacheFile)) { fs.unlinkSync(this.tokenCacheFile); console.error('Token cache cleared'); } this.tokenCacheData = null; } catch (error) { console.error('Failed to clear token cache:', error.message); } } /** * 获取有效的access_token(优先使用缓存) */ async getValidAccessToken() { // 如果环境变量中有token,直接使用 if (process.env.DINGTALK_ACCESS_TOKEN) { return process.env.DINGTALK_ACCESS_TOKEN; } // 检查当前缓存的token是否有效 if (this.tokenCacheData && this.isTokenCacheValid(this.tokenCacheData)) { console.error('Using cached access token'); return this.tokenCacheData.access_token; } // 缓存无效或不存在,刷新token console.error('Token cache invalid or missing, refreshing...'); return await this.refreshAccessToken(); } /** * 刷新access_token */ async refreshAccessToken() { if (!this.appId || !this.appSecret) { throw new Error('DINGTALK_Client_ID and DINGTALK_Client_Secret are required'); } try { console.error('Requesting new access token from DingTalk API...'); const response = await axios.get('https://oapi.dingtalk.com/gettoken', { params: { appkey: this.appId, appsecret: this.appSecret }, timeout: 10000 // 10秒超时 }); const data = response.data; if (data.errcode === 0 && data.access_token) { this.accessToken = data.access_token; const expiresIn = data.expires_in || 7200; // 保存到缓存 this.saveTokenCache(this.accessToken, expiresIn); console.error('Access token refreshed successfully'); return this.accessToken; } else { throw new Error(`Token refresh failed: ${data.errmsg} (errcode: ${data.errcode})`); } } catch (error) { // 刷新失败时清除缓存 this.clearTokenCache(); if (axios.isAxiosError(error) && error.response) { throw new Error(`Failed to refresh access token: HTTP ${error.response.status} - ${JSON.stringify(error.response.data)}`); } else { throw new Error(`Failed to refresh access token: ${error.message}`); } } } /** * 设置请求处理器 */ setupHandlers() { // 列出可用工具 this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: this.tools.map(tool => ({ name: tool.name, description: tool.description, inputSchema: { type: 'object', properties: this.generateSchema(tool.args || []), required: (tool.args || []).filter(arg => arg.required).map(arg => arg.name) } })) }; }); // 执行工具调用 this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; return await this.executeTool(name, args || {}); }); } /** * 生成工具参数的JSON Schema */ generateSchema(args) { const schema = {}; args.forEach(arg => { schema[arg.name] = { type: arg.type, description: arg.description }; if (arg.type === 'array' && arg.items) { schema[arg.name].items = arg.items; } }); return schema; } /** * 执行工具调用 */ async executeTool(toolName, args) { const tool = this.tools.find(t => t.name === toolName); if (!tool) { return { content: [{ type: 'text', text: `Tool ${toolName} not found. Available tools: ${this.tools.map(t => t.name).join(', ')}` }], isError: true }; } try { // 获取有效的访问令牌(使用缓存机制) const accessToken = await this.getValidAccessToken(); if (!accessToken) { throw new Error('No access token available. Please set DINGTALK_ACCESS_TOKEN or DINGTALK_Client_ID/DINGTALK_Client_Secret'); } // 更新当前token this.accessToken = accessToken; // 构建请求 const url = this.buildUrl(tool.requestTemplate.url, args); const headers = this.buildHeaders(tool); const body = this.buildBody(tool, args); console.error(`Calling ${tool.requestTemplate.method} ${url}`); console.error(`Headers:`, headers); if (body) console.error(`Body:`, JSON.stringify(body, null, 2)); // 执行API调用 const response = await axios({ method: tool.requestTemplate.method, url: url, headers: headers, data: body, timeout: 30000 }); return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] }; } catch (error) { let errorMessage = error.message; if (axios.isAxiosError(error) && error.response) { // 如果是token相关错误,清除缓存 if (error.response.status === 401 || (error.response.data && error.response.data.errcode === 40014)) { console.error('Token authentication failed, clearing cache...'); this.clearTokenCache(); this.accessToken = undefined; } errorMessage = `API Error ${error.response.status}: ${JSON.stringify(error.response.data, null, 2)}`; } console.error('Tool execution error:', errorMessage); return { content: [{ type: 'text', text: `Error executing ${toolName}: ${errorMessage}` }], isError: true }; } } /** * 构建请求URL */ buildUrl(template, args) { let url = template; // 替换路径参数 Object.keys(args).forEach(key => { const regex = new RegExp(`\\{${key}\\}`, 'g'); if (regex.test(url)) { url = url.replace(regex, encodeURIComponent(args[key])); } }); // 处理查询参数 const [baseUrl, queryString] = url.split('?'); if (queryString) { const queryParams = new URLSearchParams(); // 解析模板中的查询参数 const templateParams = new URLSearchParams(queryString); for (const [key, value] of templateParams.entries()) { // 检查是否为模板占位符 if (value.includes('String') || value.includes('Long') || value.includes('Boolean') || value.includes('Integer')) { if (args[key] !== undefined) { queryParams.set(key, args[key]); } } else { queryParams.set(key, value); } } const finalQuery = queryParams.toString(); return finalQuery ? `${baseUrl}?${finalQuery}` : baseUrl; } // 特殊处理oapi.dingtalk.com接口,将access_token作为URL参数传递 if (url.includes('oapi.dingtalk.com') && this.accessToken) { const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}access_token=${encodeURIComponent(this.accessToken)}`; } return url; } /** * 构建请求头 */ buildHeaders(tool) { const headers = { 'Content-Type': 'application/json' }; // 只有对新版API (api.dingtalk.com) 才添加token到header if (tool.requestTemplate.url && !tool.requestTemplate.url.includes('oapi.dingtalk.com') && this.accessToken) { headers['x-acs-dingtalk-access-token'] = this.accessToken; } if (tool.requestTemplate.headers) { tool.requestTemplate.headers.forEach(header => { headers[header.key] = header.value; }); } return headers; } /** * 构建请求体 */ buildBody(tool, args) { if (tool.requestTemplate.method === 'GET' || tool.requestTemplate.method === 'DELETE') { return undefined; } const body = {}; (tool.args || []).forEach(arg => { if (arg.position === 'body' && args[arg.name] !== undefined) { body[arg.name] = args[arg.name]; } }); // 为searchDepartment添加默认的分页参数 if (tool.name === 'searchDepartment') { // 设置默认的offset为0 if (body.offset === undefined) { body.offset = 0; } // 设置默认的size为10 if (body.size === undefined) { body.size = 10; } console.error(`SearchDepartment API with params: offset=${body.offset}, size=${body.size}`); } return Object.keys(body).length > 0 ? body : undefined; } /** * 启动服务器 */ async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('DingTalk Department MCP server running on stdio'); } }