/** * MCP tools for rTime (commitments, woven tasks, external time logs). * * Tools: rtime_list_commitments, rtime_list_woven_tasks, rtime_place_task, * rtime_list_time_logs, rtime_create_commitment */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; import { commitmentsDocId, weavingDocId, externalTimeLogsDocId, } from "../../modules/rtime/schemas"; import type { CommitmentsDoc, WeavingDoc, ExternalTimeLogsDoc, Skill, } from "../../modules/rtime/schemas"; import { boardDocId } from "../../modules/rtasks/schemas"; import type { BoardDoc } from "../../modules/rtasks/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; import { filterArrayByVisibility } from "../../shared/membrane"; const VALID_SKILLS: Skill[] = ["facilitation", "design", "tech", "outreach", "logistics"]; export function registerTimeTools(server: McpServer, syncServer: SyncServer) { server.tool( "rtime_list_commitments", "List resource commitments in a space (time pledges by members)", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), skill: z.string().optional().describe("Filter by skill (facilitation, design, tech, outreach, logistics)"), status: z.string().optional().describe("Filter by status (active, matched, settled, withdrawn)"), limit: z.number().optional().describe("Max results (default 50)"), }, async ({ space, token, skill, status, limit }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(commitmentsDocId(space)); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No commitments data found" }) }] }; } let items = filterArrayByVisibility(Object.values(doc.items || {}), access.role); if (skill) items = items.filter(c => c.skill === skill); if (status) items = items.filter(c => (c.status || "active") === status); items.sort((a, b) => b.createdAt - a.createdAt); items = items.slice(0, limit || 50); const summary = items.map(c => ({ id: c.id, memberName: c.memberName, hours: c.hours, skill: c.skill, desc: c.desc, status: c.status || "active", createdAt: c.createdAt, })); return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; }, ); server.tool( "rtime_list_woven_tasks", "List tasks placed on the rTime weaving canvas with overlay data (needs, position, connections)", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), limit: z.number().optional().describe("Max results (default 50)"), }, async ({ space, token, limit }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const wDoc = syncServer.getDoc(weavingDocId(space)); if (!wDoc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No weaving data found" }) }] }; } // Look up rTasks board for task titles const boardSlug = wDoc.boardSlug || space; const board = syncServer.getDoc(boardDocId(space, boardSlug)); const tasks = Object.entries(wDoc.weavingOverlays || {}) .slice(0, limit || 50) .map(([id, ov]) => { const item = board?.tasks[id]; return { id, title: item?.title || id, status: item?.status || 'TODO', description: item?.description || '', needs: ov.needs, canvasX: ov.canvasX, canvasY: ov.canvasY, notes: ov.notes, links: ov.links, connectionCount: Object.values(wDoc.connections || {}).filter(c => c.toTaskId === id).length, }; }); return { content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }] }; }, ); server.tool( "rtime_place_task", "Place an rTasks item onto the weaving canvas with skill needs (requires auth token)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), task_id: z.string().describe("rTasks task ID to place on canvas"), needs: z.record(z.number()).describe("Skill-to-hours map (e.g. {tech: 4, design: 2})"), canvas_x: z.number().optional().describe("Canvas X position (default 400)"), canvas_y: z.number().optional().describe("Canvas Y position (default 150)"), }, async ({ space, token, task_id, needs, canvas_x, canvas_y }) => { const access = await resolveAccess(token, space, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const wDoc = syncServer.getDoc(weavingDocId(space)); if (!wDoc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No weaving doc found" }) }], isError: true }; } // Verify task exists in rTasks board const boardSlug = wDoc.boardSlug || space; const board = syncServer.getDoc(boardDocId(space, boardSlug)); if (!board?.tasks[task_id]) { return { content: [{ type: "text", text: JSON.stringify({ error: "Task not found in rTasks board" }) }], isError: true }; } syncServer.changeDoc(weavingDocId(space), `Place task ${task_id} on canvas`, (d) => { if (!d.weavingOverlays) (d as any).weavingOverlays = {}; d.weavingOverlays[task_id] = { rtasksId: task_id, needs, canvasX: canvas_x ?? 400, canvasY: canvas_y ?? 150, notes: '', links: [], }; }); return { content: [{ type: "text", text: JSON.stringify({ id: task_id, placed: true, needs }) }] }; }, ); server.tool( "rtime_list_time_logs", "List external time logs (imported from backlog-md)", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), status: z.string().optional().describe("Filter by status (pending, commitment_created, settled)"), limit: z.number().optional().describe("Max results (default 50)"), }, async ({ space, token, status, limit }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(externalTimeLogsDocId(space)); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No time logs found" }) }] }; } let logs = Object.values(doc.logs || {}); if (status) logs = logs.filter(l => l.status === status); logs.sort((a, b) => b.loggedAt - a.loggedAt); logs = logs.slice(0, limit || 50); const summary = logs.map(l => ({ id: l.id, backlogTaskTitle: l.backlogTaskTitle, memberName: l.memberName, hours: l.hours, skill: l.skill, status: l.status, loggedAt: l.loggedAt, })); return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; }, ); server.tool( "rtime_create_commitment", "Create a new time commitment (requires auth token + space membership)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), member_name: z.string().describe("Name of the committing member"), hours: z.number().min(1).max(10).describe("Hours committed (1-10)"), skill: z.enum(["facilitation", "design", "tech", "outreach", "logistics"]).describe("Skill type"), desc: z.string().describe("Description of the commitment"), }, async ({ space, token, member_name, hours, skill, desc }) => { const access = await resolveAccess(token, space, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const docId = commitmentsDocId(space); const doc = syncServer.getDoc(docId); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No commitments data found" }) }], isError: true }; } const commitmentId = `cmt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const now = Date.now(); syncServer.changeDoc(docId, `Create commitment by ${member_name}`, (d) => { if (!d.items) (d as any).items = {}; d.items[commitmentId] = { id: commitmentId, memberName: member_name, hours, skill: skill as Skill, desc, createdAt: now, status: "active", ownerDid: (access.claims?.did as string) ?? undefined, }; }); return { content: [{ type: "text", text: JSON.stringify({ id: commitmentId, created: true }) }] }; }, ); }