feat: 桌面端添加MCP服务

This commit is contained in:
kuaifan 2025-10-24 23:48:18 +08:00
parent 0475e88dc2
commit eeaff08673
3 changed files with 426 additions and 3 deletions

10
electron/electron.js vendored
View File

@ -46,6 +46,7 @@ const utils = require('./lib/utils');
const config = require('./package.json');
const electronDown = require("./electron-down");
const electronMenu = require("./electron-menu");
const { startMCPServer } = require("./lib/mcp");
// 实例初始化
const userConf = new electronConf()
@ -73,6 +74,7 @@ let enableStoreBkp = true,
// 服务器配置
let serverPort = 22223,
mcpPort = 22224,
serverPublicDir = path.join(__dirname, 'public'),
serverUrl = "",
serverTimer = null;
@ -1141,11 +1143,11 @@ if (!getTheLock) {
app.on('ready', async () => {
isReady = true
isWin && app.setAppUserModelId(config.appId)
// 启动web服务
// 启动 Web 服务器
try {
await startWebServer()
} catch (error) {
dialog.showErrorBox('启动失败', `服务器启动失败:${error.message}`);
dialog.showErrorBox('启动失败', `Web 服务器启动失败:${error.message}`);
app.quit();
return;
}
@ -1157,6 +1159,8 @@ if (!getTheLock) {
preCreateChildWindow()
// 监听主题变化
monitorThemeChanges()
// 启动 MCP 服务器
startMCPServer(mainWindow, mcpPort)
// 创建托盘
if (['darwin', 'win32'].includes(process.platform) && utils.isJson(config.trayIcon)) {
mainTray = new Tray(path.join(__dirname, config.trayIcon[isDevelopMode ? 'dev' : 'prod'][process.platform === 'darwin' ? 'mac' : 'win']));
@ -1217,7 +1221,7 @@ app.on('before-quit', () => {
willQuitApp = true
})
app.on("will-quit",function(){
app.on("will-quit", () => {
globalShortcut.unregisterAll();
})

417
electron/lib/mcp.js vendored Normal file
View File

@ -0,0 +1,417 @@
/**
* DooTask MCP Server
*
* DooTask Electron 客户端集成了 Model Context Protocol (MCP) 服务
* 允许 AI 助手( Claude)直接与 DooTask 任务进行交互
*
* 提供的工具:
* 1. list_tasks - 获取任务列表支持按状态/项目/主任务筛选搜索分页
* 2. get_task - 获取任务详情包含负责人协助人员标签等完整信息
* 3. complete_task - 标记任务完成自动记录完成时间
* 4. uncomplete_task - 取消完成任务将已完成任务改为未完成
* 5. get_task_content - 获取任务的富文本描述内容
*
* 配置方法:
* 添加 DooTask MCP 服务器配置:
* {
* "mcpServers": {
* "DooTask": {
* "url": "http://localhost:22224/sse"
* }
* }
* }
*
* 使用示例:
* - "请帮我查看目前有哪些未完成的任务"
* - "任务 123 的详细信息是什么?"
* - "帮我把任务 789 标记为已完成"
*/
const { FastMCP } = require('fastmcp');
const { z } = require('zod');
const loger = require("electron-log");
let mcpServer = null;
class DooTaskMCP {
constructor(mainWindow) {
this.mainWindow = mainWindow;
this.mcp = new FastMCP({
name: 'DooTask MCP Server',
version: '1.0.0',
description: 'DooTask 任务管理 MCP 接口',
});
this.setupTools();
}
/**
* 调用接口
*/
async request(method, path, data = {}) {
try {
// 通过主窗口执行前端代码来调用API
if (!this.mainWindow || !this.mainWindow.webContents) {
throw new Error('Main window not available, please open DooTask application first');
}
// 检查 webContents 是否 ready
if (this.mainWindow.webContents.isLoading()) {
loger.warn(`[MCP] WebContents is loading, waiting...`);
await new Promise(resolve => {
this.mainWindow.webContents.once('did-finish-load', resolve);
});
}
// 通过前端已有的API调用机制添加超时处理
const executePromise = this.mainWindow.webContents.executeJavaScript(`
(async () => {
try {
// 检查API是否可用
if (typeof $A === 'undefined') {
return { error: 'API not available - $A is undefined' };
}
if (typeof $A.apiCall !== 'function') {
return { error: 'API not available - $A.apiCall is not a function' };
}
// 调用 API
const result = await $A.apiCall({
url: '${path}',
data: ${JSON.stringify(data)},
method: '${method}'
});
try {
return { data: JSON.parse(JSON.stringify(result.data)) };
} catch (serError) {
return { error: 'Result contains non-serializable data' };
}
} catch (error) {
return { error: error.msg || error.message || String(error) || 'API request failed' };
}
})()
`);
// 添加超时处理30秒
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout after 30 seconds')), 30000);
});
// 返回结果
const result = await Promise.race([executePromise, timeoutPromise]);
if (result && result.error) {
throw new Error(result.error);
}
return result;
} catch (error) {
throw error;
}
}
// 设置 MCP 工具
setupTools() {
// 1. 获取任务列表
this.mcp.addTool({
name: 'list_tasks',
description: '获取任务列表。可以按状态筛选(已完成/未完成)、搜索任务名称、按时间范围筛选等。',
parameters: z.object({
status: z.enum(['all', 'completed', 'uncompleted'])
.optional()
.describe('任务状态: all(所有), completed(已完成), uncompleted(未完成)'),
search: z.string()
.optional()
.describe('搜索关键词(可搜索任务ID、名称、描述)'),
time: z.string()
.optional()
.describe('时间范围: today(今天), week(本周), month(本月), year(今年), 自定义时间范围,如2025-12-12,2025-12-30'),
project_id: z.number()
.optional()
.describe('项目ID,只获取指定项目的任务'),
parent_id: z.number()
.optional()
.describe('主任务ID。大于0:获取该主任务的子任务; -1:仅获取主任务; 不传:所有任务'),
page: z.number()
.optional()
.describe('页码,默认1'),
pagesize: z.number()
.optional()
.describe('每页数量,默认20,最大100'),
}),
execute: async (params) => {
const requestData = {
page: params.page || 1,
pagesize: params.pagesize || 20,
};
// 构建 keys 对象用于筛选
const keys = {};
if (params.search) {
keys.name = params.search;
}
if (params.status && params.status !== 'all') {
keys.status = params.status;
}
if (Object.keys(keys).length > 0) {
requestData.keys = keys;
}
// 其他筛选参数
if (params.time !== undefined) {
requestData.time = params.time;
}
if (params.project_id !== undefined) {
requestData.project_id = params.project_id;
}
if (params.parent_id !== undefined) {
requestData.parent_id = params.parent_id;
}
const result = await this.request('GET', 'project/task/lists', requestData);
if (result.error) {
throw new Error(result.error);
}
// 格式化返回数据,使其更易读
const tasks = result.data.data.map(task => ({
id: task.id,
name: task.name,
status: task.complete_at ? '已完成' : '未完成',
complete_at: task.complete_at || '未完成',
project_id: task.project_id,
parent_id: task.parent_id,
sub_num: task.sub_num || 0,
sub_complete: task.sub_complete || 0,
percent: task.percent || 0,
created_at: task.created_at,
}));
return {
content: [{
type: 'text',
text: JSON.stringify({
total: result.data.total,
page: result.data.current_page,
pagesize: result.data.per_page,
tasks: tasks,
}, null, 2)
}]
};
}
});
// 2. 获取任务详情
this.mcp.addTool({
name: 'get_task',
description: '获取指定任务的详细信息,包括任务描述、负责人、协助人员、标签、时间等完整信息。',
parameters: z.object({
task_id: z.number()
.describe('任务ID'),
}),
execute: async (params) => {
const result = await this.request('GET', 'project/task/one', {
task_id: params.task_id,
});
if (result.error) {
throw new Error(result.error);
}
const task = result.data;
// 格式化任务详情
const taskDetail = {
id: task.id,
name: task.name,
desc: task.desc || '无描述',
status: task.complete_at ? '已完成' : '未完成',
complete_at: task.complete_at || '未完成',
project_id: task.project_id,
project_name: task.project_name,
column_id: task.column_id,
column_name: task.column_name,
parent_id: task.parent_id,
start_at: task.start_at,
end_at: task.end_at,
flow_item_id: task.flow_item_id,
flow_item_name: task.flow_item_name,
visibility: task.visibility === 1 ? '公开' : '指定人员',
owners: task.taskUser?.filter(u => u.owner === 1).map(u => u.userid) || [],
assistants: task.taskUser?.filter(u => u.owner === 0).map(u => u.userid) || [],
tags: task.taskTag?.map(t => t.name) || [],
created_at: task.created_at,
updated_at: task.updated_at,
};
return {
content: [{
type: 'text',
text: JSON.stringify(taskDetail, null, 2)
}]
};
}
});
// 3. 标记任务完成
this.mcp.addTool({
name: 'complete_task',
description: '将指定任务标记为已完成。注意:主任务必须在所有子任务完成后才能标记完成。',
parameters: z.object({
task_id: z.number()
.describe('要标记完成的任务ID'),
}),
execute: async (params) => {
// 使用当前时间标记完成
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
const result = await this.request('POST', 'project/task/update', {
task_id: params.task_id,
complete_at: now,
});
if (result.error) {
throw new Error(result.error);
}
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
message: '任务已标记为完成',
task_id: params.task_id,
complete_at: result.data.complete_at,
}, null, 2)
}]
};
}
});
// 4. 取消完成任务
this.mcp.addTool({
name: 'uncomplete_task',
description: '将已完成的任务标记为未完成。',
parameters: z.object({
task_id: z.number()
.describe('要标记为未完成的任务ID'),
}),
execute: async (params) => {
const result = await this.request('POST', 'project/task/update', {
task_id: params.task_id,
complete_at: false,
});
if (result.error) {
throw new Error(result.error);
}
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
message: '任务已标记为未完成',
task_id: params.task_id,
}, null, 2)
}]
};
}
});
// 5. 获取任务内容详情
this.mcp.addTool({
name: 'get_task_content',
description: '获取任务的详细内容描述(富文本内容)',
parameters: z.object({
task_id: z.number()
.describe('任务ID'),
}),
execute: async (params) => {
const result = await this.request('GET', 'project/task/content', {
task_id: params.task_id,
});
if (result.error) {
throw new Error(result.error);
}
return {
content: [{
type: 'text',
text: JSON.stringify({
task_id: params.task_id,
content: result.data.content || '无内容',
}, null, 2)
}]
};
}
});
}
// 启动 MCP 服务器
async start(port = 22224) {
try {
await this.mcp.start({
transportType: 'httpStream',
httpStream: {
port: port
}
});
} catch (error) {
throw error;
}
}
// 停止服务器
async stop() {
if (this.mcp && typeof this.mcp.stop === 'function') {
await this.mcp.stop();
}
}
}
/**
* 启动 MCP 服务器
*/
function startMCPServer(mainWindow, mcpPort) {
if (mcpServer) {
loger.info('MCP server already running');
return;
}
mcpServer = new DooTaskMCP(mainWindow);
mcpServer.start(mcpPort).then(() => {
loger.info(`DooTask MCP Server started on http://localhost:${mcpPort}/sse`);
}).catch((error) => {
loger.error('Failed to start MCP server:', error);
mcpServer = null;
});
}
/**
* 停止 MCP 服务器
*/
function stopMCPServer() {
if (!mcpServer) {
loger.info('MCP server is not running');
return;
}
mcpServer.stop().then(() => {
loger.info('MCP server stopped');
}).catch((error) => {
loger.error('Failed to stop MCP server:', error);
}).finally(() => {
mcpServer = null;
});
}
module.exports = {
DooTaskMCP,
startMCPServer,
stopMCPServer,
};

View File

@ -53,10 +53,12 @@
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",
"express": "^5.1.0",
"fastmcp": "^3.21.0",
"fs-extra": "^11.2.0",
"pdf-lib": "^1.17.1",
"request": "^2.88.2",
"tar": "^7.4.3",
"zod": "^3.23.8",
"yauzl": "^3.2.0"
},
"trayIcon": {