UNPKG

dingtalk-honorary-mcp

Version:

DingTalk Honor MCP Server - TypeScript implementation for AI assistants to manage enterprise honor system

328 lines 13.3 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 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 DingTalkHonorServer { server; accessToken; appId; appSecret; tokenCacheFile; tokenCacheData = null; tools = []; configPath; constructor(options = {}) { this.server = new Server({ name: 'dingtalk-honor', version: '1.0.0', }, { capabilities: { tools: {}, }, }); this.accessToken = options.accessToken || process.env.DINGTALK_ACCESS_TOKEN; this.appId = options.appId || process.env.DINGTALK_Client_ID; this.appSecret = options.appSecret || process.env.DINGTALK_Client_Secret; this.tokenCacheFile = options.tokenCacheFile || path.join(__dirname, '..', '.dingtalk_token_cache.json'); this.configPath = options.configPath || path.join(__dirname, '..', 'dingtalk_honor_mcp.yaml'); this.loadConfig(); this.loadTokenCache(); this.setupHandlers(); } loadConfig() { try { const config = yaml.load(fs.readFileSync(this.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} tools from config`); console.error(`Tools: ${this.tools.map(t => t.name).join(', ')}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error('Failed to load config:', errorMessage); this.tools = []; } } 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) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error('Failed to load token cache:', errorMessage); this.clearTokenCache(); } } isTokenCacheValid(cacheData) { if (!cacheData || !cacheData.access_token || !cacheData.expires_at) { return false; } const bufferTime = 5 * 60 * 1000; const now = Date.now(); return now < (cacheData.expires_at - bufferTime); } 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 }; 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) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error('Failed to save token cache:', errorMessage); } } clearTokenCache() { try { if (fs.existsSync(this.tokenCacheFile)) { fs.unlinkSync(this.tokenCacheFile); console.error('Token cache cleared'); } this.tokenCacheData = null; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error('Failed to clear token cache:', errorMessage); } } async getValidAccessToken() { if (process.env.DINGTALK_ACCESS_TOKEN) { return process.env.DINGTALK_ACCESS_TOKEN; } if (this.tokenCacheData && this.isTokenCacheValid(this.tokenCacheData)) { console.error('Using cached access token'); return this.tokenCacheData.access_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 }); if (response.data.errcode === 0) { const accessToken = response.data.access_token; this.accessToken = accessToken; const expiresIn = response.data.expires_in || 7200; this.saveTokenCache(accessToken, expiresIn); console.error('Access token refreshed successfully'); return accessToken; } else { throw new Error(`Token refresh failed: ${response.data.errmsg} (errcode: ${response.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 { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to refresh access token: ${errorMessage}`); } } } 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'); } 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)); 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) }], isError: false }; } catch (error) { let errorMessage = 'Unknown error'; if (axios.isAxiosError(error)) { 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)}`; } else if (error instanceof Error) { errorMessage = error.message; } 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(String(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, String(args[key])); } } else { queryParams.set(key, value); } } const finalQuery = queryParams.toString(); return finalQuery ? `${baseUrl}?${finalQuery}` : baseUrl; } 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' }; 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]; } }); return Object.keys(body).length > 0 ? body : undefined; } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('DingTalk Honor MCP server running on stdio'); } } //# sourceMappingURL=DingTalkHonorServer.js.map