Skip to main content
Glama
bulk-tasks.ts22.3 kB
/** * T016-T018: Bulk operations tool for todoist_bulk_tasks MCP tool * Implements bulk update, complete, uncomplete, and move operations */ import { z } from 'zod'; import { TodoistApiService, SyncCommand, SyncResponse, } from '../services/todoist-api.js'; import { OperationResult, BulkOperationSummary, BulkTasksResponse, SyncError, } from '../types/bulk-operations.js'; import { APIConfiguration, TodoistTask } from '../types/todoist.js'; import { ValidationError } from '../types/errors.js'; import { z as zodLib } from 'zod'; /** * T016: Bulk operation input schema * Matches contracts/mcp-tool-schema.json specification */ const BulkOperationInputSchema = z .object({ action: z.enum(['update', 'complete', 'uncomplete', 'move', 'delete']), task_ids: z.array(z.string()).min(1, 'At least one task ID required'), // Optional update fields project_id: z.string().optional(), section_id: z.string().optional(), parent_id: z.string().optional(), order: z.number().optional(), labels: z.array(z.string()).optional(), priority: z.number().int().min(1).max(4).optional(), assignee_id: z.number().optional(), due_string: z.string().optional(), due_date: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .optional(), due_datetime: z.string().datetime().optional(), due_lang: z.string().optional(), duration: z.number().optional(), duration_unit: z.enum(['minute', 'day']).optional(), deadline_date: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .optional(), }) .passthrough() .refine( data => { // Ensure no disallowed fields const disallowed = ['content', 'description', 'comments']; return !disallowed.some(field => field in data); }, { message: 'Cannot modify content, description, or comments in bulk operations', } ); type BulkOperationInput = z.infer<typeof BulkOperationInputSchema>; /** * T016: Error response structure */ interface BulkErrorResponse { success: false; error: { code: string; message: string; }; } /** * TodoistBulkTasksTool - MCP tool for bulk operations on Todoist tasks * T016: Main tool class implementation */ export class TodoistBulkTasksTool { private apiService: TodoistApiService; constructor( config: APIConfiguration, dependencies?: { apiService?: TodoistApiService; } ) { this.apiService = dependencies?.apiService || new TodoistApiService(config); } /** * T016: MCP tool definition * Matches contracts/mcp-tool-schema.json */ static getToolDefinition() { return { name: 'todoist_bulk_tasks', description: 'Perform bulk operations on up to 50 Todoist tasks. Supports update, complete, uncomplete, move, and delete operations. Automatically deduplicates task IDs. Uses partial execution mode (continues on individual task failures). Returns individual results for each task with success/failure status.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['update', 'complete', 'uncomplete', 'move', 'delete'], description: 'Operation type to perform on all selected tasks', }, task_ids: { type: 'array', items: { type: 'string' }, description: 'Array of Todoist task IDs (1-50 items). Duplicate IDs are automatically removed before processing.', minItems: 1, maxItems: 50, }, project_id: { type: 'string', description: 'Project ID for move/update operations', }, section_id: { type: 'string', description: 'Section ID for move/update operations', }, parent_id: { type: 'string', description: 'Parent task ID for move/update operations', }, order: { type: 'number', description: 'Task order for update operations', }, labels: { type: 'array', items: { type: 'string' }, description: 'Label names for update operations', }, priority: { type: 'number', minimum: 1, maximum: 4, description: 'Priority level for update operations (1-4)', }, assignee_id: { type: 'number', description: 'User ID for update operations', }, due_string: { type: 'string', description: 'Natural language due date for update operations', }, due_date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'Due date in YYYY-MM-DD format for update operations', }, due_datetime: { type: 'string', format: 'date-time', description: 'Due datetime in ISO 8601 format for update operations', }, due_lang: { type: 'string', description: 'Language code for parsing due_string', }, duration: { type: 'number', description: 'Task duration value for update operations', }, duration_unit: { type: 'string', enum: ['minute', 'day'], description: 'Duration unit for update operations', }, deadline_date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'Deadline date in YYYY-MM-DD format for update operations', }, }, required: ['action', 'task_ids'], additionalProperties: false, }, }; } /** * T016: Main execution method * Validates input, deduplicates task IDs, and dispatches to action handlers */ async execute( params: unknown ): Promise<BulkTasksResponse | BulkErrorResponse> { const startTime = Date.now(); try { // T016: Input validation const validated = BulkOperationInputSchema.parse(params); // T016: Deduplication logic const originalCount = validated.task_ids.length; const uniqueTaskIds = Array.from(new Set(validated.task_ids)); const deduplicatedCount = uniqueTaskIds.length; const deduplicationApplied = originalCount !== deduplicatedCount; // T016: Pre-validation - max 50 tasks after deduplication if (deduplicatedCount > 50) { return { success: false, error: { code: 'INVALID_PARAMS', message: `Maximum 50 tasks allowed, received ${deduplicatedCount}`, }, }; } // T016: Dispatch to action handlers let summary: BulkOperationSummary; switch (validated.action) { case 'update': summary = await this.handleUpdateAction(uniqueTaskIds, validated); break; case 'complete': summary = await this.handleCompleteAction(uniqueTaskIds); break; case 'uncomplete': summary = await this.handleUncompleteAction(uniqueTaskIds); break; case 'move': summary = await this.handleMoveAction(uniqueTaskIds, validated); break; case 'delete': summary = await this.handleDeleteAction(uniqueTaskIds); break; default: throw new ValidationError( `Invalid action: ${(validated as { action: string }).action}` ); } const executionTime = Date.now() - startTime; // T016: Return formatted response with metadata return { success: true, data: summary, metadata: { deduplication_applied: deduplicationApplied, original_count: originalCount, deduplicated_count: deduplicatedCount, execution_time_ms: executionTime, }, }; } catch (error) { // Handle validation errors if (error instanceof zodLib.ZodError) { const firstError = error.errors[0]; return { success: false, error: { code: 'INVALID_PARAMS', message: firstError.message || 'Validation error', }, }; } // Handle validation errors if (error instanceof ValidationError) { return { success: false, error: { code: 'INVALID_PARAMS', message: error.message, }, }; } // Handle other errors return { success: false, error: { code: 'INTERNAL_ERROR', message: error instanceof Error ? error.message : 'Unknown error occurred', }, }; } } /** * T017: Handle update action * Builds item_update commands with field updates * Uses hybrid approach: Sync API for most fields, REST API for deadline updates */ private async handleUpdateAction( taskIds: string[], params: BulkOperationInput ): Promise<BulkOperationSummary> { // Build update arguments from params (excluding deadline) const updateArgs: Record<string, unknown> = {}; if (params.project_id !== undefined) updateArgs.project_id = params.project_id; if (params.section_id !== undefined) updateArgs.section_id = params.section_id; if (params.parent_id !== undefined) updateArgs.parent_id = params.parent_id; if (params.order !== undefined) updateArgs.order = params.order; if (params.labels !== undefined) updateArgs.labels = params.labels; if (params.priority !== undefined) updateArgs.priority = params.priority; if (params.assignee_id !== undefined) updateArgs.assignee_id = params.assignee_id; // Handle due date fields if ( params.due_string !== undefined || params.due_date !== undefined || params.due_datetime !== undefined ) { const dueObj: Record<string, unknown> = {}; if (params.due_string !== undefined) dueObj.string = params.due_string; if (params.due_date !== undefined) dueObj.date = params.due_date; if (params.due_datetime !== undefined) dueObj.datetime = params.due_datetime; if (params.due_lang !== undefined) dueObj.lang = params.due_lang; updateArgs.due = dueObj; } // Handle duration if (params.duration !== undefined && params.duration_unit !== undefined) { updateArgs.duration = { amount: params.duration, unit: params.duration_unit, }; } // Hybrid approach: Process Sync API updates first, then deadline via REST API let syncResponse: SyncResponse | null = null; // Step 1: Execute Sync API batch if there are non-deadline updates if (Object.keys(updateArgs).length > 0) { const commands: SyncCommand[] = taskIds.map((taskId, index) => ({ type: 'item_update', uuid: `cmd-${index}-task-${taskId}`, args: { id: taskId, ...updateArgs, }, })); syncResponse = await this.apiService.executeBatch(commands); } else { // No Sync API updates needed, create a synthetic success response syncResponse = { sync_status: Object.fromEntries( taskIds.map((taskId, index) => [`cmd-${index}-task-${taskId}`, 'ok']) ), temp_id_mapping: {}, full_sync: false, }; } // Step 2: Execute deadline updates via REST API (one by one) const deadlineResults: Map<string, { success: boolean; error?: string }> = new Map(); if (params.deadline_date !== undefined) { await Promise.all( taskIds.map(async taskId => { try { // API service accepts deadline as string and transforms it to deadline_date // Type assertion needed because TodoistTask.deadline is typed as TodoistDeadline interface // but updateTask method runtime code accepts string (see todoist-api.ts:503-505) await this.apiService.updateTask(taskId, { deadline: params.deadline_date, } as Partial<TodoistTask>); deadlineResults.set(taskId, { success: true }); } catch (error) { deadlineResults.set(taskId, { success: false, error: error instanceof Error ? error.message : 'Deadline update failed', }); } }) ); } // Step 3: Merge results from Sync API and REST API deadline updates const mergedSummary = this.mergeSyncAndDeadlineResults( syncResponse, taskIds, deadlineResults ); // Fetch actual task values for verification const summary = await this.buildBulkOperationSummaryWithVerification( mergedSummary, taskIds, params ); return summary; } /** * Merge results from Sync API batch and REST API deadline updates * A task is only successful if BOTH operations succeeded */ private mergeSyncAndDeadlineResults( syncResponse: SyncResponse, taskIds: string[], deadlineResults: Map<string, { success: boolean; error?: string }> ): SyncResponse { // If no deadline updates were made, return Sync API response as-is if (deadlineResults.size === 0) { return syncResponse; } // Merge Sync API and deadline update results const mergedStatus: Record<string, 'ok' | SyncError> = {}; taskIds.forEach((taskId, index) => { const uuid = `cmd-${index}-task-${taskId}`; const syncStatus = syncResponse.sync_status[uuid]; const deadlineStatus = deadlineResults.get(taskId); // Both operations must succeed for overall success if (syncStatus === 'ok' && deadlineStatus?.success) { mergedStatus[uuid] = 'ok'; } else if (syncStatus !== 'ok') { // Sync API failed mergedStatus[uuid] = syncStatus as SyncError; } else if (deadlineStatus && !deadlineStatus.success) { // Deadline update failed mergedStatus[uuid] = { error: 'DEADLINE_UPDATE_FAILED', error_message: deadlineStatus.error || 'Failed to update deadline', }; } }); return { ...syncResponse, sync_status: mergedStatus, }; } /** * T017: Handle complete action * Builds item_complete commands */ private async handleCompleteAction( taskIds: string[] ): Promise<BulkOperationSummary> { // T017: Generate SyncCommand array (only id in args) const commands: SyncCommand[] = taskIds.map((taskId, index) => ({ type: 'item_complete', uuid: `cmd-${index}-task-${taskId}`, args: { id: taskId, }, })); // T017: Call executeBatch and map results const response = await this.apiService.executeBatch(commands); return this.buildBulkOperationSummary(response, taskIds); } /** * T017: Handle uncomplete action * Builds item_uncomplete commands */ private async handleUncompleteAction( taskIds: string[] ): Promise<BulkOperationSummary> { // T017: Generate SyncCommand array (only id in args) const commands: SyncCommand[] = taskIds.map((taskId, index) => ({ type: 'item_uncomplete', uuid: `cmd-${index}-task-${taskId}`, args: { id: taskId, }, })); // T017: Call executeBatch and map results const response = await this.apiService.executeBatch(commands); return this.buildBulkOperationSummary(response, taskIds); } /** * T017: Handle move action * Uses individual moveTask calls (Sync API per task) since bulk Sync and REST update don't work */ private async handleMoveAction( taskIds: string[], params: BulkOperationInput ): Promise<BulkOperationSummary> { // Build destination object - only one destination allowed per Todoist API const destination: { project_id?: string; section_id?: string; parent_id?: string; } = {}; if (params.project_id !== undefined) destination.project_id = params.project_id; if (params.section_id !== undefined) destination.section_id = params.section_id; if (params.parent_id !== undefined) destination.parent_id = params.parent_id; // Execute move operations using moveTask (one by one, each uses Sync API) const moveResults = await Promise.all( taskIds.map(async taskId => { try { await this.apiService.moveTask(taskId, destination); return { task_id: taskId, success: true, error: null, resource_uri: `todoist://task/${taskId}`, }; } catch (error) { return { task_id: taskId, success: false, error: error instanceof Error ? error.message : 'Move operation failed', resource_uri: `todoist://task/${taskId}`, }; } }) ); // Build summary from individual results const successful = moveResults.filter(r => r.success).length; const failed = moveResults.filter(r => !r.success).length; return { total_tasks: taskIds.length, successful, failed, results: moveResults, }; } /** * Handle delete action * Uses individual deleteTask calls (REST API per task) for reliable deletion * Returns individual results for each task to support partial execution mode */ private async handleDeleteAction( taskIds: string[] ): Promise<BulkOperationSummary> { // Execute delete operations using deleteTask (one by one) const deleteResults = await Promise.all( taskIds.map(async taskId => { try { await this.apiService.deleteTask(taskId); return { task_id: taskId, success: true, error: null, resource_uri: `todoist://task/${taskId}`, }; } catch (error) { return { task_id: taskId, success: false, error: error instanceof Error ? error.message : 'Delete operation failed', resource_uri: `todoist://task/${taskId}`, }; } }) ); // Build summary from individual results const successful = deleteResults.filter(r => r.success).length; const failed = deleteResults.filter(r => !r.success).length; return { total_tasks: taskIds.length, successful, failed, results: deleteResults, }; } /** * T018: Map sync_status to OperationResult array and build summary * Helper method that maps Todoist Sync API response to our result format */ private buildBulkOperationSummary( syncResponse: SyncResponse, taskIds: string[] ): BulkOperationSummary { // T018: Map each task to an OperationResult const results: OperationResult[] = taskIds.map((taskId, index) => { const uuid = `cmd-${index}-task-${taskId}`; const status = syncResponse.sync_status[uuid]; // T018: Map "ok" to success if (status === 'ok') { return { task_id: taskId, success: true, error: null, resource_uri: `todoist://task/${taskId}`, }; } // T018: Map error object to failure const errorObj = status as SyncError; return { task_id: taskId, success: false, error: errorObj.error_message || errorObj.error || 'Unknown error', resource_uri: `todoist://task/${taskId}`, }; }); // T017: Build summary with counts const successful = results.filter(r => r.success).length; const failed = results.filter(r => !r.success).length; return { total_tasks: taskIds.length, successful, failed, results, }; } /** * Build summary with verification by fetching actual task values * This helps confirm that updates were actually applied */ private async buildBulkOperationSummaryWithVerification( syncResponse: SyncResponse, taskIds: string[], params: BulkOperationInput ): Promise<BulkOperationSummary> { // First build the basic summary const baseSummary = this.buildBulkOperationSummary(syncResponse, taskIds); // For successful operations, fetch actual task values for verification const verifiedResults = await Promise.all( baseSummary.results.map(async result => { if (!result.success) { return result; // Don't verify failed operations } try { // Fetch the actual task from Todoist const task = await this.apiService.getTask(result.task_id); // Build verified_values object with requested fields const verifiedValues: Record<string, unknown> = {}; if (params.due_string || params.due_date || params.due_datetime) { verifiedValues.due = task.due; } if (params.deadline_date !== undefined) { verifiedValues.deadline = task.deadline; } if (params.priority !== undefined) { verifiedValues.priority = task.priority; } if (params.labels !== undefined) { verifiedValues.labels = task.labels; } if (params.project_id !== undefined) { verifiedValues.project_id = task.project_id; } if (params.section_id !== undefined) { verifiedValues.section_id = task.section_id; } if (params.parent_id !== undefined) { verifiedValues.parent_id = task.parent_id; } if (params.duration !== undefined) { // eslint-disable-next-line @typescript-eslint/no-explicit-any verifiedValues.duration = (task as any).duration; } return { ...result, verified_values: verifiedValues, }; } catch (error) { // If verification fails, return original result (silently) // Error is logged for debugging but doesn't affect the response return result; } }) ); return { ...baseSummary, results: verifiedResults, }; } }

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

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