@taazkareem/clickup-mcp-server
Version:
ClickUp MCP Server - Integrate ClickUp tasks with AI through Model Context Protocol
541 lines (540 loc) • 22.8 kB
JavaScript
/**
* 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) {
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;
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.include_subtasks)
params.append('include_subtasks', String(filters.include_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) {
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
* @returns The updated task
*/
async updateTask(taskId, updateData) {
this.logOperation('updateTask', { taskId, ...updateData });
try {
// Extract custom fields from updateData
const { custom_fields, ...standardFields } = updateData;
// First update the standard fields
const updatedTask = await this.makeRequest(async () => {
const response = await this.client.put(`/task/${taskId}`, standardFields);
// 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;
});
// Then update custom fields if provided
if (custom_fields && Array.isArray(custom_fields) && custom_fields.length > 0) {
// Use the setCustomFieldValues method from the inherited class
// This will be available in TaskServiceCustomFields which extends this class
await this.setCustomFieldValues(taskId, custom_fields);
// Fetch the task again to get the updated version with custom fields
return await this.getTask(taskId);
}
return updatedTask;
}
catch (error) {
throw this.handleError(error, `Failed to update task ${taskId}`);
}
}
/**
* Delete a task
* @param taskId The ID of the task to delete
* @returns A ServiceResponse indicating success
*/
async deleteTask(taskId) {
this.logOperation('deleteTask', { taskId });
try {
await this.makeRequest(async () => {
await this.client.delete(`/task/${taskId}`);
});
return {
success: true,
data: undefined,
error: undefined
};
}
catch (error) {
throw this.handleError(error, `Failed to delete task ${taskId}`);
}
}
/**
* Move a task to another list
* @param taskId The ID of the task to move
* @param destinationListId The ID of the list to move the task to
* @returns The updated task
*/
async moveTask(taskId, destinationListId) {
const startTime = Date.now();
this.logOperation('moveTask', { taskId, destinationListId, operation: 'start' });
try {
// First, get task and validate destination list
const [sourceTask, _] = await Promise.all([
this.validateTaskExists(taskId),
this.validateListExists(destinationListId)
]);
// Extract task data for creating the new task
const taskData = this.extractTaskData(sourceTask);
// Create the task in the new list
const newTask = await this.createTask(destinationListId, taskData);
// Delete the original task
await this.deleteTask(taskId);
// Update the cache
this.validationCache.tasks.delete(taskId);
this.validationCache.tasks.set(newTask.id, {
validatedAt: Date.now(),
task: newTask
});
const totalTime = Date.now() - startTime;
this.logOperation('moveTask', {
taskId,
destinationListId,
operation: 'complete',
timing: { totalTime },
newTaskId: newTask.id
});
return newTask;
}
catch (error) {
// Log failure
this.logOperation('moveTask', {
taskId,
destinationListId,
operation: 'failed',
error: error instanceof Error ? error.message : String(error),
timing: { totalTime: Date.now() - startTime }
});
throw this.handleError(error, 'Failed to move task');
}
}
/**
* Duplicate a task, optionally to a different list
* @param taskId The ID of the task to duplicate
* @param listId Optional ID of list to create duplicate in (defaults to same list)
* @returns The duplicated task
*/
async duplicateTask(taskId, listId) {
this.logOperation('duplicateTask', { taskId, listId });
try {
// Get source task and validate destination list if provided
const [sourceTask, _] = await Promise.all([
this.validateTaskExists(taskId),
listId ? this.validateListExists(listId) : Promise.resolve()
]);
// Create duplicate in specified list or original list
const targetListId = listId || sourceTask.list.id;
const taskData = this.extractTaskData(sourceTask);
return await this.createTask(targetListId, taskData);
}
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 });
}
}