kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at cd7cada2f86b4e866a15b4323bb8d6d7ab5bba8b 582 lines 17 kB view raw
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}