248 lines
9.7 KiB
TypeScript
248 lines
9.7 KiB
TypeScript
/**
|
|
* 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<BoardDoc>(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<BoardDoc>(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<BoardDoc>(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<BoardDoc>(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<BoardDoc>(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<BoardDoc>(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<BoardDoc>(docId);
|
|
if (!doc?.tasks?.[task_id]) continue;
|
|
|
|
syncServer.changeDoc<BoardDoc>(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 };
|
|
},
|
|
);
|
|
}
|