178 lines
6.0 KiB
TypeScript
178 lines
6.0 KiB
TypeScript
/**
|
|
* MCP tools for rTime (commitments, tasks, external time logs).
|
|
*
|
|
* Tools: rtime_list_commitments, rtime_list_tasks,
|
|
* 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,
|
|
tasksDocId,
|
|
externalTimeLogsDocId,
|
|
} from "../../modules/rtime/schemas";
|
|
import type {
|
|
CommitmentsDoc,
|
|
TasksDoc,
|
|
ExternalTimeLogsDoc,
|
|
Skill,
|
|
} from "../../modules/rtime/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<CommitmentsDoc>(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_tasks",
|
|
"List rTime tasks with their needs maps",
|
|
{
|
|
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 doc = syncServer.getDoc<TasksDoc>(tasksDocId(space));
|
|
if (!doc) {
|
|
return { content: [{ type: "text", text: JSON.stringify({ error: "No tasks data found" }) }] };
|
|
}
|
|
|
|
const tasks = filterArrayByVisibility(Object.values(doc.tasks || {}), access.role)
|
|
.slice(0, limit || 50)
|
|
.map(t => ({
|
|
id: t.id,
|
|
name: t.name,
|
|
description: t.description,
|
|
needs: t.needs,
|
|
}));
|
|
|
|
return { content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }] };
|
|
},
|
|
);
|
|
|
|
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<ExternalTimeLogsDoc>(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<CommitmentsDoc>(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<CommitmentsDoc>(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 }) }] };
|
|
},
|
|
);
|
|
}
|