From 342e8725bd17c6f47522fe9259edb25b11ea44e1 Mon Sep 17 00:00:00 2001 From: kuaifan Date: Mon, 27 Oct 2025 06:30:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20MCP=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E9=85=8D=E7=BD=AE=E5=92=8C=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/lib/mcp.js | 648 +++++++++++++++++- .../js/pages/manage/components/MCPHelper.vue | 19 +- 2 files changed, 641 insertions(+), 26 deletions(-) diff --git a/electron/lib/mcp.js b/electron/lib/mcp.js index 716efe8d9..ceae8536c 100644 --- a/electron/lib/mcp.js +++ b/electron/lib/mcp.js @@ -4,24 +4,37 @@ * DooTask 的 Electron 客户端集成了 Model Context Protocol (MCP) 服务, * 允许 AI 助手(如 Claude)直接与 DooTask 任务进行交互。 * - * 提供的工具(共 7 个): + * 提供的工具(共 15 个): + * + * === 用户管理 === + * - get_users_basic - 根据用户ID列表获取基础信息,便于匹配负责人/协助人 + * - search_user - 按关键字或项目筛选用户,支持分页与更多过滤项 * * === 任务管理 === - * 1. list_tasks - 获取任务列表,支持按状态/项目/主任务筛选、搜索、分页 - * 2. get_task - 获取任务详情,包含完整内容、负责人、协助人员、标签等所有信息 - * 3. complete_task - 快速标记任务完成 - * 4. create_task - 创建新任务 - * 5. update_task - 更新任务,支持修改名称、内容、负责人、时间、状态等所有属性 + * - list_tasks - 获取任务列表,支持按状态/项目/主任务筛选、搜索、分页 + * - get_task - 获取任务详情,包含完整内容、负责人、协助人员、标签等所有信息 + * - complete_task - 快速标记任务完成 + * - create_task - 创建新任务 + * - update_task - 更新任务,支持修改名称、内容、负责人、时间、状态等所有属性 + * - create_sub_task - 为指定主任务创建子任务 + * - get_task_files - 获取任务附件列表 + * - delete_task - 删除或还原任务 * * === 项目管理 === - * 6. list_projects - 获取项目列表,支持按归档状态筛选、搜索 - * 7. get_project - 获取项目详情,包含列(看板列)、成员等完整信息 + * - list_projects - 获取项目列表,支持按归档状态筛选、搜索 + * - get_project - 获取项目详情,包含列(看板列)、成员等完整信息 + * - create_project - 创建新项目 + * - update_project - 修改项目信息(名称、描述等) + * + * === 消息通知 === + * - send_message_to_user - 给指定用户发送私信 + * - get_message_list - 获取对话消息或执行关键词搜索 * * 配置方法: * { * "mcpServers": { * "DooTask": { - * "url": "http://localhost:22224/sse" + * "url": "http://localhost:22224/mcp" * } * } * } @@ -125,7 +138,161 @@ class DooTaskMCP { // 设置 MCP 工具 setupTools() { - // 1. 获取任务列表 + // 用户管理:获取用户基础信息 + this.mcp.addTool({ + name: 'get_users_basic', + description: '根据用户ID列表获取用户基础信息(昵称、邮箱、头像等),方便在分配任务前确认成员身份。', + parameters: z.object({ + userids: z.array(z.number()) + .min(1) + .max(50) + .describe('用户ID数组,至少1个,最多50个'), + }), + execute: async (params) => { + const ids = params.userids; + const requestData = { + userid: ids.length === 1 ? ids[0] : JSON.stringify(ids), + }; + + const result = await this.request('GET', 'users/basic', requestData); + + if (result.error) { + throw new Error(result.error); + } + + const rawList = Array.isArray(result.data) + ? result.data + : (Array.isArray(result.data?.data) ? result.data.data : []); + + const users = rawList.map(user => ({ + userid: user.userid, + nickname: user.nickname || '', + email: user.email || '', + avatar: user.avatar || '', + identity: user.identity || '', + department: user.department || '', + })); + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + count: users.length, + users: users, + }, null, 2) + }] + }; + } + }); + + // 用户管理:搜索用户 + this.mcp.addTool({ + name: 'search_user', + description: '按关键词搜索或筛选用户,支持按项目/对话过滤并返回分页结果。', + parameters: z.object({ + keyword: z.string() + .min(1) + .describe('搜索关键词,支持昵称、邮箱、拼音等'), + project_id: z.number() + .optional() + .describe('仅返回指定项目的成员'), + dialog_id: z.number() + .optional() + .describe('仅返回指定对话的成员'), + include_disabled: z.boolean() + .optional() + .describe('是否同时包含已离职/禁用用户'), + include_bot: z.boolean() + .optional() + .describe('是否同时包含机器人账号'), + with_department: z.boolean() + .optional() + .describe('是否返回部门信息'), + page: z.number() + .optional() + .describe('页码,默认1'), + pagesize: z.number() + .optional() + .describe('每页数量,默认20,最大100'), + }), + execute: async (params) => { + const page = params.page && params.page > 0 ? params.page : 1; + const pagesize = params.pagesize && params.pagesize > 0 ? Math.min(params.pagesize, 100) : 20; + + const keys = { + key: params.keyword, + }; + + if (params.project_id !== undefined) { + keys.project_id = params.project_id; + } + if (params.dialog_id !== undefined) { + keys.dialog_id = params.dialog_id; + } + if (params.include_disabled) { + keys.disable = 2; + } + if (params.include_bot) { + keys.bot = 2; + } + + const requestData = { + page, + pagesize, + keys, + }; + + if (params.with_department) { + requestData.with_department = 1; + } + + const result = await this.request('GET', 'users/search', requestData); + + if (result.error) { + throw new Error(result.error); + } + + const data = result.data || {}; + let users = []; + let total = 0; + let perPage = pagesize; + let currentPage = page; + + if (Array.isArray(data.data)) { + users = data.data; + total = data.total ?? users.length; + perPage = data.per_page ?? perPage; + currentPage = data.current_page ?? currentPage; + } else if (Array.isArray(data)) { + users = data; + total = users.length; + } + + const simplified = users.map(user => ({ + userid: user.userid, + nickname: user.nickname || '', + email: user.email || '', + tags: user.tags || [], + department: user.department_info || user.department || '', + online: user.online ?? null, + identity: user.identity || '', + })); + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + total, + page: currentPage, + pagesize: perPage, + users: simplified, + }, null, 2) + }] + }; + } + }); + + // 获取任务列表 this.mcp.addTool({ name: 'list_tasks', description: '获取任务列表。可以按状态筛选(已完成/未完成)、搜索任务名称、按时间范围筛选等。', @@ -223,7 +390,7 @@ class DooTaskMCP { } }); - // 2. 获取任务详情 + // 获取任务详情 this.mcp.addTool({ name: 'get_task', description: '获取指定任务的详细信息,包括任务描述、完整内容、负责人、协助人员、标签、时间等所有信息。', @@ -299,7 +466,7 @@ class DooTaskMCP { } }); - // 3. 标记任务完成 + // 标记任务完成 this.mcp.addTool({ name: 'complete_task', description: '快速标记任务完成(自动使用当前时间)。如需指定完成时间或取消完成,请使用 update_task。注意:主任务必须在所有子任务完成后才能标记完成。', @@ -333,7 +500,7 @@ class DooTaskMCP { } }); - // 4. 创建任务 + // 创建任务 this.mcp.addTool({ name: 'create_task', description: '创建新任务。可以指定任务名称、内容、负责人、时间等信息。', @@ -402,7 +569,7 @@ class DooTaskMCP { } }); - // 5. 更新任务(完整版) + // 更新任务 this.mcp.addTool({ name: 'update_task', description: '更新任务信息。可以修改任务名称、内容、负责人、时间、状态等所有属性。', @@ -486,7 +653,131 @@ class DooTaskMCP { } }); - // 6. 获取项目列表 + // 创建子任务 + this.mcp.addTool({ + name: 'create_sub_task', + description: '为指定主任务新增子任务,自动继承主任务所属项目与看板列配置。', + parameters: z.object({ + task_id: z.number() + .describe('主任务ID'), + name: z.string() + .min(1) + .describe('子任务名称'), + }), + execute: async (params) => { + const result = await this.request('GET', 'project/task/addsub', { + task_id: params.task_id, + name: params.name, + }); + + if (result.error) { + throw new Error(result.error); + } + + const subTask = result.data || {}; + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + success: true, + sub_task: { + id: subTask.id, + name: subTask.name, + project_id: subTask.project_id, + parent_id: subTask.parent_id, + column_id: subTask.column_id, + start_at: subTask.start_at, + end_at: subTask.end_at, + created_at: subTask.created_at, + } + }, null, 2) + }] + }; + } + }); + + // 获取任务附件 + this.mcp.addTool({ + name: 'get_task_files', + description: '获取指定任务的附件列表,包含文件名称、大小、下载地址等信息。', + parameters: z.object({ + task_id: z.number() + .describe('任务ID'), + }), + execute: async (params) => { + const result = await this.request('GET', 'project/task/files', { + task_id: params.task_id, + }); + + if (result.error) { + throw new Error(result.error); + } + + const files = Array.isArray(result.data) ? result.data : []; + + const normalized = files.map(file => ({ + id: file.id, + name: file.name, + ext: file.ext, + size: file.size, + url: file.path, + thumb: file.thumb, + userid: file.userid, + download: file.download, + created_at: file.created_at, + })); + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + task_id: params.task_id, + files: normalized, + }, null, 2) + }] + }; + } + }); + + // 删除或还原任务 + this.mcp.addTool({ + name: 'delete_task', + description: '删除或还原任务。默认执行删除,可通过 action=recovery 将任务从回收站恢复。', + parameters: z.object({ + task_id: z.number() + .describe('任务ID'), + action: z.enum(['delete', 'recovery']) + .optional() + .describe('操作类型:delete(默认) 删除,recovery 还原'), + }), + execute: async (params) => { + const action = params.action || 'delete'; + + const result = await this.request('GET', 'project/task/remove', { + task_id: params.task_id, + type: action, + }); + + if (result.error) { + throw new Error(result.error); + } + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + success: true, + action: action, + task_id: params.task_id, + data: result.data, + }, null, 2) + }] + }; + } + }); + + // 获取项目列表 this.mcp.addTool({ name: 'list_projects', description: '获取项目列表。可以按归档状态筛选、搜索项目名称等。', @@ -548,7 +839,7 @@ class DooTaskMCP { } }); - // 7. 获取项目详情 + // 获取项目详情 this.mcp.addTool({ name: 'get_project', description: '获取指定项目的详细信息,包括项目的列(看板列)、成员等完整信息。', @@ -597,6 +888,328 @@ class DooTaskMCP { }; } }); + + // 创建项目 + this.mcp.addTool({ + name: 'create_project', + description: '创建新项目,可选设置项目描述、初始化列及流程状态。', + parameters: z.object({ + name: z.string() + .min(2) + .describe('项目名称,至少2个字符'), + desc: z.string() + .optional() + .describe('项目描述'), + columns: z.union([z.string(), z.array(z.string())]) + .optional() + .describe('初始化列名称,字符串使用逗号分隔,也可直接传字符串数组'), + flow: z.enum(['open', 'close']) + .optional() + .describe('是否开启流程,open/close,默认close'), + personal: z.boolean() + .optional() + .describe('是否创建个人项目,仅支持创建一个个人项目'), + }), + execute: async (params) => { + const requestData = { + name: params.name, + }; + + if (params.desc !== undefined) { + requestData.desc = params.desc; + } + if (params.columns !== undefined) { + requestData.columns = Array.isArray(params.columns) ? params.columns.join(',') : params.columns; + } + if (params.flow !== undefined) { + requestData.flow = params.flow; + } + if (params.personal !== undefined) { + requestData.personal = params.personal ? 1 : 0; + } + + const result = await this.request('GET', 'project/add', requestData); + + if (result.error) { + throw new Error(result.error); + } + + const project = result.data || {}; + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + success: true, + project: { + id: project.id, + name: project.name, + desc: project.desc || '', + columns: project.projectColumn || [], + created_at: project.created_at, + } + }, null, 2) + }] + }; + } + }); + + // 更新项目 + this.mcp.addTool({ + name: 'update_project', + description: '修改项目信息(名称、描述、归档策略等)。若未传 name 将自动沿用项目当前名称。', + parameters: z.object({ + project_id: z.number() + .describe('项目ID'), + name: z.string() + .optional() + .describe('项目名称'), + desc: z.string() + .optional() + .describe('项目描述'), + archive_method: z.string() + .optional() + .describe('归档方式'), + archive_days: z.number() + .optional() + .describe('自动归档天数'), + }), + execute: async (params) => { + const requestData = { + project_id: params.project_id, + }; + + if (params.name && params.name.trim().length > 0) { + requestData.name = params.name; + } else { + const projectResult = await this.request('GET', 'project/one', { + project_id: params.project_id, + }); + + if (projectResult.error) { + throw new Error(projectResult.error); + } + + const currentName = projectResult.data?.name; + if (!currentName) { + throw new Error('无法获取项目名称,请手动提供 name 参数'); + } + requestData.name = currentName; + } + + if (params.desc !== undefined) { + requestData.desc = params.desc; + } + if (params.archive_method !== undefined) { + requestData.archive_method = params.archive_method; + } + if (params.archive_days !== undefined) { + requestData.archive_days = params.archive_days; + } + + const result = await this.request('GET', 'project/update', requestData); + + if (result.error) { + throw new Error(result.error); + } + + const project = result.data || {}; + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + success: true, + project: { + id: project.id, + name: project.name, + desc: project.desc || '', + archived_at: project.archived_at || null, + archive_method: project.archive_method ?? requestData.archive_method ?? null, + archive_days: project.archive_days ?? requestData.archive_days ?? null, + updated_at: project.updated_at, + } + }, null, 2) + }] + }; + } + }); + + // 发送消息给用户 + this.mcp.addTool({ + name: 'send_message_to_user', + description: '给指定用户发送私信,可选择 Markdown 或 HTML 格式,并支持静默发送。', + parameters: z.object({ + user_id: z.number() + .describe('接收方用户ID'), + text: z.string() + .min(1) + .describe('消息内容'), + text_type: z.enum(['md', 'html']) + .optional() + .describe('消息类型,默认md,可选md/html'), + silence: z.boolean() + .optional() + .describe('是否静默发送(不触发提醒)'), + }), + execute: async (params) => { + const dialogResult = await this.request('GET', 'dialog/open/user', { + userid: params.user_id, + }); + + if (dialogResult.error) { + throw new Error(dialogResult.error); + } + + const dialogData = dialogResult.data || {}; + const dialogId = dialogData.id; + + if (!dialogId) { + throw new Error('未能获取会话ID,无法发送消息'); + } + + const payload = { + dialog_id: dialogId, + text: params.text, + }; + + if (params.text_type) { + payload.text_type = params.text_type; + } else { + payload.text_type = 'md'; + } + if (params.silence !== undefined) { + payload.silence = params.silence ? 'yes' : 'no'; + } + + const sendResult = await this.request('POST', 'dialog/msg/sendtext', payload); + + if (sendResult.error) { + throw new Error(sendResult.error); + } + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + success: true, + dialog_id: dialogId, + data: sendResult.data, + }, null, 2) + }] + }; + } + }); + + // 获取消息列表或搜索消息 + this.mcp.addTool({ + name: 'get_message_list', + description: '获取指定对话的消息列表,或按关键字搜索消息位置/内容。', + parameters: z.object({ + dialog_id: z.number() + .optional() + .describe('对话ID,获取消息列表时必填'), + keyword: z.string() + .optional() + .describe('搜索关键词,提供时执行消息搜索'), + msg_id: z.number() + .optional() + .describe('围绕某条消息加载相关内容'), + position_id: z.number() + .optional() + .describe('以position_id为中心加载消息'), + prev_id: z.number() + .optional() + .describe('获取此消息之前的历史'), + next_id: z.number() + .optional() + .describe('获取此消息之后的新消息'), + msg_type: z.enum(['tag', 'todo', 'link', 'text', 'image', 'file', 'record', 'meeting']) + .optional() + .describe('按消息类型筛选'), + take: z.number() + .optional() + .describe('获取条数,列表模式最大100,搜索模式受接口限制'), + }), + execute: async (params) => { + const keyword = params.keyword?.trim(); + + if (keyword) { + const searchPayload = { + key: keyword, + }; + if (params.dialog_id) { + searchPayload.dialog_id = params.dialog_id; + } + if (params.take && params.take > 0) { + const takeValue = params.take; + searchPayload.take = params.dialog_id + ? Math.min(takeValue, 200) + : Math.min(takeValue, 50); + } + + const searchResult = await this.request('GET', 'dialog/msg/search', searchPayload); + + if (searchResult.error) { + throw new Error(searchResult.error); + } + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + mode: params.dialog_id ? 'position_search' : 'global_search', + keyword: keyword, + dialog_id: params.dialog_id || null, + data: searchResult.data, + }, null, 2) + }] + }; + } + + if (!params.dialog_id) { + throw new Error('请提供 dialog_id 以获取消息列表,或提供 keyword 执行搜索'); + } + + const requestData = { + dialog_id: params.dialog_id, + }; + + if (params.msg_id !== undefined) requestData.msg_id = params.msg_id; + if (params.position_id !== undefined) requestData.position_id = params.position_id; + if (params.prev_id !== undefined) requestData.prev_id = params.prev_id; + if (params.next_id !== undefined) requestData.next_id = params.next_id; + if (params.msg_type !== undefined) requestData.msg_type = params.msg_type; + if (params.take !== undefined) { + const takeValue = params.take > 0 ? params.take : 1; + requestData.take = Math.min(takeValue, 100); + } + + const result = await this.request('GET', 'dialog/msg/list', requestData); + + if (result.error) { + throw new Error(result.error); + } + + const data = result.data || {}; + const messages = Array.isArray(data.list) ? data.list : []; + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + dialog_id: params.dialog_id, + count: messages.length, + time: data.time, + dialog: data.dialog, + top: data.top, + todo: data.todo, + messages: messages, + }, null, 2) + }] + }; + } + }); } // 启动 MCP 服务器 @@ -632,7 +1245,8 @@ function startMCPServer(mainWindow, mcpPort) { mcpServer = new DooTaskMCP(mainWindow); mcpServer.start(mcpPort).then(() => { - loger.info(`DooTask MCP Server started on http://localhost:${mcpPort}/sse`); + loger.info(`MCP Server started on http://localhost:${mcpPort}/mcp`); + loger.info(`Legacy SSE endpoint also available at http://localhost:${mcpPort}/sse`); }).catch((error) => { loger.error('Failed to start MCP server:', error); mcpServer = null; diff --git a/resources/assets/js/pages/manage/components/MCPHelper.vue b/resources/assets/js/pages/manage/components/MCPHelper.vue index e43144599..7b943611f 100644 --- a/resources/assets/js/pages/manage/components/MCPHelper.vue +++ b/resources/assets/js/pages/manage/components/MCPHelper.vue @@ -4,7 +4,7 @@ {{ $L('MCP 服务器已启动成功!') }} - {{ $L('服务地址') }}: http://localhost:22224/sse + {{ $L('服务地址') }}: {{ mcpConfig.mcpServers.DooTask.url }} @@ -12,7 +12,7 @@

🔗 {{ $L('接入配置') }}

{{ $L('以接入 Claude 为例,在配置文件中添加以下配置') }}:

-
{{ mcpConfig }}
+
{{ JSON.stringify(mcpConfig, null, 2) }}
@@ -27,6 +27,7 @@
  • "{{ $L("在项目1中创建任务:完成用户手册") }}"
  • "{{ $L("把任务789的截止时间改为下周五") }}"
  • "{{ $L("我有哪些项目?") }}"
  • +
  • "{{ $L("查看项目5的详情,包括所有列和成员") }}"
  • @@ -142,13 +143,13 @@ export default { }, data() { return { - mcpConfig: `{ - "mcpServers": { - "DooTask": { - "url": "http://localhost:22224/sse" - } - } -}` + mcpConfig: { + mcpServers: { + DooTask: { + url: "http://localhost:22224/mcp" + } + } + } } }, computed: {