Skip to main content
Glama

Todoist MCP Server

subtask-handlers.ts12.8 kB
// Handler functions for subtask operations in the Todoist MCP server // Manages hierarchical task relationships and parent-child task structures import { TodoistApi } from "@doist/todoist-api-typescript"; import type { CreateSubtaskArgs, BulkCreateSubtasksArgs, ConvertToSubtaskArgs, PromoteSubtaskArgs, GetTaskHierarchyArgs, TaskNode, TaskHierarchy, TodoistTask, TasksResponse, } from "../types.js"; import { TaskNotFoundError, ValidationError } from "../errors.js"; import { validateTaskContent, validatePriority, validateDateString, } from "../validation.js"; import { extractArrayFromResponse } from "../utils/api-helpers.js"; import { ErrorHandler } from "../utils/error-handling.js"; import { SimpleCache } from "../cache.js"; import { toApiPriority } from "../utils/priority-mapper.js"; // Cache for task data (30 second TTL) const taskCache = new SimpleCache<TodoistTask[]>(30000); // Extended TaskNode interface for internal use interface ExtendedTaskNode extends TaskNode { totalTasks: number; completedTasks: number; } // Task creation data interface interface TaskCreationData { content: string; parentId?: string; projectId?: string; description?: string; dueString?: string; priority?: number; labels?: string[]; deadline?: { date: string }; sectionId?: string; } /** * Find a task by ID or name */ async function findTask( todoistClient: TodoistApi, args: { task_id?: string; task_name?: string } ): Promise<TodoistTask> { if (!args.task_id && !args.task_name) { throw new ValidationError("Either task_id or task_name is required"); } try { let task: TodoistTask | undefined; if (args.task_id) { const response = await todoistClient.getTask(args.task_id); task = response as TodoistTask; } else if (args.task_name) { const cachedTasks = taskCache.get("todoist_tasks"); let tasks: TodoistTask[]; if (cachedTasks) { tasks = cachedTasks; } else { const response = (await todoistClient.getTasks()) as TasksResponse; tasks = extractArrayFromResponse(response); taskCache.set("todoist_tasks", tasks); } const searchTerm = args.task_name.toLowerCase(); task = tasks.find((t) => t.content.toLowerCase().includes(searchTerm)); } if (!task) { throw new TaskNotFoundError( `Task not found: ${args.task_id || args.task_name}` ); } return task; } catch (error) { if (error instanceof TaskNotFoundError) { throw error; } throw ErrorHandler.handleAPIError("findTask", error); } } /** * Create a subtask under a parent task */ export async function handleCreateSubtask( todoistClient: TodoistApi, args: CreateSubtaskArgs ): Promise<{ subtask: TodoistTask; parent: TodoistTask }> { try { // Validate required fields validateTaskContent(args.content); // Find parent task const parent = await findTask(todoistClient, { task_id: args.parent_task_id, task_name: args.parent_task_name, }); // Validate optional fields if (args.priority !== undefined) { validatePriority(args.priority); } if (args.deadline_date) { validateDateString(args.deadline_date, "deadline"); } // Create subtask with parentId const subtaskData: TaskCreationData = { content: args.content, parentId: parent.id, projectId: parent.projectId, }; if (args.description) subtaskData.description = args.description; if (args.due_string) subtaskData.dueString = args.due_string; const apiPriority = toApiPriority(args.priority); if (apiPriority !== undefined) subtaskData.priority = apiPriority; if (args.labels) subtaskData.labels = args.labels; if (args.deadline_date) subtaskData.deadline = { date: args.deadline_date }; const subtask = (await todoistClient.addTask(subtaskData)) as TodoistTask; // Clear cache taskCache.clear(); return { subtask, parent }; } catch (error) { throw ErrorHandler.handleAPIError("createSubtask", error); } } /** * Convert an existing task to a subtask */ export async function handleConvertToSubtask( todoistClient: TodoistApi, args: ConvertToSubtaskArgs ): Promise<{ task: TodoistTask; parent: TodoistTask }> { try { // Find both tasks const [task, parent] = await Promise.all([ findTask(todoistClient, { task_id: args.task_id, task_name: args.task_name, }), findTask(todoistClient, { task_id: args.parent_task_id, task_name: args.parent_task_name, }), ]); // Check if already a subtask if (task.parentId) { throw new ValidationError(`Task "${task.content}" is already a subtask`); } // Delete the original task and recreate it as a subtask // This is a workaround since updateTask may not support parentId await todoistClient.deleteTask(task.id); const subtaskData: TaskCreationData = { content: task.content, parentId: parent.id, projectId: parent.projectId || task.projectId, }; // Preserve other task properties if (task.description) subtaskData.description = task.description; if (task.due?.string) subtaskData.dueString = task.due.string; if (task.priority) subtaskData.priority = task.priority; if (task.labels) subtaskData.labels = task.labels; if (task.deadline?.date) subtaskData.deadline = { date: task.deadline.date }; const updatedTask = (await todoistClient.addTask( subtaskData )) as TodoistTask; // Clear cache taskCache.clear(); return { task: updatedTask, parent }; } catch (error) { throw ErrorHandler.handleAPIError("convertToSubtask", error); } } /** * Promote a subtask to a main task */ export async function handlePromoteSubtask( todoistClient: TodoistApi, args: PromoteSubtaskArgs ): Promise<TodoistTask> { try { // Find subtask const subtask = await findTask(todoistClient, { task_id: args.subtask_id, task_name: args.subtask_name, }); // Check if it's actually a subtask if (!subtask.parentId) { throw new ValidationError(`Task "${subtask.content}" is not a subtask`); } // Delete the subtask and recreate it as a main task // This is a workaround since updateTask may not support parentId changes await todoistClient.deleteTask(subtask.id); const taskData: TaskCreationData = { content: subtask.content, projectId: args.project_id || subtask.projectId, }; // Preserve other task properties if (subtask.description) taskData.description = subtask.description; if (subtask.due?.string) taskData.dueString = subtask.due.string; if (subtask.priority) taskData.priority = subtask.priority; if (subtask.labels) taskData.labels = subtask.labels; if (subtask.deadline?.date) taskData.deadline = { date: subtask.deadline.date }; if (args.section_id) taskData.sectionId = args.section_id; const promotedTask = (await todoistClient.addTask(taskData)) as TodoistTask; // Clear cache taskCache.clear(); return promotedTask; } catch (error) { throw ErrorHandler.handleAPIError("promoteSubtask", error); } } /** * Get task hierarchy with all subtasks and parent tasks */ export async function handleGetTaskHierarchy( todoistClient: TodoistApi, args: GetTaskHierarchyArgs ): Promise<TaskHierarchy> { try { // Find the requested task const requestedTask = await findTask(todoistClient, { task_id: args.task_id, task_name: args.task_name, }); // Get all tasks for hierarchy building const response = (await todoistClient.getTasks()) as TasksResponse; const allTasks = extractArrayFromResponse(response) as TodoistTask[]; // Find the topmost parent by traversing upward let topmostParent = requestedTask; const visitedIds = new Set<string>(); // Prevent infinite loops while (topmostParent.parentId && !visitedIds.has(topmostParent.id)) { visitedIds.add(topmostParent.id); const parent = allTasks.find((t: unknown) => { const todoTask = t as TodoistTask; return todoTask.id === topmostParent.parentId; }); if (parent) { topmostParent = parent as TodoistTask; } else { // Parent not found, stop traversal break; } } // Build task tree recursively from the topmost parent async function buildTaskTree( task: TodoistTask, depth: number = 0, originalTaskId: string = "" ): Promise<ExtendedTaskNode> { // Find direct children const children = allTasks.filter((t: unknown) => { const todoTask = t as TodoistTask; return ( todoTask.parentId === task.id && (args.include_completed || !todoTask.isCompleted) ); }); // Recursively build child nodes const childNodes = await Promise.all( children.map((child) => buildTaskTree(child as TodoistTask, depth + 1, originalTaskId) ) ); // Calculate completion percentage const totalSubtasks = childNodes.reduce( (sum, node) => sum + node.totalTasks, childNodes.length ); const completedSubtasks = childNodes.reduce( (sum, node) => sum + node.completedTasks, childNodes.filter((n) => n.task.isCompleted).length ); const completionPercentage = totalSubtasks > 0 ? Math.round((completedSubtasks / totalSubtasks) * 100) : task.isCompleted ? 100 : 0; return { task, children: childNodes, depth, completionPercentage, totalTasks: 1 + totalSubtasks, completedTasks: (task.isCompleted ? 1 : 0) + completedSubtasks, isOriginalTask: task.id === originalTaskId, // Mark the originally requested task }; } const rootNode = await buildTaskTree(topmostParent, 0, requestedTask.id); return { root: rootNode, totalTasks: rootNode.totalTasks, completedTasks: rootNode.completedTasks, overallCompletion: Math.round( (rootNode.completedTasks / rootNode.totalTasks) * 100 ), originalTaskId: requestedTask.id, // Include the originally requested task ID }; } catch (error) { throw ErrorHandler.handleAPIError("getTaskHierarchy", error); } } /** * Bulk create multiple subtasks */ export async function handleBulkCreateSubtasks( todoistClient: TodoistApi, args: BulkCreateSubtasksArgs ): Promise<{ parent: TodoistTask; created: TodoistTask[]; failed: Array<{ task: (typeof args.subtasks)[number]; error: string }>; }> { try { // Validate subtasks array if (!args.subtasks || args.subtasks.length === 0) { throw new ValidationError("At least one subtask is required"); } // Find parent task const parent = await findTask(todoistClient, { task_id: args.parent_task_id, task_name: args.parent_task_name, }); const created: TodoistTask[] = []; const failed: Array<{ task: (typeof args.subtasks)[number]; error: string; }> = []; // Create subtasks sequentially for (const subtaskData of args.subtasks) { try { // Validate subtask data validateTaskContent(subtaskData.content); if (subtaskData.priority !== undefined) { validatePriority(subtaskData.priority); } if (subtaskData.deadline_date) { validateDateString(subtaskData.deadline_date, "deadline"); } // Create subtask const taskData: TaskCreationData = { content: subtaskData.content, parentId: parent.id, projectId: parent.projectId, }; if (subtaskData.description) taskData.description = subtaskData.description; if (subtaskData.due_string) taskData.dueString = subtaskData.due_string; const apiPriority = toApiPriority(subtaskData.priority); if (apiPriority !== undefined) taskData.priority = apiPriority; if (subtaskData.labels) taskData.labels = subtaskData.labels; if (subtaskData.deadline_date) { taskData.deadline = { date: subtaskData.deadline_date }; } const subtask = (await todoistClient.addTask(taskData)) as TodoistTask; created.push(subtask); } catch (error) { failed.push({ task: subtaskData, error: error instanceof Error ? error.message : "Unknown error", }); } } // Clear cache taskCache.clear(); return { parent, created, failed }; } catch (error) { throw ErrorHandler.handleAPIError("bulkCreateSubtasks", error); } }

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/greirson/mcp-todoist'

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