Skip to main content
Glama
todos.ts10.9 kB
/** * TODO tools for Basecamp MCP server */ 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"; export function registerTodoTools(server: McpServer): void { server.registerTool( "basecamp_get_todoset", { title: "Get Basecamp Todo Set", description: "Get todo set container for a project. Returns todo lists and groups.", inputSchema: { bucket_id: BasecampIdSchema, todoset_id: BasecampIdSchema, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, async (params) => { try { const client = await initializeBasecampClient(); const responseTodoSet = await client.todoSets.get({ params: { bucketId: params.bucket_id, todosetId: params.todoset_id }, }); if (responseTodoSet.status !== 200 || !responseTodoSet.body) { throw new Error("Failed to fetch todo set"); } const todoLists = await asyncPagedToArray({ fetchPage: client.todoLists.list, request: { params: { bucketId: params.bucket_id, todosetId: params.todoset_id, }, query: {}, }, }); const todoSet = responseTodoSet.body; return { content: [ { type: "text", text: JSON.stringify( { id: todoSet.id, name: todoSet.name, url: todoSet.app_url, completed: todoSet.completed, todoLists: todoLists.map((list) => ({ id: list.id, url: list.app_url, title: list.title, completed: list.completed, position: list.position, })), }, null, 2, ), }, ], }; } catch (error) { return { content: [{ type: "text", text: handleBasecampError(error) }], }; } }, ); server.registerTool( "basecamp_list_todos", { title: "List Basecamp Todos", description: "List todos in a todo list. Filter by status: 'active' or 'archived'.", inputSchema: { bucket_id: BasecampIdSchema, todolist_id: BasecampIdSchema, status: z.enum(["active", "archived"]).default("active").optional(), completed: z.enum(["true"]).optional(), }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, async (params) => { try { const client = await initializeBasecampClient(); const todos = await asyncPagedToArray({ fetchPage: client.todos.list, request: { params: { bucketId: params.bucket_id, todolistId: params.todolist_id, }, query: { status: params.status, completed: params.completed }, }, }); return { content: [ { type: "text", text: JSON.stringify( { count: todos.length, todos: todos.map((t) => ({ id: t.id, title: t.content, completed: t.completed, assignees: t.assignees.map(serializePerson), })), }, null, 2, ), }, ], }; } catch (error) { return { content: [{ type: "text", text: handleBasecampError(error) }], }; } }, ); server.registerTool( "basecamp_create_todo", { title: "Create Basecamp Todo", description: `Create a new todo item in a todo list. ${htmlRules}`, inputSchema: { bucket_id: BasecampIdSchema, todolist_id: BasecampIdSchema, title: z.string().min(1), content: z.string().optional(), assignee_ids: z .array(BasecampIdSchema) .optional() .describe("Array of person IDs to assign to this todo"), }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, }, async (params) => { try { const client = await initializeBasecampClient(); const response = await client.todos.create({ params: { bucketId: params.bucket_id, todolistId: params.todolist_id, }, body: { content: params.title, description: params.content, assignee_ids: params.assignee_ids, }, }); if (response.status !== 201 || !response.body) { throw new Error("Failed to create todo"); } return { content: [ { type: "text", text: `Todo created!\n\nID: ${response.body.id}\nContent: ${response.body.content}`, }, ], }; } catch (error) { return { content: [{ type: "text", text: handleBasecampError(error) }], }; } }, ); server.registerTool( "basecamp_complete_todo", { title: "Complete Basecamp Todo", description: "Mark a todo as completed.", inputSchema: { bucket_id: BasecampIdSchema, todo_id: BasecampIdSchema, }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, async (params) => { try { const client = await initializeBasecampClient(); await client.todos.complete({ params: { bucketId: params.bucket_id, todoId: params.todo_id }, }); return { content: [{ type: "text", text: "Todo marked as completed!" }], }; } catch (error) { return { content: [{ type: "text", text: handleBasecampError(error) }], }; } }, ); server.registerTool( "basecamp_uncomplete_todo", { title: "Uncomplete Basecamp Todo", description: "Mark a todo as incomplete (undo completion).", inputSchema: { bucket_id: BasecampIdSchema, todo_id: BasecampIdSchema, }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, async (params) => { try { const client = await initializeBasecampClient(); await client.todos.uncomplete({ params: { bucketId: params.bucket_id, todoId: params.todo_id }, }); return { content: [{ type: "text", text: "Todo marked as incomplete!" }], }; } catch (error) { return { content: [{ type: "text", text: handleBasecampError(error) }], }; } }, ); server.registerTool( "basecamp_update_todo", { title: "Update Basecamp Todo", description: `Update a todo item. Use partial content operations when possible to save on token usage. ${htmlRules}`, inputSchema: { bucket_id: BasecampIdSchema, todo_id: BasecampIdSchema, title: z.string().optional().describe("New todo title"), assignee_ids: z .array(BasecampIdSchema) .optional() .describe("Array of person IDs to assign to this todo"), ...ContentOperationFields, }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, async (params) => { try { // Validate at least one operation is provided validateContentOperations(params, ["title", "assignee_ids"]); const client = await initializeBasecampClient(); // Fetch current todo to get existing values const currentResponse = await client.todos.get({ params: { bucketId: params.bucket_id, todoId: params.todo_id, }, }); if (currentResponse.status !== 200 || !currentResponse.body) { throw new Error( `Failed to fetch current todo: ${currentResponse.status}`, ); } // Determine final title (maps to Basecamp's content field) const finalTitle = params.title ?? currentResponse.body.content; // Determine final content (maps to Basecamp's description field) let finalContent: string | undefined; const hasPartialOps = params.content_append || params.content_prepend || params.search_replace; if (hasPartialOps) { const currentContent = currentResponse.body.description || ""; const result = applyContentOperations(currentContent, params); if (result === undefined) { throw new Error("Content operations resulted in undefined content"); } finalContent = result; } else if (params.content !== undefined) { // Full content replacement finalContent = params.content; } const response = await client.todos.update({ params: { bucketId: params.bucket_id, todoId: params.todo_id, }, body: { content: finalTitle, ...(finalContent !== undefined ? { description: finalContent } : {}), ...(params.assignee_ids !== undefined ? { assignee_ids: params.assignee_ids } : {}), }, }); if (response.status !== 200 || !response.body) { throw new Error("Failed to update todo"); } return { content: [ { type: "text", text: `Todo updated!\n\nID: ${response.body.id}\nContent: ${response.body.content}`, }, ], }; } 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