dingtalk-checkin-mcp
Version:
DingTalk Checkin MCP Server - Check-in records management for AI assistants
425 lines (364 loc) • 12.9 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);
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 };