- 新增 /api/mcp (Streamable HTTP) 与 41 个工具: 项目/看板列/任务/清单/打卡; - 新增 McpToken 模型与迁移, DB 仅存 SHA-256 哈希, token 绑定用户; - 新增 /api/mcp-tokens 增删改查, 支持独立命名 (如 R1); - 「我的」页新增 AI 接入 (MCP) 区块: 地址复制/新建/一次性展示/删除; - 补充 docs/MCP.md 与样式
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
// MCP 工具: 打卡 (打卡项 / 打卡记录)
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../../db.js'
|
||||
import { toDateStr, addDays, periodRange, PERIODS } from '../../checkin/period.js'
|
||||
import { tool, reqStr } from '../helpers.js'
|
||||
|
||||
const BACKFILL_DAYS = 7
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
function normDate(v) {
|
||||
const s = String(v || '')
|
||||
return DATE_RE.test(s) ? s : toDateStr()
|
||||
}
|
||||
|
||||
function groupCounts(records) {
|
||||
const map = new Map()
|
||||
for (const r of records) {
|
||||
if (!map.has(r.habitId)) map.set(r.habitId, new Map())
|
||||
const byDate = map.get(r.habitId)
|
||||
byDate.set(r.date, (byDate.get(r.date) || 0) + 1)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function sumRange(byDate, start, end) {
|
||||
let n = 0
|
||||
let cursor = start
|
||||
while (cursor <= end) {
|
||||
n += byDate.get(cursor) || 0
|
||||
cursor = addDays(cursor, 1)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
export function registerCheckinTools(server, ctx) {
|
||||
const userId = ctx.userId
|
||||
|
||||
tool(server, 'list_habits', {
|
||||
title: '查询打卡项',
|
||||
description: '列出打卡项及其指定日期所在周期的完成进度; 默认只返回未归档项。',
|
||||
inputSchema: {
|
||||
date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'),
|
||||
includeArchived: z.boolean().optional().describe('是否包含已归档打卡项, 默认 false'),
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async ({ date, includeArchived = false }) => {
|
||||
const today = toDateStr()
|
||||
const day = normDate(date)
|
||||
const habits = await prisma.habit.findMany({
|
||||
where: { userId, ...(includeArchived ? {} : { archived: false }) },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
if (!habits.length) return { date: day, today, items: [] }
|
||||
|
||||
const ranges = new Map(habits.map((h) => [h.id, periodRange(h.period, day)]))
|
||||
let min = null
|
||||
let max = null
|
||||
for (const r of ranges.values()) {
|
||||
if (!min || r.start < min) min = r.start
|
||||
if (!max || r.end > max) max = r.end
|
||||
}
|
||||
const records = await prisma.checkIn.findMany({
|
||||
where: {
|
||||
userId,
|
||||
habitId: { in: habits.map((h) => h.id) },
|
||||
date: { gte: min, lte: max },
|
||||
},
|
||||
select: { habitId: true, date: true },
|
||||
})
|
||||
const grouped = groupCounts(records)
|
||||
|
||||
const items = habits.map((h) => {
|
||||
const range = ranges.get(h.id)
|
||||
const byDate = grouped.get(h.id) || new Map()
|
||||
const periodDone = sumRange(byDate, range.start, range.end)
|
||||
const target = Math.max(1, h.target)
|
||||
return {
|
||||
...h,
|
||||
target,
|
||||
periodStart: range.start,
|
||||
periodEnd: range.end,
|
||||
periodDone,
|
||||
completed: periodDone >= target,
|
||||
countOnDate: byDate.get(day) || 0,
|
||||
}
|
||||
})
|
||||
return { date: day, today, items }
|
||||
})
|
||||
|
||||
tool(server, 'create_habit', {
|
||||
title: '新建打卡项',
|
||||
description: '新建一个周期性打卡目标, 例如每天喝 8 杯水、每周运动 3 次。',
|
||||
inputSchema: {
|
||||
name: z.string().describe('打卡项名称, 最多 20 字'),
|
||||
period: z.enum(PERIODS).optional().describe('周期: day/week/month/year, 默认 day'),
|
||||
target: z.coerce.number().int().min(1).optional().describe('每周期目标次数, 默认 1'),
|
||||
color: z.string().optional().describe('颜色, 默认 #1677ff'),
|
||||
icon: z.string().optional().describe('图标 (emoji 或图标名)'),
|
||||
note: z.string().optional().describe('备注'),
|
||||
},
|
||||
}, async ({ name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' }) => {
|
||||
const cleanName = reqStr(name, '名称', 20)
|
||||
if (!PERIODS.includes(period)) throw new Error('周期类型不合法')
|
||||
const t = Math.floor(Number(target))
|
||||
if (!Number.isFinite(t) || t < 1) throw new Error('目标次数需为 ≥1 的整数')
|
||||
|
||||
const last = await prisma.habit.findFirst({
|
||||
where: { userId }, orderBy: { sortOrder: 'desc' }, select: { sortOrder: true },
|
||||
})
|
||||
const row = await prisma.habit.create({
|
||||
data: {
|
||||
userId,
|
||||
name: cleanName,
|
||||
period,
|
||||
target: t,
|
||||
color: String(color),
|
||||
icon: String(icon),
|
||||
note: String(note || ''),
|
||||
sortOrder: (last?.sortOrder ?? -1) + 1,
|
||||
},
|
||||
})
|
||||
return { item: row }
|
||||
})
|
||||
|
||||
tool(server, 'update_habit', {
|
||||
title: '修改打卡项',
|
||||
description: '修改打卡项名称、周期、目标次数、颜色、图标、备注、归档状态或排序值。',
|
||||
inputSchema: {
|
||||
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
||||
name: z.string().optional().describe('新名称, 最多 20 字'),
|
||||
period: z.enum(PERIODS).optional().describe('新周期'),
|
||||
target: z.coerce.number().int().min(1).optional().describe('新目标次数'),
|
||||
color: z.string().optional().describe('新颜色'),
|
||||
icon: z.string().optional().describe('新图标'),
|
||||
note: z.string().optional().describe('新备注'),
|
||||
archived: z.boolean().optional().describe('是否归档'),
|
||||
sortOrder: z.coerce.number().int().optional().describe('排序值'),
|
||||
},
|
||||
}, async ({ habitId, name, period, target, color, icon, note, archived, sortOrder }) => {
|
||||
const exist = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
||||
if (!exist) throw new Error('打卡项不存在')
|
||||
const data = {}
|
||||
if (name !== undefined) data.name = reqStr(name, '名称', 20)
|
||||
if (period !== undefined) {
|
||||
if (!PERIODS.includes(period)) throw new Error('周期类型不合法')
|
||||
data.period = period
|
||||
}
|
||||
if (target !== undefined) {
|
||||
const t = Math.floor(Number(target))
|
||||
if (!Number.isFinite(t) || t < 1) throw new Error('目标次数需为 ≥1 的整数')
|
||||
data.target = t
|
||||
}
|
||||
if (color !== undefined) data.color = String(color)
|
||||
if (icon !== undefined) data.icon = String(icon)
|
||||
if (note !== undefined) data.note = String(note)
|
||||
if (archived !== undefined) data.archived = !!archived
|
||||
if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0
|
||||
const row = await prisma.habit.update({ where: { id: Number(habitId) }, data })
|
||||
return { item: row }
|
||||
})
|
||||
|
||||
tool(server, 'delete_habit', {
|
||||
title: '删除打卡项',
|
||||
description: '默认归档并保留历史记录; hard=true 时永久删除打卡项及其全部打卡记录。',
|
||||
inputSchema: {
|
||||
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
||||
hard: z.boolean().optional().describe('true 永久删除, 默认 false 仅归档'),
|
||||
},
|
||||
annotations: { destructiveHint: true },
|
||||
}, async ({ habitId, hard = false }) => {
|
||||
const exist = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
||||
if (!exist) throw new Error('打卡项不存在')
|
||||
if (hard) {
|
||||
await prisma.checkIn.deleteMany({ where: { habitId: Number(habitId) } })
|
||||
await prisma.habit.delete({ where: { id: Number(habitId) } })
|
||||
return { ok: true, removed: true }
|
||||
}
|
||||
const row = await prisma.habit.update({
|
||||
where: { id: Number(habitId) }, data: { archived: true },
|
||||
})
|
||||
return { ok: true, removed: false, item: row }
|
||||
})
|
||||
|
||||
tool(server, 'reorder_habits', {
|
||||
title: '打卡项排序',
|
||||
description: '按传入的打卡项 ID 数组重排, 数组顺序即新顺序。',
|
||||
inputSchema: {
|
||||
ids: z.array(z.coerce.number().int().positive()).describe('打卡项 ID 的新顺序'),
|
||||
},
|
||||
}, async ({ ids }) => {
|
||||
const uniq = [...new Set(ids.map(Number))]
|
||||
if (!uniq.length) throw new Error('ids 不能为空')
|
||||
if (uniq.length !== ids.length) throw new Error('ids 不能重复')
|
||||
const rows = await prisma.habit.findMany({
|
||||
where: { userId, id: { in: uniq } }, select: { id: true },
|
||||
})
|
||||
if (rows.length !== uniq.length) throw new Error('包含无效的打卡项')
|
||||
await prisma.$transaction(
|
||||
ids.map((hid, idx) => prisma.habit.update({
|
||||
where: { id: Number(hid) }, data: { sortOrder: idx },
|
||||
})),
|
||||
)
|
||||
return { ok: true, orderedIds: ids.map(Number) }
|
||||
})
|
||||
|
||||
tool(server, 'check_habit', {
|
||||
title: '打卡',
|
||||
description: '为打卡项记录一次完成; 可为最近 7 天补打, 不能打未来日期。',
|
||||
inputSchema: {
|
||||
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
||||
date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'),
|
||||
note: z.string().optional().describe('打卡备注, 最多 200 字'),
|
||||
},
|
||||
}, async ({ habitId, date, note }) => {
|
||||
const habit = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
||||
if (!habit) throw new Error('打卡项不存在')
|
||||
|
||||
const today = toDateStr()
|
||||
const day = normDate(date)
|
||||
if (day > today) throw new Error('不能为未来日期打卡')
|
||||
if (day < addDays(today, -BACKFILL_DAYS)) throw new Error(`最多只能补打最近 ${BACKFILL_DAYS} 天`)
|
||||
|
||||
const dayRecords = await prisma.checkIn.findMany({
|
||||
where: { habitId: habit.id, date: day }, select: { seq: true },
|
||||
})
|
||||
const seq = dayRecords.reduce((m, r) => Math.max(m, r.seq), 0) + 1
|
||||
const row = await prisma.checkIn.create({
|
||||
data: {
|
||||
habitId: habit.id,
|
||||
userId,
|
||||
date: day,
|
||||
seq,
|
||||
note: String(note || '').slice(0, 200),
|
||||
source: day === today ? 'user' : 'backfill',
|
||||
},
|
||||
})
|
||||
return { ok: true, date: day, seq, item: row }
|
||||
})
|
||||
|
||||
tool(server, 'undo_habit_check', {
|
||||
title: '撤销打卡',
|
||||
description: '撤销指定日期所在周期内最近一次打卡记录。',
|
||||
inputSchema: {
|
||||
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
||||
date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'),
|
||||
},
|
||||
annotations: { destructiveHint: true },
|
||||
}, async ({ habitId, date }) => {
|
||||
const habit = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
||||
if (!habit) throw new Error('打卡项不存在')
|
||||
|
||||
const day = normDate(date)
|
||||
const { start, end } = periodRange(habit.period, day)
|
||||
const last = await prisma.checkIn.findFirst({
|
||||
where: { habitId: habit.id, date: { gte: start, lte: end } },
|
||||
orderBy: [{ date: 'desc' }, { seq: 'desc' }],
|
||||
})
|
||||
if (!last) throw new Error('本周期还没有打卡记录')
|
||||
await prisma.checkIn.delete({ where: { id: last.id } })
|
||||
return { ok: true, date: last.date, seq: last.seq }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// MCP 工具: 检查清单 (清单 / 检查项)
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../../db.js'
|
||||
import { ensureBuiltin } from '../../routes/checklists.js'
|
||||
import { tool, reqStr } from '../helpers.js'
|
||||
|
||||
const LIST_INCLUDE = { items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] } }
|
||||
|
||||
function withProgress(list) {
|
||||
const items = list.items || []
|
||||
const done = items.filter((i) => i.done).length
|
||||
return {
|
||||
...list,
|
||||
items,
|
||||
total: items.length,
|
||||
done,
|
||||
percent: items.length ? done / items.length : 0,
|
||||
completed: items.length > 0 && done === items.length,
|
||||
}
|
||||
}
|
||||
|
||||
async function requireList(listId, userId) {
|
||||
const list = await prisma.checkList.findFirst({ where: { id: Number(listId), userId } })
|
||||
if (!list) throw new Error('清单不存在')
|
||||
return list
|
||||
}
|
||||
|
||||
function normalizeSeedItems(items) {
|
||||
return (Array.isArray(items) ? items : [])
|
||||
.map((i) => (typeof i === 'string' ? { title: i } : i))
|
||||
.map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') }))
|
||||
.filter((i) => i.title)
|
||||
}
|
||||
|
||||
export function registerChecklistTools(server, ctx) {
|
||||
const userId = ctx.userId
|
||||
|
||||
tool(server, 'list_checklists', {
|
||||
title: '查询清单',
|
||||
description: '列出当前用户的检查清单 (出差/回老家等), 含完成进度。',
|
||||
inputSchema: {
|
||||
archived: z.boolean().optional().describe('true 查询已归档清单, 默认 false'),
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async ({ archived = false }) => {
|
||||
const rows = await prisma.checkList.findMany({
|
||||
where: { userId, archived: !!archived },
|
||||
include: LIST_INCLUDE,
|
||||
orderBy: [{ updatedAt: 'desc' }],
|
||||
})
|
||||
return { items: rows.map(withProgress) }
|
||||
})
|
||||
|
||||
tool(server, 'get_checklist', {
|
||||
title: '查询清单详情',
|
||||
description: '读取单个清单及其全部检查项、完成进度。',
|
||||
inputSchema: { listId: z.coerce.number().int().positive().describe('清单 ID') },
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async ({ listId }) => {
|
||||
const row = await prisma.checkList.findFirst({
|
||||
where: { id: Number(listId), userId },
|
||||
include: LIST_INCLUDE,
|
||||
})
|
||||
if (!row) throw new Error('清单不存在')
|
||||
return { item: withProgress(row) }
|
||||
})
|
||||
|
||||
tool(server, 'create_checklist', {
|
||||
title: '新建清单',
|
||||
description: '新建检查清单, 可直接带检查项, 也可从系统模板/自建模板复制 (传 templateId)。',
|
||||
inputSchema: {
|
||||
title: z.string().describe('清单名称'),
|
||||
scene: z.string().optional().describe('场景, 如 出差 / 回老家'),
|
||||
note: z.string().optional().describe('备注'),
|
||||
items: z.array(z.union([
|
||||
z.string(),
|
||||
z.object({ title: z.string(), note: z.string().optional() }),
|
||||
])).optional().describe('初始检查项'),
|
||||
templateId: z.coerce.number().int().positive().optional().describe('模板 ID, 在没有 items 时使用'),
|
||||
},
|
||||
}, async ({ title, scene = '', note = '', items = [], templateId }) => {
|
||||
const cleanTitle = reqStr(title, '清单名称')
|
||||
let seed = normalizeSeedItems(items)
|
||||
|
||||
if (!seed.length && templateId) {
|
||||
await ensureBuiltin()
|
||||
const tpl = await prisma.checkListTemplate.findFirst({
|
||||
where: { id: Number(templateId), OR: [{ builtin: true }, { userId }] },
|
||||
})
|
||||
if (tpl && Array.isArray(tpl.items)) seed = normalizeSeedItems(tpl.items)
|
||||
}
|
||||
|
||||
const row = await prisma.checkList.create({
|
||||
data: {
|
||||
userId,
|
||||
title: cleanTitle,
|
||||
scene: String(scene || ''),
|
||||
note: String(note || ''),
|
||||
items: { create: seed.map((i, idx) => ({ title: i.title, note: i.note, sortOrder: idx })) },
|
||||
},
|
||||
include: LIST_INCLUDE,
|
||||
})
|
||||
return { item: withProgress(row) }
|
||||
})
|
||||
|
||||
tool(server, 'update_checklist', {
|
||||
title: '修改清单',
|
||||
description: '修改清单名称、场景、备注或归档状态。',
|
||||
inputSchema: {
|
||||
listId: z.coerce.number().int().positive().describe('清单 ID'),
|
||||
title: z.string().optional().describe('新名称'),
|
||||
scene: z.string().optional().describe('新场景'),
|
||||
note: z.string().optional().describe('新备注'),
|
||||
archived: z.boolean().optional().describe('是否归档'),
|
||||
},
|
||||
}, async ({ listId, title, scene, note, archived }) => {
|
||||
await requireList(listId, userId)
|
||||
const data = {}
|
||||
if (title !== undefined) data.title = reqStr(title, '清单名称')
|
||||
if (scene !== undefined) data.scene = String(scene)
|
||||
if (note !== undefined) data.note = String(note)
|
||||
if (archived !== undefined) data.archived = !!archived
|
||||
const row = await prisma.checkList.update({
|
||||
where: { id: Number(listId) }, data, include: LIST_INCLUDE,
|
||||
})
|
||||
return { item: withProgress(row) }
|
||||
})
|
||||
|
||||
tool(server, 'delete_checklist', {
|
||||
title: '删除清单',
|
||||
description: '永久删除清单及其全部检查项。',
|
||||
inputSchema: { listId: z.coerce.number().int().positive().describe('清单 ID') },
|
||||
annotations: { destructiveHint: true },
|
||||
}, async ({ listId }) => {
|
||||
await requireList(listId, userId)
|
||||
await prisma.checkListItem.deleteMany({ where: { listId: Number(listId) } })
|
||||
await prisma.checkList.delete({ where: { id: Number(listId) } })
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
tool(server, 'duplicate_checklist', {
|
||||
title: '复制清单',
|
||||
description: '复制一份清单, 勾选状态重置。',
|
||||
inputSchema: { listId: z.coerce.number().int().positive().describe('清单 ID') },
|
||||
}, async ({ listId }) => {
|
||||
const src = await prisma.checkList.findFirst({
|
||||
where: { id: Number(listId), userId }, include: LIST_INCLUDE,
|
||||
})
|
||||
if (!src) throw new Error('清单不存在')
|
||||
const row = await prisma.checkList.create({
|
||||
data: {
|
||||
userId,
|
||||
title: src.title + ' 副本',
|
||||
scene: src.scene,
|
||||
note: src.note,
|
||||
items: {
|
||||
create: src.items.map((i, idx) => ({
|
||||
title: i.title, note: i.note, done: false, sortOrder: idx,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: LIST_INCLUDE,
|
||||
})
|
||||
return { item: withProgress(row) }
|
||||
})
|
||||
|
||||
tool(server, 'add_checklist_item', {
|
||||
title: '添加检查项',
|
||||
description: '在清单末尾添加一个检查项。',
|
||||
inputSchema: {
|
||||
listId: z.coerce.number().int().positive().describe('清单 ID'),
|
||||
title: z.string().describe('检查项内容, 最多 40 字'),
|
||||
note: z.string().optional().describe('备注'),
|
||||
},
|
||||
}, async ({ listId, title, note = '' }) => {
|
||||
await requireList(listId, userId)
|
||||
const cleanTitle = reqStr(title, '检查项', 40)
|
||||
const max = await prisma.checkListItem.findFirst({
|
||||
where: { listId: Number(listId) }, orderBy: { sortOrder: 'desc' },
|
||||
})
|
||||
const row = await prisma.checkListItem.create({
|
||||
data: {
|
||||
listId: Number(listId),
|
||||
title: cleanTitle,
|
||||
note: String(note || ''),
|
||||
sortOrder: (max?.sortOrder ?? -1) + 1,
|
||||
},
|
||||
})
|
||||
return { item: row }
|
||||
})
|
||||
|
||||
tool(server, 'update_checklist_item', {
|
||||
title: '修改检查项',
|
||||
description: '修改检查项内容、备注、完成状态或排序值。',
|
||||
inputSchema: {
|
||||
itemId: z.coerce.number().int().positive().describe('检查项 ID'),
|
||||
title: z.string().optional().describe('新内容, 最多 40 字'),
|
||||
note: z.string().optional().describe('新备注'),
|
||||
done: z.boolean().optional().describe('是否完成'),
|
||||
sortOrder: z.coerce.number().int().optional().describe('排序值'),
|
||||
},
|
||||
}, async ({ itemId, title, note, done, sortOrder }) => {
|
||||
const exist = await prisma.checkListItem.findFirst({
|
||||
where: { id: Number(itemId), list: { userId } },
|
||||
})
|
||||
if (!exist) throw new Error('检查项不存在')
|
||||
const data = {}
|
||||
if (title !== undefined) data.title = reqStr(title, '检查项', 40)
|
||||
if (note !== undefined) data.note = String(note)
|
||||
if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0
|
||||
if (done !== undefined) {
|
||||
data.done = !!done
|
||||
data.doneAt = done ? new Date() : null
|
||||
}
|
||||
const row = await prisma.checkListItem.update({ where: { id: Number(itemId) }, data })
|
||||
return { item: row }
|
||||
})
|
||||
|
||||
tool(server, 'delete_checklist_item', {
|
||||
title: '删除检查项',
|
||||
description: '永久删除一个检查项。',
|
||||
inputSchema: { itemId: z.coerce.number().int().positive().describe('检查项 ID') },
|
||||
annotations: { destructiveHint: true },
|
||||
}, async ({ itemId }) => {
|
||||
const exist = await prisma.checkListItem.findFirst({
|
||||
where: { id: Number(itemId), list: { userId } },
|
||||
})
|
||||
if (!exist) throw new Error('检查项不存在')
|
||||
await prisma.checkListItem.delete({ where: { id: Number(itemId) } })
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
tool(server, 'reorder_checklist_items', {
|
||||
title: '检查项排序',
|
||||
description: '按传入的检查项 ID 数组重排清单内检查项, 数组顺序即新顺序。',
|
||||
inputSchema: {
|
||||
listId: z.coerce.number().int().positive().describe('清单 ID'),
|
||||
ids: z.array(z.coerce.number().int().positive()).describe('检查项 ID 的新顺序'),
|
||||
},
|
||||
}, async ({ listId, ids }) => {
|
||||
await requireList(listId, userId)
|
||||
const uniq = [...new Set(ids.map(Number))]
|
||||
if (uniq.length !== ids.length) throw new Error('ids 不能重复')
|
||||
const rows = await prisma.checkListItem.findMany({
|
||||
where: { listId: Number(listId), id: { in: uniq } }, select: { id: true },
|
||||
})
|
||||
if (rows.length !== uniq.length) throw new Error('包含不属于该清单的检查项')
|
||||
await prisma.$transaction(
|
||||
ids.map((itemId, idx) => prisma.checkListItem.update({
|
||||
where: { id: Number(itemId) }, data: { sortOrder: idx },
|
||||
})),
|
||||
)
|
||||
return { ok: true, orderedIds: ids.map(Number) }
|
||||
})
|
||||
|
||||
tool(server, 'check_all_checklist_items', {
|
||||
title: '清单一键勾选',
|
||||
description: '把清单内全部检查项设为完成或未完成。',
|
||||
inputSchema: {
|
||||
listId: z.coerce.number().int().positive().describe('清单 ID'),
|
||||
done: z.boolean().describe('true 全部完成, false 全部取消'),
|
||||
},
|
||||
}, async ({ listId, done }) => {
|
||||
await requireList(listId, userId)
|
||||
await prisma.checkListItem.updateMany({
|
||||
where: { listId: Number(listId) },
|
||||
data: { done: !!done, doneAt: done ? new Date() : null },
|
||||
})
|
||||
const row = await prisma.checkList.findFirst({
|
||||
where: { id: Number(listId) }, include: LIST_INCLUDE,
|
||||
})
|
||||
return { item: withProgress(row) }
|
||||
})
|
||||
|
||||
tool(server, 'list_checklist_templates', {
|
||||
title: '查询清单模板',
|
||||
description: '列出系统预置和当前用户自建的清单模板, 可配合 create_checklist 使用。',
|
||||
inputSchema: {},
|
||||
annotations: { readOnlyHint: true },
|
||||
}, async () => {
|
||||
await ensureBuiltin()
|
||||
const rows = await prisma.checkListTemplate.findMany({
|
||||
where: { OR: [{ builtin: true }, { userId }] },
|
||||
orderBy: [{ builtin: 'desc' }, { name: 'asc' }],
|
||||
})
|
||||
return { items: rows }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user