UNPKG

@s.pawel940/clickup-mcp-server

Version:

ClickUp MCP Server (Read-Only Fork) - Integrate ClickUp tasks with AI through Model Context Protocol

498 lines (497 loc) 20.9 kB
/** * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com> * SPDX-License-Identifier: MIT * * ClickUp Task Service - Core Module * * Handles core operations related to tasks in ClickUp, including: * - Base service initialization * - Core utility methods * - Basic CRUD operations */ import { BaseClickUpService, ErrorCode, ClickUpServiceError } from '../base.js'; import { ListService } from '../list.js'; /** * Core TaskService class providing basic task operations */ export class TaskServiceCore extends BaseClickUpService { constructor(apiKey, teamId, baseUrl, workspaceService, readOnlyMode = false) { super(apiKey, teamId, baseUrl); this.workspaceService = null; // Cache for validated tasks and lists this.validationCache = { tasks: new Map(), lists: new Map() }; // Cache for task name to ID mapping this.nameToIdCache = new Map(); // Cache TTL in milliseconds (5 minutes) this.CACHE_TTL = 5 * 60 * 1000; this.READ_ONLY_MODE = readOnlyMode; if (workspaceService) { this.workspaceService = workspaceService; this.logOperation('constructor', { usingSharedWorkspaceService: true }); } // Initialize list service for list lookups this.listService = new ListService(apiKey, teamId, baseUrl, this.workspaceService); this.logOperation('constructor', { initialized: true }); } /** * Helper method to handle errors consistently * @param error The error that occurred * @param message Optional custom error message * @returns A ClickUpServiceError */ handleError(error, message) { if (error instanceof ClickUpServiceError) { return error; } return new ClickUpServiceError(message || `Task service error: ${error.message}`, ErrorCode.UNKNOWN, error); } /** * Build URL parameters from task filters * @param filters Task filters to convert to URL parameters * @returns URLSearchParams object */ buildTaskFilterParams(filters) { const params = new URLSearchParams(); // Add all filters to the query parameters if (filters.include_closed) params.append('include_closed', String(filters.include_closed)); if (filters.subtasks) params.append('subtasks', String(filters.subtasks)); if (filters.page) params.append('page', String(filters.page)); if (filters.order_by) params.append('order_by', filters.order_by); if (filters.reverse) params.append('reverse', String(filters.reverse)); // Array parameters if (filters.statuses && filters.statuses.length > 0) { filters.statuses.forEach(status => params.append('statuses[]', status)); } if (filters.assignees && filters.assignees.length > 0) { filters.assignees.forEach(assignee => params.append('assignees[]', assignee)); } // Team tasks endpoint specific parameters if (filters.tags && filters.tags.length > 0) { filters.tags.forEach(tag => params.append('tags[]', tag)); } if (filters.list_ids && filters.list_ids.length > 0) { filters.list_ids.forEach(id => params.append('list_ids[]', id)); } if (filters.folder_ids && filters.folder_ids.length > 0) { filters.folder_ids.forEach(id => params.append('folder_ids[]', id)); } if (filters.space_ids && filters.space_ids.length > 0) { filters.space_ids.forEach(id => params.append('space_ids[]', id)); } if (filters.archived !== undefined) params.append('archived', String(filters.archived)); if (filters.include_closed_lists !== undefined) params.append('include_closed_lists', String(filters.include_closed_lists)); if (filters.include_archived_lists !== undefined) params.append('include_archived_lists', String(filters.include_archived_lists)); if (filters.include_compact_time_entries !== undefined) params.append('include_compact_time_entries', String(filters.include_compact_time_entries)); // Date filters if (filters.due_date_gt) params.append('due_date_gt', String(filters.due_date_gt)); if (filters.due_date_lt) params.append('due_date_lt', String(filters.due_date_lt)); if (filters.date_created_gt) params.append('date_created_gt', String(filters.date_created_gt)); if (filters.date_created_lt) params.append('date_created_lt', String(filters.date_created_lt)); if (filters.date_updated_gt) params.append('date_updated_gt', String(filters.date_updated_gt)); if (filters.date_updated_lt) params.append('date_updated_lt', String(filters.date_updated_lt)); // Handle custom fields if present if (filters.custom_fields) { Object.entries(filters.custom_fields).forEach(([key, value]) => { params.append(`custom_fields[${key}]`, String(value)); }); } return params; } /** * Extract priority value from a task * @param task The task to extract priority from * @returns TaskPriority or null */ extractPriorityValue(task) { if (!task.priority || !task.priority.id) { return null; } const priorityValue = parseInt(task.priority.id); // Ensure it's in the valid range 1-4 if (isNaN(priorityValue) || priorityValue < 1 || priorityValue > 4) { return null; } return priorityValue; } /** * Extract task data for creation/duplication * @param task The source task * @param nameOverride Optional override for the task name * @returns CreateTaskData object */ extractTaskData(task, nameOverride) { return { name: nameOverride || task.name, description: task.description || '', status: task.status?.status, priority: this.extractPriorityValue(task), due_date: task.due_date ? Number(task.due_date) : undefined, assignees: task.assignees?.map(a => a.id) || [] }; } /** * Create a new task in the specified list * @param listId The ID of the list to create the task in * @param taskData The data for the new task * @returns The created task */ async createTask(listId, taskData) { if (this.READ_ONLY_MODE) { throw new ClickUpServiceError('Server is in read-only mode. Data modification is not allowed.', ErrorCode.FORBIDDEN); } this.logOperation('createTask', { listId, ...taskData }); try { return await this.makeRequest(async () => { const response = await this.client.post(`/list/${listId}/task`, taskData); // Handle both JSON and text responses const data = response.data; if (typeof data === 'string') { // If we got a text response, try to extract task ID from common patterns const idMatch = data.match(/task.*?(\w{9})/i); if (idMatch) { // If we found an ID, fetch the full task details return await this.getTask(idMatch[1]); } throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data); } return data; }); } catch (error) { throw this.handleError(error, 'Failed to create task'); } } /** * Get a task by its ID * @param taskId The ID of the task to retrieve * @returns The task */ async getTask(taskId) { this.logOperation('getTask', { taskId }); try { return await this.makeRequest(async () => { const response = await this.client.get(`/task/${taskId}`); // Handle both JSON and text responses const data = response.data; if (typeof data === 'string') { throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data); } return data; }); } catch (error) { throw this.handleError(error, `Failed to get task ${taskId}`); } } /** * Get all tasks in a list * @param listId The ID of the list to get tasks from * @param filters Optional filters to apply * @returns Array of tasks */ async getTasks(listId, filters = {}) { this.logOperation('getTasks', { listId, filters }); try { return await this.makeRequest(async () => { const params = this.buildTaskFilterParams(filters); const response = await this.client.get(`/list/${listId}/task`, { params }); // Handle both JSON and text responses const data = response.data; if (typeof data === 'string') { throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data); } return Array.isArray(data) ? data : data.tasks || []; }); } catch (error) { throw this.handleError(error, `Failed to get tasks for list ${listId}`); } } /** * Get subtasks of a specific task * @param taskId The ID of the parent task * @returns Array of subtask details */ async getSubtasks(taskId) { this.logOperation('getSubtasks', { taskId }); try { return await this.makeRequest(async () => { const response = await this.client.get(`/task/${taskId}`); // Return subtasks if present, otherwise empty array return response.data.subtasks || []; }); } catch (error) { throw this.handleError(error, `Failed to get subtasks for task ${taskId}`); } } /** * Get a task by its custom ID * @param customTaskId The custom ID of the task (e.g., "ABC-123") * @param listId Optional list ID to limit the search (Note: ClickUp API might not filter by list_id when using custom_task_id) * @returns The task details */ async getTaskByCustomId(customTaskId, listId) { // Log the operation, including listId even if the API might ignore it for this specific lookup type this.logOperation('getTaskByCustomId', { customTaskId, listId }); try { return await this.makeRequest(async () => { // Use the standard task endpoint with the custom task ID const url = `/task/${encodeURIComponent(customTaskId)}`; // Add required query parameters for custom ID lookup const params = new URLSearchParams({ custom_task_ids: 'true', team_id: this.teamId // team_id is required when custom_task_ids is true }); // Note: The ClickUp API documentation for GET /task/{task_id} doesn't explicitly mention // filtering by list_id when custom_task_ids=true. This parameter might be ignored. if (listId) { this.logger.warn('listId provided to getTaskByCustomId, but the ClickUp API endpoint might not support it directly for custom ID lookups.', { customTaskId, listId }); // If ClickUp API were to support it, you would add it like this: // params.append('list_id', listId); } const response = await this.client.get(url, { params }); // Handle potential non-JSON responses (though less likely with GET) const data = response.data; if (typeof data === 'string') { throw new ClickUpServiceError('Received unexpected text response from API when fetching by custom ID', ErrorCode.UNKNOWN, data); } return data; }); } catch (error) { // Provide more specific error context if possible if (error instanceof ClickUpServiceError && error.code === ErrorCode.NOT_FOUND) { throw new ClickUpServiceError(`Task with custom ID ${customTaskId} not found or not accessible for team ${this.teamId}.`, ErrorCode.NOT_FOUND, error.data); } throw this.handleError(error, `Failed to get task with custom ID ${customTaskId}`); } } /** * Update an existing task * @param taskId The ID of the task to update * @param updateData The data to update the task with * @returns The updated task */ async updateTask(taskId, updateData) { if (this.READ_ONLY_MODE) { throw new ClickUpServiceError('Server is in read-only mode. Data modification is not allowed.', ErrorCode.FORBIDDEN); } this.logOperation('updateTask', { taskId, ...updateData }); try { return await this.makeRequest(async () => { const response = await this.client.put(`/task/${taskId}`, updateData); return response.data; }); } catch (error) { throw this.handleError(error, `Failed to update task ${taskId}`); } } /** * Delete a task * @param taskId The ID of the task to delete * @returns ServiceResponse indicating success or failure */ async deleteTask(taskId) { if (this.READ_ONLY_MODE) { throw new ClickUpServiceError('Server is in read-only mode. Data modification is not allowed.', ErrorCode.FORBIDDEN); } this.logOperation('deleteTask', { taskId }); try { return await this.makeRequest(async () => { await this.client.delete(`/task/${taskId}`); return { success: true }; }); } catch (error) { throw this.handleError(error, `Failed to delete task ${taskId}`); } } /** * Move a task to a different list * @param taskId The ID of the task to move * @param destinationListId The ID of the destination list * @returns The moved task */ async moveTask(taskId, destinationListId) { if (this.READ_ONLY_MODE) { throw new ClickUpServiceError('Server is in read-only mode. Data modification is not allowed.', ErrorCode.FORBIDDEN); } this.logOperation('moveTask', { taskId, destinationListId }); try { return await this.makeRequest(async () => { const response = await this.client.post(`/task/${taskId}/move`, { list_id: destinationListId }); return response.data; }); } catch (error) { throw this.handleError(error, `Failed to move task ${taskId}`); } } /** * Duplicate a task into the same or a different list * @param taskId The ID of the task to duplicate * @param listId Optional: The ID of the list to duplicate the task into. If not provided, duplicates into the original list. * @returns The duplicated task */ async duplicateTask(taskId, listId) { if (this.READ_ONLY_MODE) { throw new ClickUpServiceError('Server is in read-only mode. Data modification is not allowed.', ErrorCode.FORBIDDEN); } this.logOperation('duplicateTask', { taskId, listId }); try { const sourceTask = await this.getTask(taskId); const dataToDuplicate = this.extractTaskData(sourceTask); // ClickUp API for task duplication is not a direct endpoint. // We simulate it by fetching the task and creating a new one with its data. return await this.createTask(listId || sourceTask.list.id, { ...dataToDuplicate, name: `${dataToDuplicate.name} (Copy)` }); } catch (error) { throw this.handleError(error, `Failed to duplicate task ${taskId}`); } } /** * Validate a task exists and cache the result * @param taskId The ID of the task to validate * @returns The validated task */ async validateTaskExists(taskId) { // Check cache first const cached = this.validationCache.tasks.get(taskId); if (cached && Date.now() - cached.validatedAt < this.CACHE_TTL) { this.logger.debug('Using cached task validation', { taskId }); return cached.task; } // Not in cache or expired, fetch task const task = await this.getTask(taskId); // Cache the validation result this.validationCache.tasks.set(taskId, { validatedAt: Date.now(), task }); return task; } /** * Validate that multiple tasks exist * @param taskIds Array of task IDs to validate * @returns Map of task IDs to task objects */ async validateTasksExist(taskIds) { const results = new Map(); const toFetch = []; // Check cache first for (const taskId of taskIds) { const cached = this.validationCache.tasks.get(taskId); if (cached && Date.now() - cached.validatedAt < this.CACHE_TTL) { results.set(taskId, cached.task); } else { toFetch.push(taskId); } } if (toFetch.length > 0) { // Fetch uncached tasks in parallel batches const batchSize = 5; for (let i = 0; i < toFetch.length; i += batchSize) { const batch = toFetch.slice(i, i + batchSize); const tasks = await Promise.all(batch.map(id => this.getTask(id))); // Cache and store results tasks.forEach((task, index) => { const taskId = batch[index]; this.validationCache.tasks.set(taskId, { validatedAt: Date.now(), task }); results.set(taskId, task); }); } } return results; } /** * Validate a list exists and cache the result * @param listId The ID of the list to validate */ async validateListExists(listId) { // Check cache first const cached = this.validationCache.lists.get(listId); if (cached && Date.now() - cached.validatedAt < this.CACHE_TTL) { this.logger.debug('Using cached list validation', { listId }); if (!cached.valid) { throw new ClickUpServiceError(`List ${listId} does not exist`, ErrorCode.NOT_FOUND); } return; } try { await this.listService.getList(listId); // Cache the successful validation this.validationCache.lists.set(listId, { validatedAt: Date.now(), valid: true }); } catch (error) { // Cache the failed validation this.validationCache.lists.set(listId, { validatedAt: Date.now(), valid: false }); throw error; } } /** * Try to get a task ID from the name cache * @param taskName The name of the task * @param listId Optional list ID for context * @returns The cached task ID if found and not expired, otherwise null */ getCachedTaskId(taskName, listId) { const cached = this.nameToIdCache.get(taskName); if (cached && Date.now() - cached.validatedAt < this.CACHE_TTL) { // If listId is provided, ensure it matches the cached context if (!listId || cached.listId === listId) { this.logger.debug('Using cached task ID for name', { taskName, cachedId: cached.taskId }); return cached.taskId; } } return null; } /** * Cache a task name to ID mapping * @param taskName The name of the task * @param taskId The ID of the task * @param listId Optional list ID for context */ cacheTaskNameToId(taskName, taskId, listId) { this.nameToIdCache.set(taskName, { taskId, validatedAt: Date.now(), listId }); this.logger.debug('Cached task name to ID mapping', { taskName, taskId, listId }); } }