ClickUp MCP Server
by windalfin
Verified
/**
* ClickUp Task Service
*
* Handles all operations related to tasks in ClickUp, including:
* - Creating tasks (single and bulk)
* - Retrieving tasks (single or multiple)
* - Updating tasks
* - Deleting tasks
* - Finding tasks by name
*/
import { AxiosError } from 'axios';
import { BaseClickUpService, ErrorCode, ClickUpServiceError, ServiceResponse } from './base.js';
import {
ClickUpTask,
CreateTaskData,
UpdateTaskData,
TaskFilters,
TasksResponse,
BulkCreateTasksData
} from './types.js';
import { BulkProcessor, BulkOperationOptions, BulkOperationResult, ProgressInfo } from './bulk.js';
import { ListService } from './list.js';
import { WorkspaceService } from './workspace.js';
export class TaskService extends BaseClickUpService {
// Bulk processor for handling batch operations
private bulkProcessor: BulkProcessor;
private listService: ListService;
private workspaceService: WorkspaceService | null = null;
constructor(
apiKey: string,
teamId: string,
baseUrl?: string,
workspaceService?: WorkspaceService
) {
super(apiKey, teamId, baseUrl);
this.bulkProcessor = new BulkProcessor();
this.listService = new ListService(apiKey, teamId);
this.workspaceService = workspaceService || null;
}
/**
* Helper method to handle errors consistently
* @param error The error that occurred
* @param message Optional custom error message
* @returns A ClickUpServiceError
*/
private handleError(error: any, message?: string): ClickUpServiceError {
if (error instanceof ClickUpServiceError) {
return error;
}
return new ClickUpServiceError(
message || `Task service error: ${error.message}`,
ErrorCode.UNKNOWN,
error
);
}
/**
* 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: string, taskData: CreateTaskData): Promise<ClickUpTask> {
this.logOperation('createTask', { listId, ...taskData });
try {
return await this.makeRequest(async () => {
const response = await this.client.post<ClickUpTask>(
`/list/${listId}/task`,
taskData
);
return response.data;
});
} catch (error) {
throw this.handleError(error, `Failed to create task in list ${listId}`);
}
}
/**
* Get all tasks in a list with optional filtering
* @param listId The ID of the list to get tasks from
* @param filters Optional filters to apply
* @returns List of tasks matching the filters
*/
async getTasks(listId: string, filters: TaskFilters = {}): Promise<ClickUpTask[]> {
this.logOperation('getTasks', { listId, filters });
try {
return await this.makeRequest(async () => {
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));
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));
}
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));
});
}
const response = await this.client.get<TasksResponse>(
`/list/${listId}/task?${params.toString()}`
);
return response.data.tasks;
});
} catch (error) {
throw this.handleError(error, `Failed to get tasks from list ${listId}`);
}
}
/**
* Get a specific task by ID
* @param taskId The ID of the task to retrieve
* @returns The task details
*/
async getTask(taskId: string): Promise<ClickUpTask> {
this.logOperation('getTask', { taskId });
try {
return await this.makeRequest(async () => {
const response = await this.client.get<ClickUpTask>(`/task/${taskId}`);
return response.data;
});
} catch (error) {
throw this.handleError(error, `Failed to get task ${taskId}`);
}
}
/**
* Update an existing task
* @param taskId ID of the task to update
* @param updateData Data to update on the task
* @returns The updated task
*/
async updateTask(taskId: string, updateData: UpdateTaskData): Promise<ClickUpTask> {
this.logOperation('updateTask', { taskId, ...updateData });
try {
return await this.makeRequest(async () => {
const response = await this.client.put<ClickUpTask>(
`/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 Success indicator
*/
async deleteTask(taskId: string): Promise<ServiceResponse<void>> {
this.logOperation('deleteTask', { taskId });
try {
await this.makeRequest(async () => {
await this.client.delete(`/task/${taskId}`);
});
return {
success: true
};
} catch (error) {
throw this.handleError(error, `Failed to delete task ${taskId}`);
}
}
/**
* Find a task by its name in a specific list
* @param listId The list ID to search within
* @param taskName The name of the task to find
* @returns The task if found, otherwise null
*/
async findTaskByName(listId: string, taskName: string): Promise<ClickUpTask | null> {
this.logOperation('findTaskByName', { listId, taskName });
try {
const tasks = await this.getTasks(listId);
const matchingTask = tasks.find(task =>
task.name.toLowerCase() === taskName.toLowerCase()
);
return matchingTask || null;
} catch (error) {
throw this.handleError(error, `Failed to find task by name: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Move a task to a different 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: string, destinationListId: string): Promise<ClickUpTask> {
this.logOperation('moveTask', { taskId, destinationListId });
try {
// First, get both the task and list info in parallel to save time
const [originalTask, destinationList] = await Promise.all([
this.getTask(taskId),
this.listService.getList(destinationListId)
]);
const currentStatus = originalTask.status?.status;
const availableStatuses = destinationList.statuses?.map(s => s.status) || [];
// Determine the appropriate status for the destination list
let newStatus = availableStatuses.includes(currentStatus || '')
? currentStatus // Keep the same status if available in destination list
: destinationList.statuses?.[0]?.status; // Otherwise use the default (first) status
// Priority mapping: convert string priority to numeric value if needed
let priorityValue = null;
if (originalTask.priority) {
// If priority.id exists and is numeric, use that
if (originalTask.priority.id) {
priorityValue = parseInt(originalTask.priority.id);
// Ensure it's in the valid range 1-4
if (isNaN(priorityValue) || priorityValue < 1 || priorityValue > 4) {
priorityValue = null;
}
}
}
// Prepare the task data for the new list
const taskData: CreateTaskData = {
name: originalTask.name,
description: originalTask.description,
status: newStatus,
priority: priorityValue,
due_date: originalTask.due_date ? Number(originalTask.due_date) : undefined,
assignees: originalTask.assignees?.map(a => a.id) || [],
// Add any other relevant fields from the original task
};
// Create new task and delete old one in a single makeRequest call
return await this.makeRequest(async () => {
// First create the new task
const response = await this.client.post<ClickUpTask>(
`/list/${destinationListId}/task`,
taskData
);
// Then delete the original task
await this.client.delete(`/task/${taskId}`);
// Add a property to indicate the task was moved
const newTask = {
...response.data,
moved: true,
originalId: taskId
};
return newTask;
});
} catch (error) {
throw this.handleError(error, 'Failed to move task');
}
}
/**
* Create a duplicate of an existing task
* @param taskId The ID of the task to duplicate
* @param listId Optional destination list ID (defaults to the same list)
* @returns The newly created duplicate task
*/
async duplicateTask(taskId: string, listId?: string): Promise<ClickUpTask> {
this.logOperation('duplicateTask', { taskId, listId });
try {
// First, get the original task
const originalTask = await this.getTask(taskId);
// Priority mapping: convert string priority to numeric value if needed
let priorityValue = null;
if (originalTask.priority) {
// If priority.id exists and is numeric, use that
if (originalTask.priority.id) {
priorityValue = parseInt(originalTask.priority.id);
// Ensure it's in the valid range 1-4
if (isNaN(priorityValue) || priorityValue < 1 || priorityValue > 4) {
priorityValue = null;
}
}
}
// Prepare data for the new task
const newTaskData: CreateTaskData = {
name: `${originalTask.name} (Copy)`,
description: originalTask.description,
status: originalTask.status?.status,
priority: priorityValue,
due_date: originalTask.due_date ? new Date(originalTask.due_date).getTime() : undefined,
assignees: originalTask.assignees?.map(a => a.id) || []
};
// Create the new task in the specified list or original list
const targetListId = listId || originalTask.list.id;
return await this.createTask(targetListId, newTaskData);
} catch (error) {
throw this.handleError(error, 'Failed to duplicate task');
}
}
/**
* Create multiple tasks in a list with advanced batching options
*
* @param listId The ID of the list to create tasks in
* @param data Object containing array of tasks to create
* @param options Configuration options for the bulk operation
* @param progressCallback Optional callback for tracking progress
* @returns Result containing both successful and failed operations
*/
async createBulkTasks(
listId: string,
data: { tasks: Array<CreateTaskData> },
options?: BulkOperationOptions,
progressCallback?: (progress: ProgressInfo) => void
): Promise<BulkOperationResult<ClickUpTask>> {
this.logOperation('createBulkTasks', {
listId,
taskCount: data.tasks.length,
batchSize: options?.batchSize,
concurrency: options?.concurrency
});
try {
// Validate list exists before proceeding
const list = await this.listService.getList(listId).catch(() => null);
if (!list) {
throw new ClickUpServiceError(
`List not found with ID: ${listId}`,
ErrorCode.NOT_FOUND
);
}
// Set default options for better performance
const bulkOptions: BulkOperationOptions = {
batchSize: options?.batchSize ?? 5, // Smaller batch size for better rate limit handling
concurrency: options?.concurrency ?? 2, // Lower concurrency to avoid rate limits
continueOnError: options?.continueOnError ?? true, // Continue on individual task failures
retryCount: options?.retryCount ?? 3, // Retry failed operations
...options
};
// Add list validation to progress tracking
const wrappedCallback = progressCallback ?
(progress: ProgressInfo) => {
progress.context = { listId, listName: list.name };
progressCallback(progress);
} : undefined;
return await this.bulkProcessor.processBulk(
data.tasks,
async (taskData) => {
try {
// Ensure task data is properly formatted
const sanitizedData = {
...taskData,
// Remove any accidentally included list IDs in task data
list: undefined,
// Ensure name has emoji if missing
name: taskData.name.match(/^\p{Emoji}/u) ?
taskData.name :
'📋 ' + taskData.name
};
return await this.createTask(listId, sanitizedData);
} catch (error) {
// Enhance error context for better debugging
if (error instanceof ClickUpServiceError) {
error.context = {
...error.context,
taskName: taskData.name,
listId,
listName: list.name
};
}
throw error;
}
},
bulkOptions
);
} catch (error: any) {
const errorMessage = error instanceof ClickUpServiceError ?
error.message :
`Failed to create tasks in bulk: ${error.message}`;
throw new ClickUpServiceError(
errorMessage,
error instanceof ClickUpServiceError ? error.code : ErrorCode.UNKNOWN,
{
listId,
taskCount: data.tasks.length,
error: error instanceof Error ? error.message : String(error)
}
);
}
}
/**
* Update multiple tasks in bulk with advanced batching options
*
* @param tasks Array of task IDs and update data
* @param options Configuration options for the bulk operation
* @param progressCallback Optional callback for tracking progress
* @returns Result containing both successful and failed operations
*/
async updateBulkTasks(
tasks: Array<{ id: string, data: UpdateTaskData }>,
options?: BulkOperationOptions,
progressCallback?: (progress: ProgressInfo) => void
): Promise<BulkOperationResult<ClickUpTask>> {
this.logOperation('updateBulkTasks', {
taskCount: tasks.length,
batchSize: options?.batchSize,
concurrency: options?.concurrency
});
try {
return await this.bulkProcessor.processBulk(
tasks,
({ id, data }) => this.updateTask(id, data),
options
);
} catch (error: any) {
if (error instanceof ClickUpServiceError) {
throw error;
}
throw new ClickUpServiceError(
`Failed to update tasks in bulk: ${error.message}`,
ErrorCode.UNKNOWN,
error
);
}
}
/**
* Move multiple tasks to a different list in bulk
*
* @param tasks Array of task IDs to move
* @param targetListId ID of the list to move tasks to
* @param options Configuration options for the bulk operation
* @param progressCallback Optional callback for tracking progress
* @returns Result containing both successful and failed operations
*/
async moveBulkTasks(
tasks: string[],
targetListId: string,
options?: BulkOperationOptions,
progressCallback?: (progress: ProgressInfo) => void
): Promise<BulkOperationResult<ClickUpTask>> {
this.logOperation('moveBulkTasks', {
taskCount: tasks.length,
targetListId,
batchSize: options?.batchSize,
concurrency: options?.concurrency
});
try {
// First verify destination list exists
const destinationList = await this.listService.getList(targetListId);
if (!destinationList) {
throw new ClickUpServiceError(
`Destination list not found with ID: ${targetListId}`,
ErrorCode.NOT_FOUND
);
}
// Set default options for better performance
const bulkOptions: BulkOperationOptions = {
batchSize: options?.batchSize ?? 5, // Smaller batch size for better rate limit handling
concurrency: options?.concurrency ?? 2, // Lower concurrency to avoid rate limits
continueOnError: options?.continueOnError ?? true, // Continue on individual task failures
retryCount: options?.retryCount ?? 3, // Retry failed operations
...options
};
return await this.bulkProcessor.processBulk(
tasks,
async (taskId) => {
try {
// Get the original task
const originalTask = await this.getTask(taskId);
const currentStatus = originalTask.status?.status;
const availableStatuses = destinationList.statuses?.map(s => s.status) || [];
// Determine the appropriate status for the destination list
let newStatus = availableStatuses.includes(currentStatus || '')
? currentStatus // Keep the same status if available in destination list
: destinationList.statuses?.[0]?.status; // Otherwise use the default (first) status
// Prepare the task data for the new list
const taskData: CreateTaskData = {
name: originalTask.name,
description: originalTask.description,
status: newStatus,
priority: originalTask.priority?.priority as any,
due_date: originalTask.due_date ? Number(originalTask.due_date) : undefined,
assignees: originalTask.assignees?.map(a => a.id) || []
};
// Create new task and delete old one in a single makeRequest call
return await this.makeRequest(async () => {
// First create the new task
const response = await this.client.post<ClickUpTask>(
`/list/${targetListId}/task`,
taskData
);
// Then delete the original task
await this.client.delete(`/task/${taskId}`);
// Add a property to indicate the task was moved
const newTask = {
...response.data,
moved: true,
originalId: taskId
};
return newTask;
});
} catch (error) {
// Enhance error context for better debugging
if (error instanceof ClickUpServiceError) {
error.context = {
...error.context,
taskId,
targetListId,
targetListName: destinationList.name
};
}
throw error;
}
},
bulkOptions
);
} catch (error: any) {
const errorMessage = error instanceof ClickUpServiceError ?
error.message :
`Failed to move tasks in bulk: ${error.message}`;
throw new ClickUpServiceError(
errorMessage,
error instanceof ClickUpServiceError ? error.code : ErrorCode.UNKNOWN,
{
targetListId,
taskCount: tasks.length,
error: error instanceof Error ? error.message : String(error)
}
);
}
}
/**
* Delete multiple tasks in bulk with advanced batching options
*
* @param taskIds Array of task IDs to delete
* @param options Configuration options for the bulk operation
* @returns Result containing both successful and failed operations
*/
async deleteBulkTasks(
taskIds: string[],
options?: BulkOperationOptions
): Promise<BulkOperationResult<string>> {
this.logOperation('deleteBulkTasks', {
taskCount: taskIds.length,
batchSize: options?.batchSize,
concurrency: options?.concurrency
});
try {
return await this.bulkProcessor.processBulk(
taskIds,
async (taskId) => {
await this.deleteTask(taskId);
return taskId;
},
options
);
} catch (error: any) {
if (error instanceof ClickUpServiceError) {
throw error;
}
throw new ClickUpServiceError(
`Failed to delete tasks in bulk: ${error.message}`,
ErrorCode.UNKNOWN,
error
);
}
}
}