dingtalk-department-mcp
Version:
DingTalk Department Management MCP Server - TypeScript implementation for AI assistants
391 lines (390 loc) • 14.8 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 * 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');
}
}