/** * MCP tools for rTasks (task boards). * * Tools: rtasks_list_boards, rtasks_list_tasks, rtasks_get_task, * rtasks_create_task, rtasks_update_task */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; import { boardDocId, createTaskItem } from "../../modules/rtasks/schemas"; import type { BoardDoc, TaskItem } from "../../modules/rtasks/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; import { filterArrayByVisibility, isVisibleTo } from "../../shared/membrane"; const BOARD_PREFIX = ":tasks:boards:"; /** Find all board docIds for a space. */ function findBoardDocIds(syncServer: SyncServer, space: string): string[] { const prefix = `${space}${BOARD_PREFIX}`; return syncServer.getDocIds().filter(id => id.startsWith(prefix)); } export function registerTasksTools(server: McpServer, syncServer: SyncServer) { server.tool( "rtasks_list_boards", "List all task boards in a space", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), }, async ({ space, token }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = findBoardDocIds(syncServer, space); const boards = []; for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.board) continue; boards.push({ id: doc.board.id, name: doc.board.name, slug: doc.board.slug, description: doc.board.description, statuses: doc.board.statuses, labels: doc.board.labels, taskCount: Object.keys(doc.tasks || {}).length, createdAt: doc.board.createdAt, }); } return { content: [{ type: "text", text: JSON.stringify(boards, null, 2) }] }; }, ); server.tool( "rtasks_list_tasks", "List tasks on a board, optionally filtered by status, priority, or search text", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), board_slug: z.string().describe("Board slug or ID"), status: z.string().optional().describe("Filter by status (e.g. TODO, IN_PROGRESS, DONE)"), priority: z.string().optional().describe("Filter by priority"), search: z.string().optional().describe("Search in title/description"), exclude_done: z.boolean().optional().describe("Exclude DONE tasks (default false)"), limit: z.number().optional().describe("Max results (default 50)"), }, async ({ space, token, board_slug, status, priority, search, exclude_done, limit }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const docId = boardDocId(space, board_slug); const doc = syncServer.getDoc(docId); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "Board not found" }) }] }; } let tasks = filterArrayByVisibility(Object.values(doc.tasks || {}), access.role); if (status) tasks = tasks.filter(t => t.status === status); if (priority) tasks = tasks.filter(t => t.priority === priority); if (exclude_done) tasks = tasks.filter(t => t.status !== "DONE"); if (search) { const q = search.toLowerCase(); tasks = tasks.filter(t => t.title.toLowerCase().includes(q) || (t.description && t.description.toLowerCase().includes(q)), ); } tasks.sort((a, b) => (a.sortOrder - b.sortOrder) || (a.createdAt - b.createdAt)); const maxResults = limit || 50; tasks = tasks.slice(0, maxResults); const summary = tasks.map(t => ({ id: t.id, title: t.title, status: t.status, priority: t.priority, labels: t.labels, assigneeId: t.assigneeId, dueDate: t.dueDate, createdAt: t.createdAt, updatedAt: t.updatedAt, })); return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; }, ); server.tool( "rtasks_get_task", "Get full details of a specific task", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"), task_id: z.string().describe("Task ID"), board_slug: z.string().optional().describe("Board slug (speeds up lookup)"), }, async ({ space, token, task_id, board_slug }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); if (board_slug) { const doc = syncServer.getDoc(boardDocId(space, board_slug)); const task = doc?.tasks?.[task_id]; if (task) { if (!isVisibleTo(task.visibility, access.role)) { return { content: [{ type: "text", text: JSON.stringify({ error: "Task not found" }) }] }; } return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }] }; } } for (const docId of findBoardDocIds(syncServer, space)) { const doc = syncServer.getDoc(docId); const task = doc?.tasks?.[task_id]; if (task) { if (!isVisibleTo(task.visibility, access.role)) { return { content: [{ type: "text", text: JSON.stringify({ error: "Task not found" }) }] }; } return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }] }; } } return { content: [{ type: "text", text: JSON.stringify({ error: "Task not found" }) }] }; }, ); server.tool( "rtasks_create_task", "Create a new task on a board (requires auth token + space membership)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), board_slug: z.string().describe("Board slug or ID"), title: z.string().describe("Task title"), description: z.string().optional().describe("Task description"), status: z.string().optional().describe("Initial status (default: TODO)"), priority: z.string().optional().describe("Priority level"), labels: z.array(z.string()).optional().describe("Labels/tags"), due_date: z.number().optional().describe("Due date (epoch ms)"), assignee_id: z.string().optional().describe("Assignee DID"), visibility: z.enum(['viewer', 'member', 'moderator', 'admin']).optional().describe("Object visibility level (default: viewer = everyone)"), }, async ({ space, token, board_slug, title, description, status, priority, labels, due_date, assignee_id, visibility }) => { const access = await resolveAccess(token, space, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const docId = boardDocId(space, board_slug); const doc = syncServer.getDoc(docId); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "Board not found" }) }], isError: true }; } const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const taskItem = createTaskItem(taskId, space, title, { description: description || "", status: status || "TODO", priority: priority || null, labels: labels || [], dueDate: due_date || null, assigneeId: assignee_id || null, createdBy: (access.claims?.did as string) ?? null, }); if (visibility) taskItem.visibility = visibility; syncServer.changeDoc(docId, `Create task ${title}`, (d) => { if (!d.tasks) (d as any).tasks = {}; d.tasks[taskId] = taskItem; }); return { content: [{ type: "text", text: JSON.stringify({ id: taskId, created: true }) }] }; }, ); server.tool( "rtasks_update_task", "Update an existing task (requires auth token + space membership)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), task_id: z.string().describe("Task ID"), board_slug: z.string().optional().describe("Board slug (speeds up lookup)"), title: z.string().optional().describe("New title"), description: z.string().optional().describe("New description"), status: z.string().optional().describe("New status"), priority: z.string().optional().describe("New priority"), labels: z.array(z.string()).optional().describe("New labels"), due_date: z.number().optional().describe("New due date (epoch ms)"), assignee_id: z.string().optional().describe("New assignee DID"), visibility: z.enum(['viewer', 'member', 'moderator', 'admin']).optional().describe("Object visibility level"), }, async ({ space, token, task_id, board_slug, ...updates }) => { const access = await resolveAccess(token, space, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = board_slug ? [boardDocId(space, board_slug)] : findBoardDocIds(syncServer, space); for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.tasks?.[task_id]) continue; syncServer.changeDoc(docId, `Update task ${task_id}`, (d) => { const t = d.tasks[task_id]; if (updates.title !== undefined) t.title = updates.title; if (updates.description !== undefined) t.description = updates.description; if (updates.status !== undefined) t.status = updates.status; if (updates.priority !== undefined) t.priority = updates.priority; if (updates.labels !== undefined) t.labels = updates.labels; if (updates.due_date !== undefined) t.dueDate = updates.due_date; if (updates.assignee_id !== undefined) t.assigneeId = updates.assignee_id; if (updates.visibility !== undefined) t.visibility = updates.visibility || undefined; t.updatedAt = Date.now(); }); return { content: [{ type: "text", text: JSON.stringify({ id: task_id, updated: true }) }] }; } return { content: [{ type: "text", text: JSON.stringify({ error: "Task not found" }) }], isError: true }; }, ); }