dingtalk-honorary-mcp
Version:
DingTalk Honor MCP Server - TypeScript implementation for AI assistants to manage enterprise honor system
328 lines • 13.3 kB
JavaScript
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