// MCP 工具: 任务模块 (项目 / 看板列 / 任务) // 所有操作都以 token 绑定的用户身份执行, 复用任务域现有的权限与状态联动规则. import { z } from 'zod' import { prisma } from '../../db.js' import { ensurePersonalProject, createDefaultColumns, normTaskKinds, AUTO_ARCHIVE_OPTIONS, MAX_COLUMNS, DEFAULT_TASK_KINDS, } from '../../routes/projects.js' import { loadProjectForUser, loadTaskForUser, loadColumns, loadMemberIds } from '../../task-domain/access.js' import { resolveTaskWrite } from '../../task-domain/resolveStatus.js' import { copyTask } from '../../task-domain/copyTask.js' import { genUuid, withUuid, withTaskNumber, normProjectCode, allocProjectCode, } from '../../task-domain/identity.js' import { tool, reqStr, parseMarkDate } from '../helpers.js' const MAX_NAME = 50 const MAX_COLUMN_NAME = 20 const STATUSES = ['todo', 'doing', 'done'] const RETENTIONS = ['never', 'today', '3d', 'week', 'month', 'forever'] const TASK_INCLUDE = { column: true, assignee: { select: { id: true, name: true } }, creator: { select: { id: true, name: true } }, members: { include: { user: { select: { id: true, name: true } } } }, tags: true, children: { where: { archived: false }, include: { column: true, assignee: { select: { id: true, name: true } }, members: { include: { user: { select: { id: true, name: true } } } }, tags: true, }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }, } function shapeTask(t) { if (!t) return t const out = { ...t, members: (t.members || []).map((m) => ({ userId: m.userId, name: m.user?.name })), tags: (t.tags || []).map((tag) => tag.name), } if (Array.isArray(t.children)) out.children = t.children.map(shapeTask) return out } function shapeProject(p) { return { id: p.id, uuid: p.uuid, code: p.code, name: p.name, ownerId: p.ownerId, isPersonal: p.isPersonal, archived: p.archived, notes: Array.isArray(p.notes) ? p.notes : [], taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS, autoArchive: p.autoArchive, createdAt: p.createdAt, updatedAt: p.updatedAt, memberCount: p._count?.members, taskCount: p._count?.tasks, } } async function requireProject(projectId, userId) { const project = await loadProjectForUser(projectId, userId) if (!project) throw new Error('项目不存在或无权限') return project } function normalizeNotes(notes) { if (!Array.isArray(notes)) throw new Error('项目信息块格式非法') return notes.slice(0, 50).map((n) => ({ title: String(n?.title ?? '').slice(0, 50), content: String(n?.content ?? '').slice(0, 20000), })) } // ---------- 项目 ---------- function registerProjectTools(server, ctx) { const userId = ctx.userId tool(server, 'get_current_user', { title: '查询当前用户', description: '返回当前 MCP Token 绑定的用户信息, 用于确认操作身份。', inputSchema: {}, annotations: { readOnlyHint: true }, }, async () => ({ user: ctx.user || { id: userId } })) tool(server, 'list_projects', { title: '查询项目', description: '列出当前用户参与的全部项目 (含个人默认项目), 返回项目编号、名称、成员数、未归档任务数。', inputSchema: { includeArchived: z.boolean().optional().describe('是否包含已归档项目, 默认 false'), }, annotations: { readOnlyHint: true }, }, async ({ includeArchived = false }) => { await ensurePersonalProject(userId) const rows = await prisma.project.findMany({ where: { members: { some: { userId } }, ...(includeArchived ? {} : { archived: false }), }, include: { _count: { select: { members: true, tasks: { where: { archived: false } } } } }, orderBy: [{ archived: 'asc' }, { updatedAt: 'desc' }], }) return { items: rows.map(shapeProject) } }) tool(server, 'get_project', { title: '查询项目详情', description: '读取单个项目的完整信息: 基本信息、成员、看板列、信息块。', inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID') }, annotations: { readOnlyHint: true }, }, async ({ projectId }) => { const project = await requireProject(projectId, userId) const [members, columns] = await Promise.all([ prisma.projectMember.findMany({ where: { projectId: project.id }, include: { user: { select: { id: true, name: true, email: true } } }, orderBy: { createdAt: 'asc' }, }), loadColumns(project.id), ]) return { item: { ...shapeProject(project), members: members.map((m) => ({ userId: m.userId, name: m.user.name, email: m.user.email, permission: m.permission, })), columns, }, } }) tool(server, 'create_project', { title: '新建项目', description: '新建一个项目并自动创建默认看板列 (Plan/Doing/Action/Done), 创建者自动成为成员。', inputSchema: { name: z.string().describe('项目名称, 最多 50 字'), code: z.string().optional().describe('项目短编号 2~8 位字母数字, 不填则按名称自动分配'), }, }, async ({ name, code }) => { const cleanName = reqStr(name, '项目名称', MAX_NAME) let finalCode if (code !== undefined && String(code).trim() !== '') { finalCode = normProjectCode(code) if (!finalCode) throw new Error('项目编号须为 2~8 位字母数字') const dup = await prisma.project.findUnique({ where: { code: finalCode } }) if (dup) throw new Error(`项目编号 ${finalCode} 已被占用`) } else { finalCode = await allocProjectCode(cleanName) } const project = await prisma.project.create({ data: { name: cleanName, ownerId: userId, isPersonal: false, uuid: genUuid(), code: finalCode, members: { create: { uuid: genUuid(), userId } }, }, }) await createDefaultColumns(project.id) return { item: shapeProject(project) } }) tool(server, 'update_project', { title: '修改项目', description: '修改项目名称、短编号、归档状态、信息块、任务类型标签、自动归档档位。', inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID'), name: z.string().optional().describe('新项目名称, 最多 50 字'), code: z.string().optional().describe('新项目短编号 2~8 位字母数字'), archived: z.boolean().optional().describe('是否归档'), notes: z.array(z.object({ title: z.string().optional(), content: z.string().optional(), })).optional().describe('项目信息块, 整体替换'), taskKinds: z.array(z.string()).optional().describe('任务类型标签, 整体替换, 至少保留一个'), autoArchive: z.enum(AUTO_ARCHIVE_OPTIONS).optional().describe('自动归档: 3d/1w/1m/never'), }, }, async ({ projectId, name, code, archived, notes, taskKinds, autoArchive }) => { const project = await requireProject(projectId, userId) const data = {} if (name !== undefined) data.name = reqStr(name, '项目名称', MAX_NAME) if (code !== undefined) { const c = normProjectCode(code) if (!c) throw new Error('项目编号须为 2~8 位字母数字') if (c !== project.code) { const dup = await prisma.project.findUnique({ where: { code: c } }) if (dup) throw new Error(`项目编号 ${c} 已被占用`) data.code = c } } if (notes !== undefined) data.notes = normalizeNotes(notes) if (taskKinds !== undefined) { const kinds = normTaskKinds(taskKinds) if (!kinds?.length) throw new Error('至少保留一个任务类型') data.taskKinds = kinds } if (autoArchive !== undefined) data.autoArchive = autoArchive if (archived !== undefined) data.archived = !!archived const row = await prisma.project.update({ where: { id: project.id }, data }) return { item: shapeProject(row) } }) tool(server, 'delete_project', { title: '删除项目', description: '删除项目及其全部看板列和任务。个人默认项目不可删除。', inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID') }, annotations: { destructiveHint: true }, }, async ({ projectId }) => { const project = await requireProject(projectId, userId) if (project.isPersonal) throw new Error('个人默认项目不可删除') await prisma.project.delete({ where: { id: project.id } }) return { ok: true, deletedProjectId: project.id } }) tool(server, 'add_project_member', { title: '添加项目成员', description: '按用户 ID 或邮箱把用户加入项目。个人默认项目不可添加成员。', inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID'), userId: z.coerce.number().int().positive().optional().describe('目标用户 ID'), email: z.string().optional().describe('目标用户邮箱'), }, }, async ({ projectId, userId: targetId, email }) => { const project = await requireProject(projectId, userId) if (project.isPersonal) throw new Error('个人默认项目不可添加成员') if (!targetId && !email) throw new Error('userId 与 email 至少提供一个') const target = targetId ? await prisma.user.findUnique({ where: { id: Number(targetId) } }) : await prisma.user.findFirst({ where: { email: String(email).trim() } }) if (!target) throw new Error('用户不存在') const exist = await prisma.projectMember.findUnique({ where: { projectId_userId: { projectId: project.id, userId: target.id } }, }) if (exist) throw new Error('该用户已是项目成员') await prisma.projectMember.create({ data: { uuid: genUuid(), projectId: project.id, userId: target.id }, }) return { ok: true, member: { userId: target.id, name: target.name, email: target.email } } }) tool(server, 'remove_project_member', { title: '移除项目成员', description: '把成员移出项目; 成员本人也可用此工具退出项目。个人默认项目不可移除成员。', inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID'), userId: z.coerce.number().int().positive().describe('要移除的用户 ID'), }, annotations: { destructiveHint: true }, }, async ({ projectId, userId: targetId }) => { const project = await requireProject(projectId, userId) if (project.isPersonal) throw new Error('个人默认项目不可移除成员') const target = Number(targetId) if (target !== userId && project.ownerId !== userId) { throw new Error('只能移除自己,或由创建人移除他人') } await prisma.projectMember.deleteMany({ where: { projectId: project.id, userId: target } }) return { ok: true, removedUserId: target } }) } // ---------- 看板列 ---------- function registerColumnTools(server, ctx) { const userId = ctx.userId tool(server, 'list_columns', { title: '查询看板列', description: '列出项目全部看板列 (按显示顺序), 含绑定状态与完成保留选项。', inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID') }, annotations: { readOnlyHint: true }, }, async ({ projectId }) => { const project = await requireProject(projectId, userId) return { items: await loadColumns(project.id) } }) tool(server, 'create_column', { title: '新建看板列', description: `在项目末尾新建看板列, 最多 ${MAX_COLUMNS} 列。`, inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID'), name: z.string().describe('列名, 最多 20 字'), boundStatus: z.enum(STATUSES).nullable().optional().describe('绑定任务状态 todo/doing/done, null 表示不联动'), retention: z.enum(RETENTIONS).optional().describe('完成保留选项, 默认 forever'), }, }, async ({ projectId, name, boundStatus = null, retention = 'forever' }) => { const project = await requireProject(projectId, userId) const cleanName = reqStr(name, '列名', MAX_COLUMN_NAME) const cols = await loadColumns(project.id) if (cols.length >= MAX_COLUMNS) throw new Error(`最多 ${MAX_COLUMNS} 列`) if (cols.some((c) => c.name === cleanName)) throw new Error('列名已存在') if (boundStatus !== null && !STATUSES.includes(boundStatus)) throw new Error('绑定状态取值非法') if (!RETENTIONS.includes(retention)) throw new Error('保留选项取值非法') const row = await prisma.boardColumn.create({ data: { projectId: project.id, uuid: genUuid(), name: cleanName, sortOrder: cols.length ? Math.max(...cols.map((c) => c.sortOrder)) + 1 : 0, boundStatus, retention, }, }) return { item: row } }) tool(server, 'update_column', { title: '修改看板列', description: '修改列名、绑定状态或完成保留选项。', inputSchema: { columnId: z.coerce.number().int().positive().describe('看板列 ID'), name: z.string().optional().describe('新列名, 最多 20 字'), boundStatus: z.enum(STATUSES).nullable().optional().describe('绑定任务状态, null 表示取消绑定'), retention: z.enum(RETENTIONS).optional().describe('完成保留选项'), }, }, async ({ columnId, name, boundStatus, retention }) => { const col = await prisma.boardColumn.findUnique({ where: { id: Number(columnId) } }) if (!col) throw new Error('列不存在') const project = await requireProject(col.projectId, userId) const data = {} if (name !== undefined) { const t = reqStr(name, '列名', MAX_COLUMN_NAME) const dup = await prisma.boardColumn.findFirst({ where: { projectId: project.id, name: t, NOT: { id: col.id } }, }) if (dup) throw new Error('列名已存在') data.name = t } if (boundStatus !== undefined) { if (boundStatus !== null && !STATUSES.includes(boundStatus)) throw new Error('绑定状态取值非法') data.boundStatus = boundStatus } if (retention !== undefined) { if (!RETENTIONS.includes(retention)) throw new Error('保留选项取值非法') data.retention = retention } const row = await prisma.boardColumn.update({ where: { id: col.id }, data }) return { item: row } }) tool(server, 'delete_column', { title: '删除看板列', description: '删除看板列并把列内任务迁移到目标列 (不传则迁移到相邻列)。至少保留一列。', inputSchema: { columnId: z.coerce.number().int().positive().describe('看板列 ID'), moveToColumnId: z.coerce.number().int().positive().optional().describe('任务迁移目标列 ID'), }, annotations: { destructiveHint: true }, }, async ({ columnId, moveToColumnId }) => { const col = await prisma.boardColumn.findUnique({ where: { id: Number(columnId) } }) if (!col) throw new Error('列不存在') const project = await requireProject(col.projectId, userId) const cols = await loadColumns(project.id) if (cols.length <= 1) throw new Error('至少保留一列') let targetId = Number(moveToColumnId) || 0 if (!targetId) { const idx = cols.findIndex((c) => c.id === col.id) targetId = (cols[idx + 1] || cols[idx - 1]).id } const target = cols.find((c) => c.id === targetId) if (!target || target.id === col.id) throw new Error('迁移目标列非法') const tasks = await prisma.task.findMany({ where: { columnId: col.id } }) const newStatus = target.boundStatus || null await prisma.task.updateMany({ where: { columnId: col.id }, data: { columnId: target.id, ...(newStatus ? { status: newStatus } : {}) }, }) await prisma.boardColumn.delete({ where: { id: col.id } }) return { ok: true, moved: tasks.length, toColumnId: target.id } }) tool(server, 'reorder_columns', { title: '看板列排序', description: '按传入的列 ID 数组重排项目全部看板列, 数组顺序即新顺序。', inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID'), ids: z.array(z.coerce.number().int().positive()).describe('全部看板列 ID 的新顺序'), }, }, async ({ projectId, ids }) => { const project = await requireProject(projectId, userId) const cols = await loadColumns(project.id) const uniq = [...new Set(ids.map(Number))] if (uniq.length !== cols.length || uniq.length !== ids.length) { throw new Error('必须传入项目全部看板列, 且不能重复') } const colSet = new Set(cols.map((c) => c.id)) if (uniq.some((eid) => !colSet.has(eid))) throw new Error('包含不属于该项目的看板列') await prisma.$transaction( ids.map((eid, idx) => prisma.boardColumn.update({ where: { id: Number(eid) }, data: { sortOrder: idx } })), ) return { items: await loadColumns(project.id) } }) } // ---------- 任务 ---------- function registerTaskTools(server, ctx) { const userId = ctx.userId tool(server, 'list_tasks', { title: '查询任务', description: '查询项目任务列表 (默认只看未归档顶层任务, 含子任务), 支持按列、状态、关键词过滤。', inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID'), columnId: z.coerce.number().int().positive().optional().describe('只看某一列'), status: z.enum(STATUSES).optional().describe('只看某一状态'), keyword: z.string().optional().describe('标题关键词'), includeArchived: z.boolean().optional().describe('是否包含已归档任务, 默认 false'), limit: z.coerce.number().int().positive().max(500).optional().describe('最多返回条数, 默认 200'), }, annotations: { readOnlyHint: true }, }, async ({ projectId, columnId, status, keyword, includeArchived = false, limit = 200 }) => { const project = await requireProject(projectId, userId) const columns = await loadColumns(project.id) const where = { projectId: project.id, parentId: null } if (!includeArchived) where.archived = false if (columnId) where.columnId = Number(columnId) if (status) where.status = status if (keyword && String(keyword).trim()) where.title = { contains: String(keyword).trim() } const rows = await prisma.task.findMany({ where, include: TASK_INCLUDE, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], take: limit, }) const order = new Map(columns.map((c, i) => [c.id, i])) rows.sort((a, b) => ( (order.get(a.columnId) ?? 9999) - (order.get(b.columnId) ?? 9999) || a.sortOrder - b.sortOrder || a.id - b.id )) return { columns, items: rows.map(shapeTask), total: rows.length } }) tool(server, 'get_task', { title: '查询任务详情', description: '读取单个任务的完整信息, 含看板列、负责人、创建者、成员、标签和子任务。', inputSchema: { taskId: z.coerce.number().int().positive().describe('任务 ID') }, annotations: { readOnlyHint: true }, }, async ({ taskId }) => { const found = await loadTaskForUser(taskId, userId) if (!found) throw new Error('任务不存在或无权限') return { item: shapeTask(found.task) } }) tool(server, 'create_task', { title: '新建任务', description: '在项目中新建任务或子任务, 可指定列、状态、负责人、成员、标签、类型和时间标记。', inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID'), title: z.string().describe('任务标题, 最多 30 字'), description: z.string().optional().describe('任务描述'), columnId: z.coerce.number().int().positive().optional().describe('看板列 ID, 默认第一列'), status: z.enum(STATUSES).optional().describe('任务状态, 默认跟随看板列绑定'), parentId: z.coerce.number().int().positive().optional().describe('父任务 ID (仅一级子任务)'), assigneeId: z.coerce.number().int().positive().nullable().optional().describe('负责人用户 ID; 不传默认创建者, null 表示不指派'), memberIds: z.array(z.coerce.number().int().positive()).optional().describe('任务成员用户 ID 列表'), tags: z.array(z.string()).optional().describe('标签列表'), kind: z.string().optional().describe('任务类型标签, 最多 20 字'), markDate: z.string().nullable().optional().describe('时间标记 YYYY-MM-DD'), extras: z.array(z.object({ label: z.string().optional(), value: z.string().optional(), url: z.string().optional(), })).optional().describe('扩展信息卡片'), sortOrder: z.coerce.number().int().optional().describe('排序值, 不传则追加到列末尾'), }, }, async (args) => { const project = await requireProject(args.projectId, userId) const title = reqStr(args.title, '任务标题', 30) const columns = await loadColumns(project.id) if (!columns.length) throw new Error('项目没有可用的看板列') let pid = null if (args.parentId) { const parent = await prisma.task.findUnique({ where: { id: Number(args.parentId) } }) if (!parent) throw new Error('父任务不存在') if (parent.projectId !== project.id) throw new Error('子任务必须与父任务同项目') if (parent.parentId) throw new Error('仅支持一级子任务') pid = parent.id } let columnId = args.columnId ? Number(args.columnId) : columns[0].id let targetCol = columns.find((c) => c.id === columnId) if (!targetCol) throw new Error('看板列不存在') let status = args.status || targetCol.boundStatus || 'todo' if (args.status !== undefined) { const r = resolveTaskWrite( { status: targetCol.boundStatus || 'todo', columnId, parentId: pid, column: targetCol }, { status: args.status }, columns, ) status = r.status columnId = r.columnId targetCol = columns.find((c) => c.id === columnId) || targetCol } const memberIds = await loadMemberIds(project.id) const memberSet = new Set(memberIds) let finalAssignee = null if (args.assigneeId === undefined) finalAssignee = memberSet.has(userId) ? userId : null else if (args.assigneeId !== null && args.assigneeId !== '') { const aid = Number(args.assigneeId) if (!memberSet.has(aid)) throw new Error('负责人必须是项目成员') finalAssignee = aid } let sortOrder = args.sortOrder if (sortOrder === undefined) { const agg = await prisma.task.aggregate({ where: { projectId: project.id, columnId, parentId: pid }, _max: { sortOrder: true }, }) sortOrder = (agg._max.sortOrder ?? -1) + 1 } const taskData = { uuid: genUuid(), projectId: project.id, columnId, parentId: pid, title, description: String(args.description || ''), kind: args.kind ? String(args.kind).trim().slice(0, 20) : null, markDate: parseMarkDate(args.markDate), status, doneAt: status === 'done' ? new Date() : null, assigneeId: finalAssignee, creatorId: userId, extras: (Array.isArray(args.extras) ? args.extras : []).map((e) => ({ label: String(e?.label ?? '').slice(0, 40), value: String(e?.value ?? ''), url: e?.url ? String(e.url) : '', })), sortOrder, members: { create: withUuid((Array.isArray(args.memberIds) ? args.memberIds : []) .map(Number).filter((uid) => memberSet.has(uid)) .map((uid) => ({ userId: uid }))), }, tags: { create: withUuid((Array.isArray(args.tags) ? args.tags : []) .map((n) => String(n).trim()).filter(Boolean).slice(0, 10) .map((name) => ({ name: name.slice(0, 20) }))), }, } const { value: row } = await withTaskNumber(project.id, (number) => prisma.task.create({ data: { ...taskData, number }, include: TASK_INCLUDE }), ) return { item: shapeTask(row) } }) tool(server, 'update_task', { title: '修改任务', description: '修改任务标题、描述、列、状态、负责人、成员、标签、类型、时间标记、父任务或排序值。', inputSchema: { taskId: z.coerce.number().int().positive().describe('任务 ID'), title: z.string().optional().describe('新标题, 最多 30 字'), description: z.string().optional().describe('新描述'), columnId: z.coerce.number().int().positive().optional().describe('移动到看板列'), status: z.enum(STATUSES).optional().describe('任务状态'), assigneeId: z.coerce.number().int().positive().nullable().optional().describe('负责人用户 ID, null 取消指派'), memberIds: z.array(z.coerce.number().int().positive()).optional().describe('任务成员, 整体替换'), tags: z.array(z.string()).optional().describe('标签, 整体替换'), kind: z.string().nullable().optional().describe('任务类型标签, null 清空'), markDate: z.string().nullable().optional().describe('时间标记 YYYY-MM-DD, null 清空'), extras: z.array(z.object({ label: z.string().optional(), value: z.string().optional(), url: z.string().optional(), })).optional().describe('扩展信息, 整体替换'), sortOrder: z.coerce.number().int().optional().describe('排序值'), parentId: z.coerce.number().int().positive().nullable().optional().describe('父任务 ID, null 取消父子关系'), archived: z.boolean().optional().describe('归档/恢复任务'), }, }, async (args) => { const found = await loadTaskForUser(args.taskId, userId) if (!found) throw new Error('任务不存在或无权限') const { task, project } = found const columns = await loadColumns(project.id) const data = {} if (args.title !== undefined) data.title = reqStr(args.title, '任务标题', 30) if (args.description !== undefined) data.description = String(args.description) if (args.kind !== undefined) { const k = String(args.kind || '').trim().slice(0, 20) data.kind = k || null } if (args.markDate !== undefined) data.markDate = parseMarkDate(args.markDate) if (args.extras !== undefined) { data.extras = args.extras.map((e) => ({ label: String(e?.label ?? '').slice(0, 40), value: String(e?.value ?? ''), url: e?.url ? String(e.url) : '', })) } if (args.sortOrder !== undefined) data.sortOrder = Number(args.sortOrder) || 0 if (args.archived !== undefined) { data.archived = !!args.archived data.archivedAt = args.archived ? new Date() : null data.archiveSource = args.archived ? 'manual' : null } const memberIds = await loadMemberIds(project.id) const memberSet = new Set(memberIds) if (args.assigneeId !== undefined) { if (args.assigneeId === null || args.assigneeId === '') data.assigneeId = null else { const aid = Number(args.assigneeId) if (!memberSet.has(aid)) throw new Error('负责人必须是项目成员') data.assigneeId = aid } } const patch = {} if (args.columnId !== undefined) patch.columnId = Number(args.columnId) if (args.status !== undefined) patch.status = args.status if (patch.columnId !== undefined && !columns.some((c) => c.id === patch.columnId)) { throw new Error('看板列不存在') } let finalStatus = task.status if (Object.keys(patch).length) { const r = resolveTaskWrite(task, patch, columns) data.status = r.status data.columnId = r.columnId finalStatus = r.status } if (finalStatus === 'done' && task.status !== 'done') data.doneAt = new Date() else if (finalStatus !== 'done' && data.status !== undefined) data.doneAt = null if (Array.isArray(args.memberIds)) { const ids = args.memberIds.map(Number).filter((uid) => memberSet.has(uid)) await prisma.taskMember.deleteMany({ where: { taskId: task.id } }) if (ids.length) { await prisma.taskMember.createMany({ data: withUuid(ids.map((uid) => ({ taskId: task.id, userId: uid }))), }) } } if (Array.isArray(args.tags)) { await prisma.taskTag.deleteMany({ where: { taskId: task.id } }) const names = args.tags.map((n) => String(n).trim()).filter(Boolean).slice(0, 10) if (names.length) { await prisma.taskTag.createMany({ data: withUuid(names.map((name) => ({ taskId: task.id, name: name.slice(0, 20) }))), }) } } if (args.parentId !== undefined) { if (args.parentId === null) data.parentId = null else { const pid = Number(args.parentId) if (pid === task.id) throw new Error('不能成为自己的子任务') const parent = await prisma.task.findUnique({ where: { id: pid } }) if (!parent) throw new Error('目标任务不存在') if (parent.projectId !== task.projectId) throw new Error('子任务必须与父任务同项目') if (parent.parentId) throw new Error('仅支持一级子任务') const childCount = await prisma.task.count({ where: { parentId: task.id } }) if (childCount > 0) throw new Error('该任务已有子任务,不能成为子任务') data.parentId = pid } } let row = await prisma.task.update({ where: { id: task.id }, data, include: TASK_INCLUDE }) // 归档/恢复连同子任务, 与 archive_task / unarchive_task 口径一致 if (args.archived !== undefined) { const children = await prisma.task.findMany({ where: { parentId: task.id }, select: { id: true } }) if (children.length) { await prisma.task.updateMany({ where: { id: { in: children.map((c) => c.id) } }, data: { archived: !!args.archived, archivedAt: args.archived ? new Date() : null, archiveSource: args.archived ? 'manual' : null, }, }) } // 子任务状态变化后重取, 保证返回值与库内一致 row = await prisma.task.findUnique({ where: { id: task.id }, include: TASK_INCLUDE }) } return { item: shapeTask(row) } }) tool(server, 'delete_task', { title: '删除任务', description: '永久删除任务; 其子任务会一并级联删除。', inputSchema: { taskId: z.coerce.number().int().positive().describe('任务 ID') }, annotations: { destructiveHint: true }, }, async ({ taskId }) => { const found = await loadTaskForUser(taskId, userId) if (!found) throw new Error('任务不存在或无权限') const childCount = await prisma.task.count({ where: { parentId: found.task.id } }) await prisma.task.delete({ where: { id: found.task.id } }) return { ok: true, deletedSubtasks: childCount } }) tool(server, 'archive_task', { title: '归档任务', description: '归档任务并连带全部子任务; 已归档时重复调用无副作用。', inputSchema: { taskId: z.coerce.number().int().positive().describe('任务 ID') }, }, async ({ taskId }) => { const found = await loadTaskForUser(taskId, userId) if (!found) throw new Error('任务不存在或无权限') const children = await prisma.task.findMany({ where: { parentId: found.task.id }, select: { id: true } }) const r = await prisma.task.updateMany({ where: { id: { in: [found.task.id, ...children.map((c) => c.id)] } }, data: { archived: true, archivedAt: new Date(), archiveSource: 'manual' }, }) return { ok: true, count: r.count } }) tool(server, 'unarchive_task', { title: '恢复任务', description: '把归档任务恢复到原列, 状态不变; 连带恢复子任务。', inputSchema: { taskId: z.coerce.number().int().positive().describe('任务 ID') }, }, async ({ taskId }) => { const found = await loadTaskForUser(taskId, userId) if (!found) throw new Error('任务不存在或无权限') const children = await prisma.task.findMany({ where: { parentId: found.task.id }, select: { id: true } }) const r = await prisma.task.updateMany({ where: { id: { in: [found.task.id, ...children.map((c) => c.id)] } }, data: { archived: false, archivedAt: null, archiveSource: null }, }) return { ok: true, count: r.count } }) tool(server, 'reorder_tasks', { title: '任务排序', description: '按传入的任务 ID 数组重写排序 (数组顺序即新顺序)。传 columnId 时只重排该列内的任务。', inputSchema: { projectId: z.coerce.number().int().positive().describe('项目 ID'), ids: z.array(z.coerce.number().int().positive()).describe('任务 ID 的新顺序'), columnId: z.coerce.number().int().positive().optional().describe('仅重排该看板列内的任务'), }, }, async ({ projectId, ids, columnId }) => { const project = await requireProject(projectId, userId) const uniq = [...new Set(ids.map(Number))] if (!uniq.length) throw new Error('ids 不能为空') if (uniq.length !== ids.length) throw new Error('ids 不能重复') const where = { projectId: project.id, id: { in: uniq } } if (columnId) where.columnId = Number(columnId) else where.parentId = null const rows = await prisma.task.findMany({ where, select: { id: true } }) if (rows.length !== uniq.length) throw new Error('包含无效的任务或不属于指定列') await prisma.$transaction( ids.map((tid, idx) => prisma.task.update({ where: { id: Number(tid) }, data: { sortOrder: idx } })), ) return { ok: true, orderedIds: ids.map(Number) } }) tool(server, 'copy_task', { title: '拷贝任务', description: '把任务及子任务拷贝到另一个项目; 非目标项目成员会被自动丢弃。', inputSchema: { taskId: z.coerce.number().int().positive().describe('源任务 ID'), projectId: z.coerce.number().int().positive().describe('目标项目 ID'), }, }, async ({ taskId, projectId }) => { const found = await loadTaskForUser(taskId, userId) if (!found) throw new Error('任务不存在或无权限') const targetProject = await requireProject(projectId, userId) const targetColumns = await loadColumns(targetProject.id) if (!targetColumns.length) throw new Error('目标项目没有可用的看板列') const targetMemberIds = await loadMemberIds(targetProject.id) const sourceColumns = await loadColumns(found.project.id) const source = { ...found.task, __columns: sourceColumns } const result = await copyTask({ source, targetProject, targetColumns, operatorId: userId, targetMemberIds, }) const fresh = await prisma.task.findUnique({ where: { id: result.task.id }, include: TASK_INCLUDE }) return { item: shapeTask(fresh), dropped: result.dropped } }) } export function registerTaskToolsGroup(server, ctx) { registerProjectTools(server, ctx) registerColumnTools(server, ctx) registerTaskTools(server, ctx) }