kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3import { z } from "zod";
4
5class ApiClient {
6 constructor(
7 private baseUrl: string,
8 private token: string,
9 ) {}
10
11 async json<T = unknown>(path: string, init?: RequestInit): Promise<T> {
12 const headers = new Headers(init?.headers);
13 headers.set("Authorization", `Bearer ${this.token}`);
14 if (init?.body != null && !headers.has("Content-Type")) {
15 headers.set("Content-Type", "application/json");
16 }
17
18 const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
19 const res = await fetch(url, {
20 ...init,
21 headers,
22 signal: AbortSignal.timeout(10_000),
23 });
24
25 const text = await res.text();
26 let body: unknown = null;
27 if (text) {
28 try {
29 body = JSON.parse(text);
30 } catch {
31 body = text;
32 }
33 }
34 if (!res.ok) {
35 const detail =
36 typeof body === "object" && body !== null && "message" in body
37 ? (body as { message: string }).message
38 : typeof body === "string" && body.length > 0
39 ? body.slice(0, 500)
40 : `HTTP ${res.status}`;
41 throw new Error(`${path}: ${detail}`);
42 }
43 return body as T;
44 }
45}
46
47function textResult(data: unknown, isError = false): CallToolResult {
48 const text =
49 typeof data === "string" ? data : (JSON.stringify(data, null, 2) ?? "");
50 return { content: [{ type: "text", text }], isError };
51}
52
53function errorResult(message: string): CallToolResult {
54 return textResult({ error: message }, true);
55}
56
57function run(fn: () => Promise<unknown>): Promise<CallToolResult> {
58 return fn()
59 .then((data) => textResult(data))
60 .catch((e: unknown) =>
61 errorResult(e instanceof Error ? e.message : String(e)),
62 );
63}
64
65const PRIORITIES = ["no-priority", "low", "medium", "high", "urgent"] as const;
66
67function isTaskPriority(v: string): v is (typeof PRIORITIES)[number] {
68 return (PRIORITIES as readonly string[]).includes(v);
69}
70
71function formatOptionalIso(value: unknown): string | undefined {
72 if (value === null || value === undefined) return undefined;
73 if (value instanceof Date) return value.toISOString();
74 if (typeof value === "string") return value;
75 return undefined;
76}
77
78function buildFullTaskUpdateBody(
79 existing: Record<string, unknown>,
80 patch: Record<string, unknown>,
81): Record<string, string | number | undefined> {
82 const positionRaw = patch.position ?? existing.position;
83 const position =
84 typeof positionRaw === "number"
85 ? positionRaw
86 : typeof positionRaw === "string"
87 ? Number(positionRaw)
88 : Number.NaN;
89 if (!Number.isFinite(position))
90 throw new Error(
91 "Cannot update task: missing numeric `position` on existing task.",
92 );
93
94 const title =
95 (patch.title as string) ??
96 (typeof existing.title === "string" ? existing.title : undefined);
97 if (!title) throw new Error("Cannot update task: missing title.");
98
99 const description =
100 patch.description !== undefined
101 ? patch.description === null
102 ? ""
103 : String(patch.description)
104 : existing.description == null
105 ? ""
106 : String(existing.description);
107
108 const status =
109 (patch.status as string) ??
110 (typeof existing.status === "string" ? existing.status : undefined);
111 if (!status) throw new Error("Cannot update task: missing status.");
112
113 const priorityRaw =
114 (patch.priority as string) ??
115 (typeof existing.priority === "string" ? existing.priority : undefined);
116 if (!priorityRaw || !isTaskPriority(priorityRaw))
117 throw new Error("Cannot update task: invalid or missing priority.");
118
119 const projectId =
120 (patch.projectId as string) ??
121 (typeof existing.projectId === "string" ? existing.projectId : undefined);
122 if (!projectId) throw new Error("Cannot update task: missing projectId.");
123
124 const userId =
125 patch.userId !== undefined
126 ? patch.userId === null
127 ? ""
128 : (patch.userId as string)
129 : typeof existing.userId === "string"
130 ? existing.userId
131 : undefined;
132
133 const startDate = formatOptionalIso(
134 patch.startDate !== undefined ? patch.startDate : existing.startDate,
135 );
136 const dueDate = formatOptionalIso(
137 patch.dueDate !== undefined ? patch.dueDate : existing.dueDate,
138 );
139
140 const body: Record<string, string | number | undefined> = {
141 title,
142 description,
143 status,
144 priority: priorityRaw,
145 projectId,
146 position,
147 };
148 if (startDate !== undefined) body.startDate = startDate;
149 if (dueDate !== undefined) body.dueDate = dueDate;
150 if (userId !== undefined) body.userId = userId;
151 return body;
152}
153
154const prioritySchema = z.enum([
155 "no-priority",
156 "low",
157 "medium",
158 "high",
159 "urgent",
160]);
161const nonEmptyString = z.string().trim().min(1);
162const optionalNonEmptyString = nonEmptyString.optional();
163const nullableOptionalNonEmptyString = nonEmptyString.nullable().optional();
164const isoDateTimeSchema = z.string().datetime({ offset: true });
165const optionalIsoDateTimeSchema = isoDateTimeSchema.optional();
166const nullableOptionalIsoDateTimeSchema = isoDateTimeSchema
167 .nullable()
168 .optional();
169const hexColorSchema = z
170 .string()
171 .regex(
172 /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,
173 "Expected a hex color like #FF6600",
174 );
175
176export function registerMcpTools(
177 server: McpServer,
178 baseUrl: string,
179 token: string,
180): void {
181 const client = new ApiClient(baseUrl, token);
182
183 server.registerTool(
184 "whoami",
185 {
186 description: "Return the current Kaneo session and user.",
187 inputSchema: z.object({}),
188 },
189 async () =>
190 run(() => client.json("/api/auth/get-session", { method: "GET" })),
191 );
192
193 server.registerTool(
194 "list_workspaces",
195 {
196 description: "List workspaces the signed-in user can access.",
197 inputSchema: z.object({}),
198 },
199 async () =>
200 run(() => client.json("/api/auth/organization/list", { method: "GET" })),
201 );
202
203 server.registerTool(
204 "list_projects",
205 {
206 description: "List projects in a workspace.",
207 inputSchema: z.object({
208 workspaceId: nonEmptyString.describe("Workspace ID"),
209 includeArchived: z
210 .boolean()
211 .optional()
212 .describe("Include archived projects"),
213 }),
214 },
215 async (args) => {
216 const qs = new URLSearchParams({ workspaceId: args.workspaceId });
217 if (args.includeArchived === true) qs.set("includeArchived", "true");
218 return run(() =>
219 client.json(`/api/project?${qs.toString()}`, { method: "GET" }),
220 );
221 },
222 );
223
224 server.registerTool(
225 "get_project",
226 {
227 description: "Get a single project by ID.",
228 inputSchema: z.object({ id: nonEmptyString }),
229 },
230 async (args) =>
231 run(() => client.json(`/api/project/${encodeURIComponent(args.id)}`)),
232 );
233
234 server.registerTool(
235 "create_project",
236 {
237 description: "Create a project in a workspace.",
238 inputSchema: z.object({
239 name: nonEmptyString,
240 workspaceId: nonEmptyString,
241 icon: nonEmptyString,
242 slug: nonEmptyString,
243 }),
244 },
245 async (args) =>
246 run(() =>
247 client.json("/api/project", {
248 method: "POST",
249 body: JSON.stringify({
250 name: args.name,
251 workspaceId: args.workspaceId,
252 icon: args.icon,
253 slug: args.slug,
254 }),
255 }),
256 ),
257 );
258
259 server.registerTool(
260 "update_project",
261 {
262 description:
263 "Update project metadata (PATCH-style: only provided fields are changed).",
264 inputSchema: z.object({
265 id: nonEmptyString,
266 name: optionalNonEmptyString,
267 icon: z.string().optional(),
268 slug: optionalNonEmptyString,
269 description: z.string().optional(),
270 isPublic: z.boolean().optional(),
271 }),
272 },
273 async (args) => {
274 const { id, ...patch } = args;
275 return run(async () => {
276 const existing = (await client.json(
277 `/api/project/${encodeURIComponent(id)}`,
278 { method: "GET" },
279 )) as Record<string, unknown>;
280 const name =
281 patch.name ??
282 (typeof existing.name === "string" ? existing.name : "");
283 if (!name) throw new Error("Cannot update project: missing name.");
284 const icon =
285 patch.icon !== undefined
286 ? patch.icon
287 : typeof existing.icon === "string"
288 ? existing.icon
289 : "Layout";
290 const slug =
291 patch.slug ??
292 (typeof existing.slug === "string" ? existing.slug : "");
293 if (!slug) throw new Error("Cannot update project: missing slug.");
294 const description =
295 patch.description !== undefined
296 ? patch.description
297 : typeof existing.description === "string"
298 ? existing.description
299 : "";
300 const isPublic =
301 patch.isPublic !== undefined
302 ? patch.isPublic
303 : typeof existing.isPublic === "boolean"
304 ? existing.isPublic
305 : false;
306 return client.json(`/api/project/${encodeURIComponent(id)}`, {
307 method: "PUT",
308 body: JSON.stringify({ name, icon, slug, description, isPublic }),
309 });
310 });
311 },
312 );
313
314 server.registerTool(
315 "list_tasks",
316 {
317 description: "List tasks for a project (optionally filtered/sorted).",
318 inputSchema: z.object({
319 projectId: nonEmptyString,
320 status: optionalNonEmptyString,
321 priority: prioritySchema.optional(),
322 assigneeId: optionalNonEmptyString,
323 page: z.number().int().positive().optional(),
324 limit: z.number().int().positive().optional(),
325 sortBy: z
326 .enum([
327 "createdAt",
328 "priority",
329 "dueDate",
330 "position",
331 "title",
332 "number",
333 ])
334 .optional(),
335 sortOrder: z.enum(["asc", "desc"]).optional(),
336 dueBefore: optionalIsoDateTimeSchema,
337 dueAfter: optionalIsoDateTimeSchema,
338 }),
339 },
340 async (args) => {
341 const { projectId, ...rest } = args;
342 const qs = new URLSearchParams();
343 for (const [k, v] of Object.entries(rest)) {
344 if (v !== undefined && v !== null) qs.set(k, String(v));
345 }
346 const q = qs.toString();
347 return run(() =>
348 client.json(
349 `/api/task/tasks/${encodeURIComponent(projectId)}${q ? `?${q}` : ""}`,
350 { method: "GET" },
351 ),
352 );
353 },
354 );
355
356 server.registerTool(
357 "get_task",
358 {
359 description: "Get a task by ID.",
360 inputSchema: z.object({ taskId: nonEmptyString }),
361 },
362 async (args) =>
363 run(() =>
364 client.json(`/api/task/${encodeURIComponent(args.taskId)}`, {
365 method: "GET",
366 }),
367 ),
368 );
369
370 server.registerTool(
371 "create_task",
372 {
373 description: "Create a task in a project.",
374 inputSchema: z.object({
375 projectId: nonEmptyString,
376 title: nonEmptyString,
377 description: z.string(),
378 priority: prioritySchema,
379 status: nonEmptyString,
380 startDate: optionalIsoDateTimeSchema,
381 dueDate: optionalIsoDateTimeSchema,
382 userId: optionalNonEmptyString,
383 }),
384 },
385 async (args) => {
386 const body: Record<string, string | undefined> = {
387 title: args.title,
388 description: args.description,
389 priority: args.priority,
390 status: args.status,
391 };
392 if (args.startDate !== undefined) body.startDate = args.startDate;
393 if (args.dueDate !== undefined) body.dueDate = args.dueDate;
394 if (args.userId !== undefined) body.userId = args.userId;
395 return run(() =>
396 client.json(`/api/task/${encodeURIComponent(args.projectId)}`, {
397 method: "POST",
398 body: JSON.stringify(body),
399 }),
400 );
401 },
402 );
403
404 server.registerTool(
405 "update_task",
406 {
407 description:
408 "Update a task (fetches current task, merges fields, then full update).",
409 inputSchema: z.object({
410 taskId: nonEmptyString,
411 title: optionalNonEmptyString,
412 description: z.string().nullable().optional(),
413 status: optionalNonEmptyString,
414 priority: prioritySchema.optional(),
415 projectId: optionalNonEmptyString,
416 position: z.number().optional(),
417 startDate: nullableOptionalIsoDateTimeSchema,
418 dueDate: nullableOptionalIsoDateTimeSchema,
419 userId: nullableOptionalNonEmptyString,
420 }),
421 },
422 async (args) => {
423 const { taskId, ...patch } = args;
424 return run(async () => {
425 const existing = (await client.json(
426 `/api/task/${encodeURIComponent(taskId)}`,
427 { method: "GET" },
428 )) as Record<string, unknown>;
429 const body = buildFullTaskUpdateBody(existing, patch);
430 return client.json(`/api/task/${encodeURIComponent(taskId)}`, {
431 method: "PUT",
432 body: JSON.stringify(body),
433 });
434 });
435 },
436 );
437
438 server.registerTool(
439 "move_task",
440 {
441 description:
442 "Move a task to another project (and optional column status).",
443 inputSchema: z.object({
444 taskId: nonEmptyString,
445 destinationProjectId: nonEmptyString,
446 destinationStatus: optionalNonEmptyString,
447 }),
448 },
449 async (args) =>
450 run(() =>
451 client.json(`/api/task/move/${encodeURIComponent(args.taskId)}`, {
452 method: "PUT",
453 body: JSON.stringify({
454 destinationProjectId: args.destinationProjectId,
455 ...(args.destinationStatus !== undefined
456 ? { destinationStatus: args.destinationStatus }
457 : {}),
458 }),
459 }),
460 ),
461 );
462
463 server.registerTool(
464 "update_task_status",
465 {
466 description: "Update only the status (column) of a task.",
467 inputSchema: z.object({ taskId: nonEmptyString, status: nonEmptyString }),
468 },
469 async (args) =>
470 run(() =>
471 client.json(`/api/task/status/${encodeURIComponent(args.taskId)}`, {
472 method: "PUT",
473 body: JSON.stringify({ status: args.status }),
474 }),
475 ),
476 );
477
478 server.registerTool(
479 "list_task_comments",
480 {
481 description: "List comments on a task.",
482 inputSchema: z.object({ taskId: nonEmptyString }),
483 },
484 async (args) =>
485 run(() =>
486 client.json(`/api/comment/${encodeURIComponent(args.taskId)}`, {
487 method: "GET",
488 }),
489 ),
490 );
491
492 server.registerTool(
493 "create_task_comment",
494 {
495 description: "Add a comment to a task.",
496 inputSchema: z.object({
497 taskId: nonEmptyString,
498 content: nonEmptyString,
499 }),
500 },
501 async (args) =>
502 run(() =>
503 client.json(`/api/comment/${encodeURIComponent(args.taskId)}`, {
504 method: "POST",
505 body: JSON.stringify({ content: args.content }),
506 }),
507 ),
508 );
509
510 server.registerTool(
511 "list_workspace_labels",
512 {
513 description: "List labels defined in a workspace.",
514 inputSchema: z.object({ workspaceId: nonEmptyString }),
515 },
516 async (args) =>
517 run(() =>
518 client.json(
519 `/api/label/workspace/${encodeURIComponent(args.workspaceId)}`,
520 { method: "GET" },
521 ),
522 ),
523 );
524
525 server.registerTool(
526 "create_label",
527 {
528 description:
529 "Create a label in a workspace (optionally attach to a task).",
530 inputSchema: z.object({
531 name: nonEmptyString,
532 color: hexColorSchema,
533 workspaceId: nonEmptyString,
534 taskId: optionalNonEmptyString,
535 }),
536 },
537 async (args) =>
538 run(() =>
539 client.json("/api/label", {
540 method: "POST",
541 body: JSON.stringify({
542 name: args.name,
543 color: args.color,
544 workspaceId: args.workspaceId,
545 ...(args.taskId !== undefined ? { taskId: args.taskId } : {}),
546 }),
547 }),
548 ),
549 );
550
551 server.registerTool(
552 "attach_label_to_task",
553 {
554 description: "Attach an existing label to a task.",
555 inputSchema: z.object({
556 labelId: nonEmptyString,
557 taskId: nonEmptyString,
558 }),
559 },
560 async (args) =>
561 run(() =>
562 client.json(`/api/label/${encodeURIComponent(args.labelId)}/task`, {
563 method: "PUT",
564 body: JSON.stringify({ taskId: args.taskId }),
565 }),
566 ),
567 );
568
569 server.registerTool(
570 "detach_label_from_task",
571 {
572 description: "Detach a label from its current task.",
573 inputSchema: z.object({ labelId: nonEmptyString }),
574 },
575 async (args) =>
576 run(() =>
577 client.json(`/api/label/${encodeURIComponent(args.labelId)}/task`, {
578 method: "DELETE",
579 }),
580 ),
581 );
582}