import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; class ApiClient { constructor( private baseUrl: string, private token: string, ) {} async json(path: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers); headers.set("Authorization", `Bearer ${this.token}`); if (init?.body != null && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`; const res = await fetch(url, { ...init, headers, signal: AbortSignal.timeout(10_000), }); const text = await res.text(); let body: unknown = null; if (text) { try { body = JSON.parse(text); } catch { body = text; } } if (!res.ok) { const detail = typeof body === "object" && body !== null && "message" in body ? (body as { message: string }).message : typeof body === "string" && body.length > 0 ? body.slice(0, 500) : `HTTP ${res.status}`; throw new Error(`${path}: ${detail}`); } return body as T; } } function textResult(data: unknown, isError = false): CallToolResult { const text = typeof data === "string" ? data : (JSON.stringify(data, null, 2) ?? ""); return { content: [{ type: "text", text }], isError }; } function errorResult(message: string): CallToolResult { return textResult({ error: message }, true); } function run(fn: () => Promise): Promise { return fn() .then((data) => textResult(data)) .catch((e: unknown) => errorResult(e instanceof Error ? e.message : String(e)), ); } const PRIORITIES = ["no-priority", "low", "medium", "high", "urgent"] as const; function isTaskPriority(v: string): v is (typeof PRIORITIES)[number] { return (PRIORITIES as readonly string[]).includes(v); } function formatOptionalIso(value: unknown): string | undefined { if (value === null || value === undefined) return undefined; if (value instanceof Date) return value.toISOString(); if (typeof value === "string") return value; return undefined; } function buildFullTaskUpdateBody( existing: Record, patch: Record, ): Record { const positionRaw = patch.position ?? existing.position; const position = typeof positionRaw === "number" ? positionRaw : typeof positionRaw === "string" ? Number(positionRaw) : Number.NaN; if (!Number.isFinite(position)) throw new Error( "Cannot update task: missing numeric `position` on existing task.", ); const title = (patch.title as string) ?? (typeof existing.title === "string" ? existing.title : undefined); if (!title) throw new Error("Cannot update task: missing title."); const description = patch.description !== undefined ? patch.description === null ? "" : String(patch.description) : existing.description == null ? "" : String(existing.description); const status = (patch.status as string) ?? (typeof existing.status === "string" ? existing.status : undefined); if (!status) throw new Error("Cannot update task: missing status."); const priorityRaw = (patch.priority as string) ?? (typeof existing.priority === "string" ? existing.priority : undefined); if (!priorityRaw || !isTaskPriority(priorityRaw)) throw new Error("Cannot update task: invalid or missing priority."); const projectId = (patch.projectId as string) ?? (typeof existing.projectId === "string" ? existing.projectId : undefined); if (!projectId) throw new Error("Cannot update task: missing projectId."); const userId = patch.userId !== undefined ? patch.userId === null ? "" : (patch.userId as string) : typeof existing.userId === "string" ? existing.userId : undefined; const startDate = formatOptionalIso( patch.startDate !== undefined ? patch.startDate : existing.startDate, ); const dueDate = formatOptionalIso( patch.dueDate !== undefined ? patch.dueDate : existing.dueDate, ); const body: Record = { title, description, status, priority: priorityRaw, projectId, position, }; if (startDate !== undefined) body.startDate = startDate; if (dueDate !== undefined) body.dueDate = dueDate; if (userId !== undefined) body.userId = userId; return body; } const prioritySchema = z.enum([ "no-priority", "low", "medium", "high", "urgent", ]); const nonEmptyString = z.string().trim().min(1); const optionalNonEmptyString = nonEmptyString.optional(); const nullableOptionalNonEmptyString = nonEmptyString.nullable().optional(); const isoDateTimeSchema = z.string().datetime({ offset: true }); const optionalIsoDateTimeSchema = isoDateTimeSchema.optional(); const nullableOptionalIsoDateTimeSchema = isoDateTimeSchema .nullable() .optional(); const hexColorSchema = z .string() .regex( /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, "Expected a hex color like #FF6600", ); export function registerMcpTools( server: McpServer, baseUrl: string, token: string, ): void { const client = new ApiClient(baseUrl, token); server.registerTool( "whoami", { description: "Return the current Kaneo session and user.", inputSchema: z.object({}), }, async () => run(() => client.json("/api/auth/get-session", { method: "GET" })), ); server.registerTool( "list_workspaces", { description: "List workspaces the signed-in user can access.", inputSchema: z.object({}), }, async () => run(() => client.json("/api/auth/organization/list", { method: "GET" })), ); server.registerTool( "list_projects", { description: "List projects in a workspace.", inputSchema: z.object({ workspaceId: nonEmptyString.describe("Workspace ID"), includeArchived: z .boolean() .optional() .describe("Include archived projects"), }), }, async (args) => { const qs = new URLSearchParams({ workspaceId: args.workspaceId }); if (args.includeArchived === true) qs.set("includeArchived", "true"); return run(() => client.json(`/api/project?${qs.toString()}`, { method: "GET" }), ); }, ); server.registerTool( "get_project", { description: "Get a single project by ID.", inputSchema: z.object({ id: nonEmptyString }), }, async (args) => run(() => client.json(`/api/project/${encodeURIComponent(args.id)}`)), ); server.registerTool( "create_project", { description: "Create a project in a workspace.", inputSchema: z.object({ name: nonEmptyString, workspaceId: nonEmptyString, icon: nonEmptyString, slug: nonEmptyString, }), }, async (args) => run(() => client.json("/api/project", { method: "POST", body: JSON.stringify({ name: args.name, workspaceId: args.workspaceId, icon: args.icon, slug: args.slug, }), }), ), ); server.registerTool( "update_project", { description: "Update project metadata (PATCH-style: only provided fields are changed).", inputSchema: z.object({ id: nonEmptyString, name: optionalNonEmptyString, icon: z.string().optional(), slug: optionalNonEmptyString, description: z.string().optional(), isPublic: z.boolean().optional(), }), }, async (args) => { const { id, ...patch } = args; return run(async () => { const existing = (await client.json( `/api/project/${encodeURIComponent(id)}`, { method: "GET" }, )) as Record; const name = patch.name ?? (typeof existing.name === "string" ? existing.name : ""); if (!name) throw new Error("Cannot update project: missing name."); const icon = patch.icon !== undefined ? patch.icon : typeof existing.icon === "string" ? existing.icon : "Layout"; const slug = patch.slug ?? (typeof existing.slug === "string" ? existing.slug : ""); if (!slug) throw new Error("Cannot update project: missing slug."); const description = patch.description !== undefined ? patch.description : typeof existing.description === "string" ? existing.description : ""; const isPublic = patch.isPublic !== undefined ? patch.isPublic : typeof existing.isPublic === "boolean" ? existing.isPublic : false; return client.json(`/api/project/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ name, icon, slug, description, isPublic }), }); }); }, ); server.registerTool( "list_tasks", { description: "List tasks for a project (optionally filtered/sorted).", inputSchema: z.object({ projectId: nonEmptyString, status: optionalNonEmptyString, priority: prioritySchema.optional(), assigneeId: optionalNonEmptyString, page: z.number().int().positive().optional(), limit: z.number().int().positive().optional(), sortBy: z .enum([ "createdAt", "priority", "dueDate", "position", "title", "number", ]) .optional(), sortOrder: z.enum(["asc", "desc"]).optional(), dueBefore: optionalIsoDateTimeSchema, dueAfter: optionalIsoDateTimeSchema, }), }, async (args) => { const { projectId, ...rest } = args; const qs = new URLSearchParams(); for (const [k, v] of Object.entries(rest)) { if (v !== undefined && v !== null) qs.set(k, String(v)); } const q = qs.toString(); return run(() => client.json( `/api/task/tasks/${encodeURIComponent(projectId)}${q ? `?${q}` : ""}`, { method: "GET" }, ), ); }, ); server.registerTool( "get_task", { description: "Get a task by ID.", inputSchema: z.object({ taskId: nonEmptyString }), }, async (args) => run(() => client.json(`/api/task/${encodeURIComponent(args.taskId)}`, { method: "GET", }), ), ); server.registerTool( "create_task", { description: "Create a task in a project.", inputSchema: z.object({ projectId: nonEmptyString, title: nonEmptyString, description: z.string(), priority: prioritySchema, status: nonEmptyString, startDate: optionalIsoDateTimeSchema, dueDate: optionalIsoDateTimeSchema, userId: optionalNonEmptyString, }), }, async (args) => { const body: Record = { title: args.title, description: args.description, priority: args.priority, status: args.status, }; if (args.startDate !== undefined) body.startDate = args.startDate; if (args.dueDate !== undefined) body.dueDate = args.dueDate; if (args.userId !== undefined) body.userId = args.userId; return run(() => client.json(`/api/task/${encodeURIComponent(args.projectId)}`, { method: "POST", body: JSON.stringify(body), }), ); }, ); server.registerTool( "update_task", { description: "Update a task (fetches current task, merges fields, then full update).", inputSchema: z.object({ taskId: nonEmptyString, title: optionalNonEmptyString, description: z.string().nullable().optional(), status: optionalNonEmptyString, priority: prioritySchema.optional(), projectId: optionalNonEmptyString, position: z.number().optional(), startDate: nullableOptionalIsoDateTimeSchema, dueDate: nullableOptionalIsoDateTimeSchema, userId: nullableOptionalNonEmptyString, }), }, async (args) => { const { taskId, ...patch } = args; return run(async () => { const existing = (await client.json( `/api/task/${encodeURIComponent(taskId)}`, { method: "GET" }, )) as Record; const body = buildFullTaskUpdateBody(existing, patch); return client.json(`/api/task/${encodeURIComponent(taskId)}`, { method: "PUT", body: JSON.stringify(body), }); }); }, ); server.registerTool( "move_task", { description: "Move a task to another project (and optional column status).", inputSchema: z.object({ taskId: nonEmptyString, destinationProjectId: nonEmptyString, destinationStatus: optionalNonEmptyString, }), }, async (args) => run(() => client.json(`/api/task/move/${encodeURIComponent(args.taskId)}`, { method: "PUT", body: JSON.stringify({ destinationProjectId: args.destinationProjectId, ...(args.destinationStatus !== undefined ? { destinationStatus: args.destinationStatus } : {}), }), }), ), ); server.registerTool( "update_task_status", { description: "Update only the status (column) of a task.", inputSchema: z.object({ taskId: nonEmptyString, status: nonEmptyString }), }, async (args) => run(() => client.json(`/api/task/status/${encodeURIComponent(args.taskId)}`, { method: "PUT", body: JSON.stringify({ status: args.status }), }), ), ); server.registerTool( "list_task_comments", { description: "List comments on a task.", inputSchema: z.object({ taskId: nonEmptyString }), }, async (args) => run(() => client.json(`/api/comment/${encodeURIComponent(args.taskId)}`, { method: "GET", }), ), ); server.registerTool( "create_task_comment", { description: "Add a comment to a task.", inputSchema: z.object({ taskId: nonEmptyString, content: nonEmptyString, }), }, async (args) => run(() => client.json(`/api/comment/${encodeURIComponent(args.taskId)}`, { method: "POST", body: JSON.stringify({ content: args.content }), }), ), ); server.registerTool( "list_workspace_labels", { description: "List labels defined in a workspace.", inputSchema: z.object({ workspaceId: nonEmptyString }), }, async (args) => run(() => client.json( `/api/label/workspace/${encodeURIComponent(args.workspaceId)}`, { method: "GET" }, ), ), ); server.registerTool( "create_label", { description: "Create a label in a workspace (optionally attach to a task).", inputSchema: z.object({ name: nonEmptyString, color: hexColorSchema, workspaceId: nonEmptyString, taskId: optionalNonEmptyString, }), }, async (args) => run(() => client.json("/api/label", { method: "POST", body: JSON.stringify({ name: args.name, color: args.color, workspaceId: args.workspaceId, ...(args.taskId !== undefined ? { taskId: args.taskId } : {}), }), }), ), ); server.registerTool( "attach_label_to_task", { description: "Attach an existing label to a task.", inputSchema: z.object({ labelId: nonEmptyString, taskId: nonEmptyString, }), }, async (args) => run(() => client.json(`/api/label/${encodeURIComponent(args.labelId)}/task`, { method: "PUT", body: JSON.stringify({ taskId: args.taskId }), }), ), ); server.registerTool( "detach_label_from_task", { description: "Detach a label from its current task.", inputSchema: z.object({ labelId: nonEmptyString }), }, async (args) => run(() => client.json(`/api/label/${encodeURIComponent(args.labelId)}/task`, { method: "DELETE", }), ), ); }