Skip to main content
Glama
kanban.ts21.5 kB
/** * Kanban tools for Basecamp MCP server (cards, columns, steps) */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { asyncPagedToArray } from "basecamp-client"; import { z } from "zod"; import { BasecampIdSchema } from "../schemas/common.js"; import { initializeBasecampClient } from "../utils/auth.js"; import { applyContentOperations, ContentOperationFields, htmlRules, validateContentOperations, } from "../utils/contentOperations.js"; import { handleBasecampError } from "../utils/errorHandlers.js"; import { serializePerson } from "../utils/serializers.js"; // Type for step input from user type StepInput = { id?: number; title: string; due_on?: string | null; assignee_ids?: number[]; completed?: boolean; }; // Type for current step from API type CurrentStep = { id: number; title: string; completed: boolean; due_on?: string | null; assignees?: Array<{ id: number }>; }; /** * Process step operations for a kanban card (create, update, delete, reposition, setCompletion) * Uses complete array replacement approach - steps not in desired array will be deleted */ async function processStepOperations( client: Awaited<ReturnType<typeof initializeBasecampClient>>, bucketId: number, cardId: number, currentSteps: CurrentStep[], desiredSteps: StepInput[], ): Promise<void> { // ===== VALIDATION ===== // Validate new steps have titles for (const step of desiredSteps) { if (!step.id && !step.title) { throw new Error("New steps (without id) must have a title"); } } // Check for duplicate IDs const stepIds = desiredSteps.filter((s) => s.id).map((s) => s.id!); if (new Set(stepIds).size !== stepIds.length) { throw new Error("Duplicate step IDs found in steps array"); } // Validate all step IDs exist on current card const currentStepIds = new Set(currentSteps.map((s) => s.id)); for (const id of stepIds) { if (!currentStepIds.has(id)) { const available = Array.from(currentStepIds).join(", "); throw new Error( `Step ID ${id} not found on card. ${ available ? `Available IDs: ${available}` : "No steps exist on this card." }`, ); } } // ===== IDENTIFY OPERATIONS ===== const desiredStepIds = new Set(stepIds); // Steps to delete: in current but not in desired const toDelete = currentSteps.filter((s) => !desiredStepIds.has(s.id)); // Steps to create: no ID provided const toCreate = desiredSteps.filter((s) => !s.id); // Steps to update/reposition/complete const toProcess = desiredSteps.filter((s) => s.id); // ===== DELETE OPERATIONS ===== for (const step of toDelete) { await client.recordings.trash({ params: { bucketId, recordingId: step.id }, }); } // ===== CREATE OPERATIONS ===== const createdStepIds: number[] = []; // Track new IDs for repositioning for (const step of toCreate) { const response = await client.cardTableSteps.create({ params: { bucketId, cardId }, body: { title: step.title, ...(step.due_on !== undefined ? { due_on: step.due_on || undefined } : {}), ...(step.assignee_ids ? { assignees: step.assignee_ids.join(",") } : {}), }, }); if (response.status !== 201 || !response.body) { throw new Error(`Failed to create step: ${step.title}`); } createdStepIds.push(response.body.id); // Set completion if specified if (step.completed !== undefined && step.completed) { await client.cardTableSteps.setCompletion({ params: { bucketId, stepId: response.body.id }, body: { completion: "on" }, }); } } // ===== UPDATE OPERATIONS ===== // Build a map of current steps for comparison const currentStepMap = new Map(currentSteps.map((s) => [s.id, s])); for (const step of toProcess) { const currentStep = currentStepMap.get(step.id!); if (!currentStep) continue; // Should not happen due to validation // Check what changed const titleChanged = step.title && step.title !== currentStep.title; const dueOnChanged = step.due_on !== undefined && step.due_on !== currentStep.due_on; const currentAssigneeIds = currentStep.assignees?.map((a) => a.id) || []; const desiredAssigneeIds = step.assignee_ids || []; const assigneesChanged = step.assignee_ids !== undefined && (currentAssigneeIds.length !== desiredAssigneeIds.length || !currentAssigneeIds.every((id, i) => id === desiredAssigneeIds[i])); // Only update if something changed if (titleChanged || dueOnChanged || assigneesChanged) { await client.cardTableSteps.update({ params: { bucketId, stepId: step.id! }, body: { ...(step.title ? { title: step.title } : {}), ...(step.due_on !== undefined ? { due_on: step.due_on || undefined } : {}), ...(step.assignee_ids ? { assignees: step.assignee_ids.join(",") } : {}), }, }); } // Handle completion status changes if ( step.completed !== undefined && step.completed !== currentStep.completed ) { await client.cardTableSteps.setCompletion({ params: { bucketId, stepId: step.id! }, body: { completion: step.completed ? "on" : "off" }, }); } } // ===== REPOSITION OPERATIONS ===== // Build final list of step IDs in desired order (mix of existing and new) const finalStepIds: number[] = []; let createIndex = 0; for (const step of desiredSteps) { if (step.id) { finalStepIds.push(step.id); } else { // This was a created step, use the ID we tracked if (createIndex < createdStepIds.length) { finalStepIds.push(createdStepIds[createIndex++]); } } } // Get current positions after all operations // We need to reposition if the order doesn't match const currentPositions = new Map( currentSteps .filter((s) => finalStepIds.includes(s.id)) .map((s, i) => [s.id, i]), ); // Reposition each step to match the desired array order for (let i = 0; i < finalStepIds.length; i++) { const stepId = finalStepIds[i]; const currentPos = currentPositions.get(stepId); // Only reposition if needed if (currentPos !== undefined && currentPos !== i) { await client.cardTableSteps.reposition({ params: { bucketId, cardId }, body: { source_id: stepId, position: i }, }); } } } export function registerKanbanTools(server: McpServer): void { server.registerTool( "basecamp_list_kanban_columns", { title: "List Kanban Columns", description: "List all columns in a kanban board.", inputSchema: { bucket_id: BasecampIdSchema, card_table_id: BasecampIdSchema, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, async (params) => { try { const client = await initializeBasecampClient(); const response = await client.cardTables.get({ params: { bucketId: params.bucket_id, cardTableId: params.card_table_id, }, }); if (response.status !== 200 || !response.body) { throw new Error("Failed to fetch card table"); } const columns = response.body.lists || []; return { content: [ { type: "text", text: JSON.stringify( columns.map((col) => ({ id: col.id, title: col.title, position: col.position, cards_count: col.cards_count, type: col.type, description: col.description, })), null, 2, ), }, ], }; } catch (error) { return { content: [{ type: "text", text: handleBasecampError(error) }], }; } }, ); server.registerTool( "basecamp_list_kanban_cards", { title: "List Kanban Cards", description: "List cards in a kanban column.", inputSchema: { bucket_id: BasecampIdSchema, column_id: BasecampIdSchema, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, async (params) => { try { const client = await initializeBasecampClient(); const cards = await asyncPagedToArray({ fetchPage: client.cardTableCards.list, request: { params: { bucketId: params.bucket_id, columnId: params.column_id }, query: {}, }, }); return { content: [ { type: "text", text: JSON.stringify( cards.map((c) => ({ id: c.id, title: c.title, due_on: c.due_on, url: c.app_url, comments_count: c.comments_count, created_at: c.created_at, creator: serializePerson(c.creator), assignees: c.assignees.map(serializePerson), steps: c.steps?.map((s) => ({ title: s.title, completed: s.completed, })), })), null, 2, ), }, ], }; } catch (error) { return { content: [{ type: "text", text: handleBasecampError(error) }], }; } }, ); server.registerTool( "basecamp_get_kanban_card", { title: "Get Kanban Card", description: "Get all details of a specific kanban card.", inputSchema: { bucket_id: BasecampIdSchema, card_id: BasecampIdSchema, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, async (params) => { try { const client = await initializeBasecampClient(); const response = await client.cardTableCards.get({ params: { bucketId: params.bucket_id, cardId: params.card_id, }, }); if (response.status !== 200 || !response.body) { throw new Error("Failed to fetch card"); } const card = response.body; return { content: [ { type: "text", text: JSON.stringify( { id: card.id, title: card.title, content: card.content, due_on: card.due_on, url: card.app_url, comments_count: card.comments_count, created_at: card.created_at, updated_at: card.updated_at, creator: serializePerson(card.creator), assignees: card.assignees.map(serializePerson), steps: card.steps?.map((s) => ({ id: s.id, title: s.title, completed: s.completed, })), }, null, 2, ), }, ], }; } catch (error) { return { content: [{ type: "text", text: handleBasecampError(error) }], }; } }, ); server.registerTool( "basecamp_create_kanban_card", { title: "Create Kanban Card", description: `Create a new card in a kanban column with optional checklist steps. ${htmlRules}`, inputSchema: { bucket_id: BasecampIdSchema, column_id: BasecampIdSchema, title: z.string().min(1), content: z.string().optional(), due_on: z.string().optional().describe("Due date in YYYY-MM-DD format"), assignee_ids: z .array(z.number()) .optional() .describe("Array of user IDs to assign to the card"), notify: z.boolean().optional().describe("Whether to notify assignees"), steps: z .array( z.object({ title: z.string().describe("Step title"), due_on: z .string() .nullable() .optional() .describe("Due date (YYYY-MM-DD) or null"), assignee_ids: z .array(z.number()) .optional() .describe("Array of user IDs to assign"), completed: z .boolean() .optional() .describe("Whether step is completed"), }), ) .optional() .describe("Array of steps to create. Array order defines position."), }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, }, async (params) => { try { const client = await initializeBasecampClient(); const response = await client.cardTableCards.create({ params: { bucketId: params.bucket_id, columnId: params.column_id }, body: { title: params.title, content: params.content, ...(params.due_on ? { due_on: params.due_on } : {}), ...(params.assignee_ids ? { assignee_ids: params.assignee_ids } : {}), ...(params.notify !== undefined ? { notify: params.notify } : {}), }, }); if (response.status !== 201 || !response.body) { throw new Error("Failed to create card"); } // Process step operations if provided (for new card, currentSteps is empty) if (params.steps) { await processStepOperations( client, params.bucket_id, response.body.id, [], // No current steps for a new card params.steps, ); } return { content: [ { type: "text", text: `Card created!\n\nID: ${response.body.id}\nTitle: ${response.body.title}`, }, ], }; } catch (error) { return { content: [{ type: "text", text: handleBasecampError(error) }], }; } }, ); server.registerTool( "basecamp_update_kanban_card", { title: "Update Kanban Card", description: `Update a kanban card including its steps. At least one field (title, content, partial content operations, or steps) must be provided. Use partial content operations when possible to save on token usage. ${htmlRules}`, inputSchema: { bucket_id: BasecampIdSchema, card_id: BasecampIdSchema, title: z.string().min(1).optional().describe("New card title"), ...ContentOperationFields, due_on: z .string() .optional() .describe("Due date (YYYY-MM-DD format) or null to clear"), notify: z .boolean() .optional() .describe("Whether to notify assignees of the update"), assignee_ids: z .array(z.number()) .optional() .describe("Array of user IDs to assign to the card"), steps: z .array( z.object({ id: z .number() .optional() .describe("Step ID for updates. Omit for new steps."), title: z.string().describe("Step title. Required for new steps."), due_on: z .string() .nullable() .optional() .describe("Due date (YYYY-MM-DD) or null to clear"), assignee_ids: z .array(z.number()) .optional() .describe("Array of user IDs to assign"), completed: z .boolean() .optional() .describe("Whether step is completed"), }), ) .optional() .describe( "Complete array of desired steps. Array order defines position. Steps not in array will be deleted.", ), }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, }, async (params) => { try { // Validate at least one operation is provided validateContentOperations(params, [ "title", "due_on", "notify", "assignee_ids", "steps", ]); const client = await initializeBasecampClient(); let finalContent: string | undefined; let currentCard: any = null; // Check if we need to fetch current content for partial operations const hasPartialOps = params.content_append || params.content_prepend || params.search_replace; if (hasPartialOps || params.content !== undefined || params.steps) { // Fetch current card if needed for partial operations or steps if (hasPartialOps || params.steps) { const currentResponse = await client.cardTableCards.get({ params: { bucketId: params.bucket_id, cardId: params.card_id, }, }); if (currentResponse.status !== 200 || !currentResponse.body) { throw new Error( `Failed to fetch current card: ${currentResponse.status}`, ); } currentCard = currentResponse.body; if (hasPartialOps) { const currentContent = currentCard.content || ""; finalContent = applyContentOperations(currentContent, params); } } else { // Full content replacement finalContent = params.content; } } const response = await client.cardTableCards.update({ params: { bucketId: params.bucket_id, cardId: params.card_id }, body: { ...(params.title ? { title: params.title } : {}), ...(finalContent !== undefined ? { content: finalContent } : {}), ...(params.due_on !== undefined ? { due_on: params.due_on } : {}), ...(params.notify !== undefined ? { notify: params.notify } : {}), ...(params.assignee_ids !== undefined ? { assignee_ids: params.assignee_ids } : {}), }, }); if (response.status !== 200 || !response.body) { throw new Error("Failed to update card"); } // Process step operations if provided if (params.steps) { await processStepOperations( client, params.bucket_id, params.card_id, currentCard?.steps || [], params.steps, ); } return { content: [ { type: "text", text: `Card updated successfully!\n\nID: ${response.body.id}\nTitle: ${response.body.title}`, }, ], }; } catch (error) { return { content: [{ type: "text", text: handleBasecampError(error) }], }; } }, ); server.registerTool( "basecamp_move_kanban_card", { title: "Move Kanban Card", description: "Move a kanban card to a different column and/or position within that column.", inputSchema: { bucket_id: BasecampIdSchema, card_id: BasecampIdSchema, column_id: BasecampIdSchema, position: z .number() .int() .nonnegative() .optional() .describe( "Position within the destination column (zero-indexed). If not specified, card will be added to the end of the column.", ), }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, }, async (params) => { try { const client = await initializeBasecampClient(); const response = await client.cardTableCards.move({ params: { bucketId: params.bucket_id, cardId: params.card_id }, body: { column_id: params.column_id, ...(params.position !== undefined ? { position: params.position } : {}), }, }); if (response.status !== 204) { throw new Error("Failed to move card"); } return { content: [ { type: "text", text: `Card moved successfully to column ${params.column_id}${ params.position !== undefined ? ` at position ${params.position}` : "" }!`, }, ], }; } catch (error) { return { content: [{ type: "text", text: handleBasecampError(error) }], }; } }, ); }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/stefanoverna/basecamp-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server