@taazkareem/clickup-mcp-server 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,477 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * ClickUp Task Service - Core Module
6
+ *
7
+ * Handles core operations related to tasks in ClickUp, including:
8
+ * - Base service initialization
9
+ * - Core utility methods
10
+ * - Basic CRUD operations
11
+ */
12
+ import { BaseClickUpService, ErrorCode, ClickUpServiceError } from '../base.js';
13
+ import { ListService } from '../list.js';
14
+ /**
15
+ * Core TaskService class providing basic task operations
16
+ */
17
+ export class TaskServiceCore extends BaseClickUpService {
18
+ constructor(apiKey, teamId, baseUrl, workspaceService) {
19
+ super(apiKey, teamId, baseUrl);
20
+ this.workspaceService = null;
21
+ // Cache for validated tasks and lists
22
+ this.validationCache = {
23
+ tasks: new Map(),
24
+ lists: new Map()
25
+ };
26
+ // Cache TTL in milliseconds (5 minutes)
27
+ this.CACHE_TTL = 5 * 60 * 1000;
28
+ if (workspaceService) {
29
+ this.workspaceService = workspaceService;
30
+ this.logOperation('constructor', { usingSharedWorkspaceService: true });
31
+ }
32
+ // Initialize list service for list lookups
33
+ this.listService = new ListService(apiKey, teamId, baseUrl, this.workspaceService);
34
+ this.logOperation('constructor', { initialized: true });
35
+ }
36
+ /**
37
+ * Helper method to handle errors consistently
38
+ * @param error The error that occurred
39
+ * @param message Optional custom error message
40
+ * @returns A ClickUpServiceError
41
+ */
42
+ handleError(error, message) {
43
+ if (error instanceof ClickUpServiceError) {
44
+ return error;
45
+ }
46
+ return new ClickUpServiceError(message || `Task service error: ${error.message}`, ErrorCode.UNKNOWN, error);
47
+ }
48
+ /**
49
+ * Build URL parameters from task filters
50
+ * @param filters Task filters to convert to URL parameters
51
+ * @returns URLSearchParams object
52
+ */
53
+ buildTaskFilterParams(filters) {
54
+ const params = new URLSearchParams();
55
+ // Add all filters to the query parameters
56
+ if (filters.include_closed)
57
+ params.append('include_closed', String(filters.include_closed));
58
+ if (filters.subtasks)
59
+ params.append('subtasks', String(filters.subtasks));
60
+ if (filters.page)
61
+ params.append('page', String(filters.page));
62
+ if (filters.order_by)
63
+ params.append('order_by', filters.order_by);
64
+ if (filters.reverse)
65
+ params.append('reverse', String(filters.reverse));
66
+ // Array parameters
67
+ if (filters.statuses && filters.statuses.length > 0) {
68
+ filters.statuses.forEach(status => params.append('statuses[]', status));
69
+ }
70
+ if (filters.assignees && filters.assignees.length > 0) {
71
+ filters.assignees.forEach(assignee => params.append('assignees[]', assignee));
72
+ }
73
+ // Team tasks endpoint specific parameters
74
+ if (filters.tags && filters.tags.length > 0) {
75
+ filters.tags.forEach(tag => params.append('tags[]', tag));
76
+ }
77
+ if (filters.list_ids && filters.list_ids.length > 0) {
78
+ filters.list_ids.forEach(id => params.append('list_ids[]', id));
79
+ }
80
+ if (filters.folder_ids && filters.folder_ids.length > 0) {
81
+ filters.folder_ids.forEach(id => params.append('folder_ids[]', id));
82
+ }
83
+ if (filters.space_ids && filters.space_ids.length > 0) {
84
+ filters.space_ids.forEach(id => params.append('space_ids[]', id));
85
+ }
86
+ if (filters.archived !== undefined)
87
+ params.append('archived', String(filters.archived));
88
+ if (filters.include_closed_lists !== undefined)
89
+ params.append('include_closed_lists', String(filters.include_closed_lists));
90
+ if (filters.include_archived_lists !== undefined)
91
+ params.append('include_archived_lists', String(filters.include_archived_lists));
92
+ if (filters.include_compact_time_entries !== undefined)
93
+ params.append('include_compact_time_entries', String(filters.include_compact_time_entries));
94
+ // Date filters
95
+ if (filters.due_date_gt)
96
+ params.append('due_date_gt', String(filters.due_date_gt));
97
+ if (filters.due_date_lt)
98
+ params.append('due_date_lt', String(filters.due_date_lt));
99
+ if (filters.date_created_gt)
100
+ params.append('date_created_gt', String(filters.date_created_gt));
101
+ if (filters.date_created_lt)
102
+ params.append('date_created_lt', String(filters.date_created_lt));
103
+ if (filters.date_updated_gt)
104
+ params.append('date_updated_gt', String(filters.date_updated_gt));
105
+ if (filters.date_updated_lt)
106
+ params.append('date_updated_lt', String(filters.date_updated_lt));
107
+ // Handle custom fields if present
108
+ if (filters.custom_fields) {
109
+ Object.entries(filters.custom_fields).forEach(([key, value]) => {
110
+ params.append(`custom_fields[${key}]`, String(value));
111
+ });
112
+ }
113
+ return params;
114
+ }
115
+ /**
116
+ * Extract priority value from a task
117
+ * @param task The task to extract priority from
118
+ * @returns TaskPriority or null
119
+ */
120
+ extractPriorityValue(task) {
121
+ if (!task.priority || !task.priority.id) {
122
+ return null;
123
+ }
124
+ const priorityValue = parseInt(task.priority.id);
125
+ // Ensure it's in the valid range 1-4
126
+ if (isNaN(priorityValue) || priorityValue < 1 || priorityValue > 4) {
127
+ return null;
128
+ }
129
+ return priorityValue;
130
+ }
131
+ /**
132
+ * Extract task data for creation/duplication
133
+ * @param task The source task
134
+ * @param nameOverride Optional override for the task name
135
+ * @returns CreateTaskData object
136
+ */
137
+ extractTaskData(task, nameOverride) {
138
+ return {
139
+ name: nameOverride || task.name,
140
+ description: task.description || '',
141
+ status: task.status?.status,
142
+ priority: this.extractPriorityValue(task),
143
+ due_date: task.due_date ? Number(task.due_date) : undefined,
144
+ assignees: task.assignees?.map(a => a.id) || []
145
+ };
146
+ }
147
+ /**
148
+ * Create a new task in the specified list
149
+ * @param listId The ID of the list to create the task in
150
+ * @param taskData The data for the new task
151
+ * @returns The created task
152
+ */
153
+ async createTask(listId, taskData) {
154
+ this.logOperation('createTask', { listId, ...taskData });
155
+ try {
156
+ return await this.makeRequest(async () => {
157
+ const response = await this.client.post(`/list/${listId}/task`, taskData);
158
+ return response.data;
159
+ });
160
+ }
161
+ catch (error) {
162
+ throw this.handleError(error, `Failed to create task in list ${listId}`);
163
+ }
164
+ }
165
+ /**
166
+ * Get a specific task by ID
167
+ * @param taskId The ID of the task to retrieve
168
+ * @returns The task details
169
+ */
170
+ async getTask(taskId) {
171
+ this.logOperation('getTask', { taskId });
172
+ try {
173
+ return await this.makeRequest(async () => {
174
+ const response = await this.client.get(`/task/${taskId}`);
175
+ return response.data;
176
+ });
177
+ }
178
+ catch (error) {
179
+ throw this.handleError(error, `Failed to get task ${taskId}`);
180
+ }
181
+ }
182
+ /**
183
+ * Get all tasks in a list with optional filtering
184
+ * @param listId The ID of the list to get tasks from
185
+ * @param filters Optional filters to apply
186
+ * @returns List of tasks matching the filters
187
+ */
188
+ async getTasks(listId, filters = {}) {
189
+ this.logOperation('getTasks', { listId, filters });
190
+ try {
191
+ return await this.makeRequest(async () => {
192
+ const params = this.buildTaskFilterParams(filters);
193
+ const response = await this.client.get(`/list/${listId}/task?${params.toString()}`);
194
+ return response.data.tasks;
195
+ });
196
+ }
197
+ catch (error) {
198
+ throw this.handleError(error, `Failed to get tasks from list ${listId}`);
199
+ }
200
+ }
201
+ /**
202
+ * Get subtasks of a specific task
203
+ * @param taskId The ID of the parent task
204
+ * @returns Array of subtask details
205
+ */
206
+ async getSubtasks(taskId) {
207
+ this.logOperation('getSubtasks', { taskId });
208
+ try {
209
+ return await this.makeRequest(async () => {
210
+ const response = await this.client.get(`/task/${taskId}`);
211
+ // Return subtasks if present, otherwise empty array
212
+ return response.data.subtasks || [];
213
+ });
214
+ }
215
+ catch (error) {
216
+ throw this.handleError(error, `Failed to get subtasks for task ${taskId}`);
217
+ }
218
+ }
219
+ /**
220
+ * Get a task by its custom ID
221
+ * @param customTaskId The custom ID of the task (e.g., "ABC-123")
222
+ * @param listId Optional list ID to limit the search
223
+ * @returns The task details
224
+ */
225
+ async getTaskByCustomId(customTaskId, listId) {
226
+ this.logOperation('getTaskByCustomId', { customTaskId, listId });
227
+ try {
228
+ return await this.makeRequest(async () => {
229
+ // Construct the URL with optional list ID
230
+ const url = `/task/custom_task_ids?custom_task_id=${encodeURIComponent(customTaskId)}${listId ? `&list_id=${listId}` : ''}`;
231
+ const response = await this.client.get(url);
232
+ return response.data;
233
+ });
234
+ }
235
+ catch (error) {
236
+ throw this.handleError(error, `Failed to get task with custom ID ${customTaskId}`);
237
+ }
238
+ }
239
+ /**
240
+ * Update an existing task
241
+ * @param taskId The ID of the task to update
242
+ * @param updateData The data to update
243
+ * @returns The updated task
244
+ */
245
+ async updateTask(taskId, updateData) {
246
+ this.logOperation('updateTask', { taskId, ...updateData });
247
+ try {
248
+ return await this.makeRequest(async () => {
249
+ const response = await this.client.put(`/task/${taskId}`, updateData);
250
+ return response.data;
251
+ });
252
+ }
253
+ catch (error) {
254
+ throw this.handleError(error, `Failed to update task ${taskId}`);
255
+ }
256
+ }
257
+ /**
258
+ * Delete a task
259
+ * @param taskId The ID of the task to delete
260
+ * @returns A ServiceResponse indicating success
261
+ */
262
+ async deleteTask(taskId) {
263
+ this.logOperation('deleteTask', { taskId });
264
+ try {
265
+ await this.makeRequest(async () => {
266
+ await this.client.delete(`/task/${taskId}`);
267
+ });
268
+ return {
269
+ success: true,
270
+ data: undefined,
271
+ error: undefined
272
+ };
273
+ }
274
+ catch (error) {
275
+ throw this.handleError(error, `Failed to delete task ${taskId}`);
276
+ }
277
+ }
278
+ /**
279
+ * Move a task to a different list
280
+ * @param taskId The ID of the task to move
281
+ * @param destinationListId The ID of the list to move the task to
282
+ * @returns The updated task
283
+ */
284
+ async moveTask(taskId, destinationListId) {
285
+ const startTime = Date.now();
286
+ this.logOperation('moveTask', { taskId, destinationListId, operation: 'start' });
287
+ try {
288
+ // First, get both the task and list info in parallel
289
+ const [task, listResult] = await Promise.all([
290
+ this.validateTaskExists(taskId),
291
+ this.validateListExists(destinationListId).then(() => this.listService.getList(destinationListId))
292
+ ]);
293
+ const originalTask = task;
294
+ const destinationList = listResult;
295
+ // Log parallel request timing
296
+ const parallelRequestTime = Date.now() - startTime;
297
+ this.logOperation('moveTask', {
298
+ taskId,
299
+ destinationListId,
300
+ operation: 'parallel_requests_complete',
301
+ timing: { parallelRequestTime }
302
+ });
303
+ const currentStatus = originalTask.status?.status;
304
+ const availableStatuses = destinationList.statuses?.map(s => s.status) || [];
305
+ // Enhanced status mapping logic
306
+ let newStatus = currentStatus;
307
+ if (currentStatus && availableStatuses.length > 0) {
308
+ // Only map status if current status isn't available in destination list
309
+ if (!availableStatuses.includes(currentStatus)) {
310
+ // Try to find a similar status
311
+ const similarStatus = availableStatuses.find(s => s.toLowerCase().includes(currentStatus.toLowerCase()) ||
312
+ currentStatus.toLowerCase().includes(s.toLowerCase()));
313
+ // If no similar status found, use the first available status
314
+ newStatus = similarStatus || availableStatuses[0];
315
+ this.logger.debug('Status mapping', {
316
+ original: currentStatus,
317
+ mapped: newStatus,
318
+ available: availableStatuses
319
+ });
320
+ }
321
+ }
322
+ // Make the move request
323
+ const movedTask = await this.makeRequest(async () => {
324
+ const response = await this.client.post(`/task/${taskId}`, {
325
+ list: destinationListId,
326
+ status: newStatus
327
+ });
328
+ return response.data;
329
+ });
330
+ // Cache the moved task
331
+ this.validationCache.tasks.set(taskId, {
332
+ validatedAt: Date.now(),
333
+ task: movedTask
334
+ });
335
+ const totalTime = Date.now() - startTime;
336
+ this.logOperation('moveTask', {
337
+ taskId,
338
+ destinationListId,
339
+ operation: 'complete',
340
+ timing: {
341
+ totalTime,
342
+ parallelRequestTime,
343
+ moveOperationTime: totalTime - parallelRequestTime
344
+ },
345
+ statusMapping: {
346
+ original: currentStatus,
347
+ new: newStatus,
348
+ wasMapped: currentStatus !== newStatus
349
+ }
350
+ });
351
+ return movedTask;
352
+ }
353
+ catch (error) {
354
+ // Log failure
355
+ this.logOperation('moveTask', {
356
+ taskId,
357
+ destinationListId,
358
+ operation: 'failed',
359
+ error: error.message,
360
+ timing: { totalTime: Date.now() - startTime }
361
+ });
362
+ throw this.handleError(error, 'Failed to move task');
363
+ }
364
+ }
365
+ /**
366
+ * Duplicate a task, optionally to a different list
367
+ * @param taskId The ID of the task to duplicate
368
+ * @param listId Optional ID of list to create duplicate in (defaults to same list)
369
+ * @returns The duplicated task
370
+ */
371
+ async duplicateTask(taskId, listId) {
372
+ this.logOperation('duplicateTask', { taskId, listId });
373
+ try {
374
+ // Get source task and validate destination list if provided
375
+ const [sourceTask, _] = await Promise.all([
376
+ this.validateTaskExists(taskId),
377
+ listId ? this.validateListExists(listId) : Promise.resolve()
378
+ ]);
379
+ // Create duplicate in specified list or original list
380
+ const targetListId = listId || sourceTask.list.id;
381
+ const taskData = this.extractTaskData(sourceTask);
382
+ return await this.createTask(targetListId, taskData);
383
+ }
384
+ catch (error) {
385
+ throw this.handleError(error, `Failed to duplicate task ${taskId}`);
386
+ }
387
+ }
388
+ /**
389
+ * Validate a task exists and cache the result
390
+ * @param taskId The ID of the task to validate
391
+ * @returns The validated task
392
+ */
393
+ async validateTaskExists(taskId) {
394
+ // Check cache first
395
+ const cached = this.validationCache.tasks.get(taskId);
396
+ if (cached && Date.now() - cached.validatedAt < this.CACHE_TTL) {
397
+ this.logger.debug('Using cached task validation', { taskId });
398
+ return cached.task;
399
+ }
400
+ // Not in cache or expired, fetch task
401
+ const task = await this.getTask(taskId);
402
+ // Cache the validation result
403
+ this.validationCache.tasks.set(taskId, {
404
+ validatedAt: Date.now(),
405
+ task
406
+ });
407
+ return task;
408
+ }
409
+ /**
410
+ * Validate that multiple tasks exist
411
+ * @param taskIds Array of task IDs to validate
412
+ * @returns Map of task IDs to task objects
413
+ */
414
+ async validateTasksExist(taskIds) {
415
+ const results = new Map();
416
+ const toFetch = [];
417
+ // Check cache first
418
+ for (const taskId of taskIds) {
419
+ const cached = this.validationCache.tasks.get(taskId);
420
+ if (cached && Date.now() - cached.validatedAt < this.CACHE_TTL) {
421
+ results.set(taskId, cached.task);
422
+ }
423
+ else {
424
+ toFetch.push(taskId);
425
+ }
426
+ }
427
+ if (toFetch.length > 0) {
428
+ // Fetch uncached tasks in parallel batches
429
+ const batchSize = 5;
430
+ for (let i = 0; i < toFetch.length; i += batchSize) {
431
+ const batch = toFetch.slice(i, i + batchSize);
432
+ const tasks = await Promise.all(batch.map(id => this.getTask(id)));
433
+ // Cache and store results
434
+ tasks.forEach((task, index) => {
435
+ const taskId = batch[index];
436
+ this.validationCache.tasks.set(taskId, {
437
+ validatedAt: Date.now(),
438
+ task
439
+ });
440
+ results.set(taskId, task);
441
+ });
442
+ }
443
+ }
444
+ return results;
445
+ }
446
+ /**
447
+ * Validate a list exists and cache the result
448
+ * @param listId The ID of the list to validate
449
+ */
450
+ async validateListExists(listId) {
451
+ // Check cache first
452
+ const cached = this.validationCache.lists.get(listId);
453
+ if (cached && Date.now() - cached.validatedAt < this.CACHE_TTL) {
454
+ this.logger.debug('Using cached list validation', { listId });
455
+ if (!cached.valid) {
456
+ throw new ClickUpServiceError(`List ${listId} does not exist`, ErrorCode.NOT_FOUND);
457
+ }
458
+ return;
459
+ }
460
+ try {
461
+ await this.listService.getList(listId);
462
+ // Cache the successful validation
463
+ this.validationCache.lists.set(listId, {
464
+ validatedAt: Date.now(),
465
+ valid: true
466
+ });
467
+ }
468
+ catch (error) {
469
+ // Cache the failed validation
470
+ this.validationCache.lists.set(listId, {
471
+ validatedAt: Date.now(),
472
+ valid: false
473
+ });
474
+ throw error;
475
+ }
476
+ }
477
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * ClickUp Task Service - Custom Fields Module
6
+ *
7
+ * Handles custom fields operations for ClickUp tasks, including:
8
+ * - Setting custom field values
9
+ * - Retrieving custom field values
10
+ */
11
+ import { TaskServiceTags } from './task-tags.js';
12
+ /**
13
+ * Custom fields functionality for the TaskService
14
+ */
15
+ export class TaskServiceCustomFields extends TaskServiceTags {
16
+ /**
17
+ * Set a single custom field value on a task
18
+ *
19
+ * @param taskId ID of the task
20
+ * @param fieldId ID of the custom field
21
+ * @param value Value to set for the custom field
22
+ * @returns Success response
23
+ */
24
+ async setCustomFieldValue(taskId, fieldId, value) {
25
+ this.logOperation('setCustomFieldValue', { taskId, fieldId, value });
26
+ try {
27
+ const payload = {
28
+ value
29
+ };
30
+ await this.client.post(`/task/${taskId}/field/${fieldId}`, payload);
31
+ return true;
32
+ }
33
+ catch (error) {
34
+ throw this.handleError(error, `Failed to set custom field "${fieldId}" value`);
35
+ }
36
+ }
37
+ /**
38
+ * Set multiple custom field values on a task
39
+ *
40
+ * @param taskId ID of the task
41
+ * @param customFields Array of custom field ID and value pairs
42
+ * @returns Success response
43
+ */
44
+ async setCustomFieldValues(taskId, customFields) {
45
+ this.logOperation('setCustomFieldValues', { taskId, customFields });
46
+ try {
47
+ // Execute each update sequentially
48
+ for (const field of customFields) {
49
+ await this.setCustomFieldValue(taskId, field.id, field.value);
50
+ }
51
+ return true;
52
+ }
53
+ catch (error) {
54
+ throw this.handleError(error, 'Failed to set custom field values');
55
+ }
56
+ }
57
+ /**
58
+ * Get all custom field values for a task
59
+ *
60
+ * @param taskId ID of the task
61
+ * @returns Record mapping field IDs to their values
62
+ */
63
+ async getCustomFieldValues(taskId) {
64
+ this.logOperation('getCustomFieldValues', { taskId });
65
+ try {
66
+ // We need to fetch the full task to get its custom fields
67
+ const task = await this.getTask(taskId);
68
+ return task.custom_fields || {};
69
+ }
70
+ catch (error) {
71
+ throw this.handleError(error, 'Failed to get custom field values');
72
+ }
73
+ }
74
+ /**
75
+ * Get a specific custom field value for a task
76
+ *
77
+ * @param taskId ID of the task
78
+ * @param fieldId ID of the custom field
79
+ * @returns The value of the custom field
80
+ * @throws ClickUpServiceError if the field doesn't exist
81
+ */
82
+ async getCustomFieldValue(taskId, fieldId) {
83
+ this.logOperation('getCustomFieldValue', { taskId, fieldId });
84
+ try {
85
+ const customFields = await this.getCustomFieldValues(taskId);
86
+ if (fieldId in customFields) {
87
+ return customFields[fieldId];
88
+ }
89
+ else {
90
+ throw this.handleError(new Error(`Custom field "${fieldId}" not found on task`), `Custom field "${fieldId}" not found on task`);
91
+ }
92
+ }
93
+ catch (error) {
94
+ throw this.handleError(error, `Failed to get custom field "${fieldId}" value`);
95
+ }
96
+ }
97
+ }