UNPKG

dingtalk-checkin-mcp

Version:

DingTalk Checkin MCP Server - Check-in records management for AI assistants

425 lines (364 loc) 12.9 kB
#!/usr/bin/env node 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 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); class DingTalkCheckinMCPServer { constructor() { this.server = new Server( { name: 'dingtalk-checkin', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); 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.tokenCacheData = null; this.loadConfig(); this.loadTokenCache(); this.setupHandlers(); } loadConfig() { try { const configPath = path.join(__dirname, 'dingtalk_mcp_server.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} checkin 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(); } 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秒超时 }); if (response.data.errcode === 0) { this.accessToken = response.data.access_token; const expiresIn = response.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: ${response.data.errmsg} (errcode: ${response.data.errcode})`); } } catch (error) { // 刷新失败时清除缓存 this.clearTokenCache(); if (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 || {}); }); } 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 (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 = null; } 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 }; } } 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(); url = finalQuery ? `${baseUrl}?${finalQuery}` : baseUrl; } else { // 如果没有查询字符串,但有查询参数,添加它们 const queryParams = new URLSearchParams(); const tool = this.tools.find(t => t.requestTemplate.url === template); if (tool) { tool.args.forEach(arg => { if (arg.position === 'query' && args[arg.name] !== undefined) { queryParams.set(arg.name, args[arg.name]); } }); } const finalQuery = queryParams.toString(); if (finalQuery) { url = `${baseUrl}?${finalQuery}`; } } // 特殊处理oapi.dingtalk.com接口,将access_token作为URL参数传递 if (url.includes('oapi.dingtalk.com')) { 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')) { 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]; } }); return Object.keys(body).length > 0 ? body : undefined; } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('DingTalk Checkin MCP server running on stdio'); } } // 启动服务器 const server = new DingTalkCheckinMCPServer(); server.run().catch(error => { console.error('Server failed to start:', error); process.exit(1); }); // 导出类供测试使用 export { DingTalkCheckinMCPServer };