@alanse/clickup-multi-mcp-server 1.0.0

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.
Files changed (56) hide show
  1. package/Dockerfile +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +470 -0
  4. package/build/config.js +237 -0
  5. package/build/index.js +87 -0
  6. package/build/logger.js +163 -0
  7. package/build/middleware/security.js +231 -0
  8. package/build/server.js +288 -0
  9. package/build/services/clickup/base.js +432 -0
  10. package/build/services/clickup/bulk.js +180 -0
  11. package/build/services/clickup/document.js +159 -0
  12. package/build/services/clickup/folder.js +136 -0
  13. package/build/services/clickup/index.js +76 -0
  14. package/build/services/clickup/list.js +191 -0
  15. package/build/services/clickup/tag.js +239 -0
  16. package/build/services/clickup/task/index.js +32 -0
  17. package/build/services/clickup/task/task-attachments.js +105 -0
  18. package/build/services/clickup/task/task-comments.js +114 -0
  19. package/build/services/clickup/task/task-core.js +604 -0
  20. package/build/services/clickup/task/task-custom-fields.js +107 -0
  21. package/build/services/clickup/task/task-search.js +986 -0
  22. package/build/services/clickup/task/task-service.js +104 -0
  23. package/build/services/clickup/task/task-tags.js +113 -0
  24. package/build/services/clickup/time.js +244 -0
  25. package/build/services/clickup/types.js +33 -0
  26. package/build/services/clickup/workspace.js +397 -0
  27. package/build/services/shared.js +61 -0
  28. package/build/sse_server.js +277 -0
  29. package/build/tools/documents.js +489 -0
  30. package/build/tools/folder.js +331 -0
  31. package/build/tools/index.js +16 -0
  32. package/build/tools/list.js +428 -0
  33. package/build/tools/member.js +106 -0
  34. package/build/tools/tag.js +833 -0
  35. package/build/tools/task/attachments.js +357 -0
  36. package/build/tools/task/attachments.types.js +9 -0
  37. package/build/tools/task/bulk-operations.js +338 -0
  38. package/build/tools/task/handlers.js +919 -0
  39. package/build/tools/task/index.js +30 -0
  40. package/build/tools/task/main.js +233 -0
  41. package/build/tools/task/single-operations.js +469 -0
  42. package/build/tools/task/time-tracking.js +575 -0
  43. package/build/tools/task/utilities.js +310 -0
  44. package/build/tools/task/workspace-operations.js +258 -0
  45. package/build/tools/tool-enhancer.js +37 -0
  46. package/build/tools/utils.js +12 -0
  47. package/build/tools/workspace-helper.js +44 -0
  48. package/build/tools/workspace.js +73 -0
  49. package/build/utils/color-processor.js +183 -0
  50. package/build/utils/concurrency-utils.js +248 -0
  51. package/build/utils/date-utils.js +542 -0
  52. package/build/utils/resolver-utils.js +135 -0
  53. package/build/utils/sponsor-service.js +93 -0
  54. package/build/utils/token-utils.js +49 -0
  55. package/package.json +77 -0
  56. package/smithery.yaml +23 -0
@@ -0,0 +1,919 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * ClickUp MCP Task Operation Handlers
6
+ *
7
+ * This module implements the handlers for task operations, both for single task
8
+ * and bulk operations. These handlers are used by the tool definitions.
9
+ */
10
+ import { toTaskPriority } from '../../services/clickup/types.js';
11
+ import { getClickUpServices } from '../../services/shared.js';
12
+ import { BulkService } from '../../services/clickup/bulk.js';
13
+ import { parseDueDate } from '../utils.js';
14
+ import { validateTaskIdentification, validateListIdentification, validateTaskUpdateData, validateBulkTasks, parseBulkOptions, resolveListIdWithValidation } from './utilities.js';
15
+ import { handleResolveAssignees } from '../member.js';
16
+ import { isNameMatch } from '../../utils/resolver-utils.js';
17
+ import { Logger } from '../../logger.js';
18
+ // Use default workspace services for backwards compatibility
19
+ const defaultServices = getClickUpServices();
20
+ const { task: taskService, list: listService, workspace: workspaceService } = defaultServices;
21
+ // Create a bulk service instance that uses the task service
22
+ const bulkService = new BulkService(taskService);
23
+ // Create a logger instance for task handlers
24
+ const logger = new Logger('TaskHandlers');
25
+ // Token limit constant for workspace tasks
26
+ const WORKSPACE_TASKS_TOKEN_LIMIT = 50000;
27
+ // Cache for task context between sequential operations
28
+ const taskContextCache = new Map();
29
+ const TASK_CONTEXT_TTL = 5 * 60 * 1000; // 5 minutes
30
+ /**
31
+ * Store task context for sequential operations
32
+ */
33
+ function storeTaskContext(taskName, taskId) {
34
+ taskContextCache.set(taskName, {
35
+ id: taskId,
36
+ timestamp: Date.now()
37
+ });
38
+ }
39
+ /**
40
+ * Get cached task context if valid
41
+ */
42
+ function getCachedTaskContext(taskName) {
43
+ const context = taskContextCache.get(taskName);
44
+ if (!context)
45
+ return null;
46
+ if (Date.now() - context.timestamp > TASK_CONTEXT_TTL) {
47
+ taskContextCache.delete(taskName);
48
+ return null;
49
+ }
50
+ return context.id;
51
+ }
52
+ //=============================================================================
53
+ // SHARED UTILITY FUNCTIONS
54
+ //=============================================================================
55
+ /**
56
+ * Parse time estimate string into minutes
57
+ * Supports formats like "2h 30m", "150m", "2.5h"
58
+ */
59
+ function parseTimeEstimate(timeEstimate) {
60
+ // If it's already a number, return it directly
61
+ if (typeof timeEstimate === 'number') {
62
+ return timeEstimate;
63
+ }
64
+ if (!timeEstimate || typeof timeEstimate !== 'string')
65
+ return 0;
66
+ // If it's just a number as string, parse it
67
+ if (/^\d+$/.test(timeEstimate)) {
68
+ return parseInt(timeEstimate, 10);
69
+ }
70
+ let totalMinutes = 0;
71
+ // Extract hours
72
+ const hoursMatch = timeEstimate.match(/(\d+\.?\d*)h/);
73
+ if (hoursMatch) {
74
+ totalMinutes += parseFloat(hoursMatch[1]) * 60;
75
+ }
76
+ // Extract minutes
77
+ const minutesMatch = timeEstimate.match(/(\d+)m/);
78
+ if (minutesMatch) {
79
+ totalMinutes += parseInt(minutesMatch[1], 10);
80
+ }
81
+ return Math.round(totalMinutes); // Return minutes
82
+ }
83
+ /**
84
+ * Resolve assignees from mixed input (user IDs, emails, usernames) to user IDs
85
+ */
86
+ async function resolveAssignees(assignees) {
87
+ if (!assignees || !Array.isArray(assignees) || assignees.length === 0) {
88
+ return [];
89
+ }
90
+ const resolved = [];
91
+ const toResolve = [];
92
+ // Separate numeric IDs from strings that need resolution
93
+ for (const assignee of assignees) {
94
+ if (typeof assignee === 'number') {
95
+ resolved.push(assignee);
96
+ }
97
+ else if (typeof assignee === 'string') {
98
+ // Check if it's a numeric string
99
+ const numericId = parseInt(assignee, 10);
100
+ if (!isNaN(numericId) && numericId.toString() === assignee) {
101
+ resolved.push(numericId);
102
+ }
103
+ else {
104
+ // It's an email or username that needs resolution
105
+ toResolve.push(assignee);
106
+ }
107
+ }
108
+ }
109
+ // Resolve emails/usernames to user IDs if any
110
+ if (toResolve.length > 0) {
111
+ try {
112
+ const result = await handleResolveAssignees({ assignees: toResolve });
113
+ // The result is wrapped by sponsorService.createResponse, so we need to parse the JSON
114
+ if (result.content && Array.isArray(result.content) && result.content.length > 0) {
115
+ const dataText = result.content[0].text;
116
+ const parsedData = JSON.parse(dataText);
117
+ if (parsedData.userIds && Array.isArray(parsedData.userIds)) {
118
+ for (const userId of parsedData.userIds) {
119
+ if (userId !== null && typeof userId === 'number') {
120
+ resolved.push(userId);
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
126
+ catch (error) {
127
+ console.warn('Failed to resolve some assignees:', error.message);
128
+ // Continue with the IDs we could resolve
129
+ }
130
+ }
131
+ return resolved;
132
+ }
133
+ /**
134
+ * Build task update data from parameters
135
+ */
136
+ async function buildUpdateData(params) {
137
+ const updateData = {};
138
+ if (params.name !== undefined)
139
+ updateData.name = params.name;
140
+ if (params.description !== undefined)
141
+ updateData.description = params.description;
142
+ if (params.markdown_description !== undefined)
143
+ updateData.markdown_description = params.markdown_description;
144
+ if (params.status !== undefined)
145
+ updateData.status = params.status;
146
+ // Use toTaskPriority to properly handle null values and validation
147
+ if (params.priority !== undefined) {
148
+ updateData.priority = toTaskPriority(params.priority);
149
+ }
150
+ if (params.dueDate !== undefined) {
151
+ const parsedDueDate = parseDueDate(params.dueDate);
152
+ if (parsedDueDate !== undefined) {
153
+ updateData.due_date = parsedDueDate;
154
+ updateData.due_date_time = true;
155
+ }
156
+ else {
157
+ // Clear the due date by setting it to null
158
+ updateData.due_date = null;
159
+ updateData.due_date_time = false;
160
+ }
161
+ }
162
+ if (params.startDate !== undefined) {
163
+ const parsedStartDate = parseDueDate(params.startDate);
164
+ if (parsedStartDate !== undefined) {
165
+ updateData.start_date = parsedStartDate;
166
+ updateData.start_date_time = true;
167
+ }
168
+ else {
169
+ // Clear the start date by setting it to null
170
+ updateData.start_date = null;
171
+ updateData.start_date_time = false;
172
+ }
173
+ }
174
+ // Handle time estimate if provided - convert from string to minutes
175
+ if (params.time_estimate !== undefined) {
176
+ // Log the time estimate for debugging
177
+ console.log(`Original time_estimate: ${params.time_estimate}, typeof: ${typeof params.time_estimate}`);
178
+ // Parse and convert to number in minutes
179
+ const minutes = parseTimeEstimate(params.time_estimate);
180
+ console.log(`Converted time_estimate: ${minutes}`);
181
+ updateData.time_estimate = minutes;
182
+ }
183
+ // Handle custom fields if provided
184
+ if (params.custom_fields !== undefined) {
185
+ updateData.custom_fields = params.custom_fields;
186
+ }
187
+ // Handle assignees if provided - resolve emails/usernames to user IDs
188
+ if (params.assignees !== undefined) {
189
+ // Parse assignees if it's a string (from MCP serialization)
190
+ let assigneesArray = params.assignees;
191
+ if (typeof params.assignees === 'string') {
192
+ try {
193
+ assigneesArray = JSON.parse(params.assignees);
194
+ }
195
+ catch (error) {
196
+ console.warn('Failed to parse assignees string:', params.assignees, error);
197
+ assigneesArray = [];
198
+ }
199
+ }
200
+ const resolvedAssignees = await resolveAssignees(assigneesArray);
201
+ // Store the resolved assignees for processing in the updateTask method
202
+ // The actual add/rem logic will be handled there based on current vs new assignees
203
+ updateData.assignees = resolvedAssignees;
204
+ }
205
+ return updateData;
206
+ }
207
+ /**
208
+ * Core function to find a task by ID or name
209
+ * This consolidates all task lookup logic in one place for consistency
210
+ */
211
+ async function findTask(params) {
212
+ const { taskId, taskName, listName, customTaskId, requireId = false, includeSubtasks = false } = params;
213
+ // Validate that we have enough information to identify a task
214
+ const validationResult = validateTaskIdentification({ taskId, taskName, listName, customTaskId }, { requireTaskId: requireId, useGlobalLookup: true });
215
+ if (!validationResult.isValid) {
216
+ throw new Error(validationResult.errorMessage);
217
+ }
218
+ try {
219
+ // Direct path for taskId - most efficient (now includes automatic custom ID detection)
220
+ if (taskId) {
221
+ const task = await taskService.getTask(taskId);
222
+ // Add subtasks if requested
223
+ if (includeSubtasks) {
224
+ const subtasks = await taskService.getSubtasks(task.id);
225
+ return { task, subtasks };
226
+ }
227
+ return { task };
228
+ }
229
+ // Direct path for customTaskId - for explicit custom ID requests
230
+ // Note: This is now mainly for backward compatibility since getTask() handles custom IDs automatically
231
+ if (customTaskId) {
232
+ const task = await taskService.getTaskByCustomId(customTaskId);
233
+ // Add subtasks if requested
234
+ if (includeSubtasks) {
235
+ const subtasks = await taskService.getSubtasks(task.id);
236
+ return { task, subtasks };
237
+ }
238
+ return { task };
239
+ }
240
+ // Special optimized path for taskName + listName combination
241
+ if (taskName && listName) {
242
+ const listId = await resolveListIdWithValidation(null, listName);
243
+ // Get all tasks in the list
244
+ const allTasks = await taskService.getTasks(listId);
245
+ // Find the task that matches the name
246
+ const matchingTask = findTaskByName(allTasks, taskName);
247
+ if (!matchingTask) {
248
+ throw new Error(`Task "${taskName}" not found in list "${listName}"`);
249
+ }
250
+ // Add subtasks if requested
251
+ if (includeSubtasks) {
252
+ const subtasks = await taskService.getSubtasks(matchingTask.id);
253
+ return { task: matchingTask, subtasks };
254
+ }
255
+ return { task: matchingTask };
256
+ }
257
+ // Fallback to searching all lists for taskName-only case
258
+ if (taskName) {
259
+ logger.debug(`Searching all lists for task: "${taskName}"`);
260
+ // Get workspace hierarchy which contains all lists
261
+ const hierarchy = await workspaceService.getWorkspaceHierarchy();
262
+ // Extract all list IDs from the hierarchy
263
+ const listIds = [];
264
+ const extractListIds = (node) => {
265
+ if (node.type === 'list') {
266
+ listIds.push(node.id);
267
+ }
268
+ if (node.children) {
269
+ node.children.forEach(extractListIds);
270
+ }
271
+ };
272
+ // Start from the root's children
273
+ hierarchy.root.children.forEach(extractListIds);
274
+ // Search through each list
275
+ const searchPromises = listIds.map(async (listId) => {
276
+ try {
277
+ const tasks = await taskService.getTasks(listId);
278
+ const matchingTask = findTaskByName(tasks, taskName);
279
+ if (matchingTask) {
280
+ logger.debug(`Found task "${matchingTask.name}" (ID: ${matchingTask.id}) in list with ID "${listId}"`);
281
+ return matchingTask;
282
+ }
283
+ return null;
284
+ }
285
+ catch (error) {
286
+ logger.warn(`Error searching list ${listId}: ${error.message}`);
287
+ return null;
288
+ }
289
+ });
290
+ // Wait for all searches to complete
291
+ const results = await Promise.all(searchPromises);
292
+ // Filter out null results and sort by match quality and recency
293
+ const matchingTasks = results
294
+ .filter(task => task !== null)
295
+ .sort((a, b) => {
296
+ const aMatch = isNameMatch(a.name, taskName);
297
+ const bMatch = isNameMatch(b.name, taskName);
298
+ // First sort by match quality
299
+ if (bMatch.score !== aMatch.score) {
300
+ return bMatch.score - aMatch.score;
301
+ }
302
+ // Then sort by recency
303
+ return parseInt(b.date_updated) - parseInt(a.date_updated);
304
+ });
305
+ if (matchingTasks.length === 0) {
306
+ throw new Error(`Task "${taskName}" not found in any list across your workspace. Please check the task name and try again.`);
307
+ }
308
+ const bestMatch = matchingTasks[0];
309
+ // Add subtasks if requested
310
+ if (includeSubtasks) {
311
+ const subtasks = await taskService.getSubtasks(bestMatch.id);
312
+ return { task: bestMatch, subtasks };
313
+ }
314
+ return { task: bestMatch };
315
+ }
316
+ // We shouldn't reach here if validation is working correctly
317
+ throw new Error("No valid task identification provided");
318
+ }
319
+ catch (error) {
320
+ // Enhance error message for non-existent tasks
321
+ if (taskName && error.message.includes('not found')) {
322
+ throw new Error(`Task "${taskName}" not found. Please check the task name and try again.`);
323
+ }
324
+ // Pass along other formatted errors
325
+ throw error;
326
+ }
327
+ }
328
+ /**
329
+ * Helper function to find a task by name in an array of tasks
330
+ */
331
+ function findTaskByName(tasks, name) {
332
+ if (!tasks || !Array.isArray(tasks) || !name)
333
+ return null;
334
+ const normalizedSearchName = name.toLowerCase().trim();
335
+ // Get match scores for all tasks
336
+ const taskMatchScores = tasks.map(task => {
337
+ const matchResult = isNameMatch(task.name, name);
338
+ return {
339
+ task,
340
+ matchResult,
341
+ // Parse the date_updated field as a number for sorting
342
+ updatedAt: task.date_updated ? parseInt(task.date_updated, 10) : 0
343
+ };
344
+ }).filter(result => result.matchResult.isMatch);
345
+ if (taskMatchScores.length === 0) {
346
+ return null;
347
+ }
348
+ // First, try to find exact matches
349
+ const exactMatches = taskMatchScores
350
+ .filter(result => result.matchResult.exactMatch)
351
+ .sort((a, b) => {
352
+ // For exact matches with the same score, sort by most recently updated
353
+ if (b.matchResult.score === a.matchResult.score) {
354
+ return b.updatedAt - a.updatedAt;
355
+ }
356
+ return b.matchResult.score - a.matchResult.score;
357
+ });
358
+ // Get the best matches based on whether we have exact matches or need to fall back to fuzzy matches
359
+ const bestMatches = exactMatches.length > 0 ? exactMatches : taskMatchScores.sort((a, b) => {
360
+ // First sort by match score (highest first)
361
+ if (b.matchResult.score !== a.matchResult.score) {
362
+ return b.matchResult.score - a.matchResult.score;
363
+ }
364
+ // Then sort by most recently updated
365
+ return b.updatedAt - a.updatedAt;
366
+ });
367
+ // Get the best match
368
+ return bestMatches[0].task;
369
+ }
370
+ /**
371
+ * Handler for getting a task - uses the consolidated findTask function
372
+ */
373
+ export async function getTaskHandler(params) {
374
+ try {
375
+ const result = await findTask({
376
+ taskId: params.taskId,
377
+ taskName: params.taskName,
378
+ listName: params.listName,
379
+ customTaskId: params.customTaskId,
380
+ includeSubtasks: params.subtasks
381
+ });
382
+ if (result.subtasks) {
383
+ return { ...result.task, subtasks: result.subtasks };
384
+ }
385
+ return result.task;
386
+ }
387
+ catch (error) {
388
+ throw error;
389
+ }
390
+ }
391
+ /**
392
+ * Get task ID from various identifiers - uses the consolidated findTask function
393
+ */
394
+ export async function getTaskId(taskId, taskName, listName, customTaskId, requireId, includeSubtasks) {
395
+ // Check task context cache first if we have a task name
396
+ if (taskName && !taskId && !customTaskId) {
397
+ const cachedId = getCachedTaskContext(taskName);
398
+ if (cachedId) {
399
+ return cachedId;
400
+ }
401
+ }
402
+ const result = await findTask({
403
+ taskId,
404
+ taskName,
405
+ listName,
406
+ customTaskId,
407
+ requireId,
408
+ includeSubtasks
409
+ });
410
+ // Store task context for future operations
411
+ if (taskName && result.task.id) {
412
+ storeTaskContext(taskName, result.task.id);
413
+ }
414
+ return result.task.id;
415
+ }
416
+ /**
417
+ * Process a list identification validation, returning the list ID
418
+ */
419
+ async function getListId(listId, listName) {
420
+ validateListIdentification(listId, listName);
421
+ return await resolveListIdWithValidation(listId, listName);
422
+ }
423
+ /**
424
+ * Extract and build task filters from parameters
425
+ */
426
+ function buildTaskFilters(params) {
427
+ const { subtasks, statuses, page, order_by, reverse } = params;
428
+ const filters = {};
429
+ if (subtasks !== undefined)
430
+ filters.subtasks = subtasks;
431
+ if (statuses !== undefined)
432
+ filters.statuses = statuses;
433
+ if (page !== undefined)
434
+ filters.page = page;
435
+ if (order_by !== undefined)
436
+ filters.order_by = order_by;
437
+ if (reverse !== undefined)
438
+ filters.reverse = reverse;
439
+ return filters;
440
+ }
441
+ /**
442
+ * Map tasks for bulk operations, resolving task IDs
443
+ * Uses smart disambiguation for tasks without list context
444
+ */
445
+ async function mapTaskIds(tasks) {
446
+ return Promise.all(tasks.map(async (task) => {
447
+ const validationResult = validateTaskIdentification({ taskId: task.taskId, taskName: task.taskName, listName: task.listName, customTaskId: task.customTaskId }, { useGlobalLookup: true });
448
+ if (!validationResult.isValid) {
449
+ throw new Error(validationResult.errorMessage);
450
+ }
451
+ return await getTaskId(task.taskId, task.taskName, task.listName, task.customTaskId);
452
+ }));
453
+ }
454
+ //=============================================================================
455
+ // SINGLE TASK OPERATIONS
456
+ //=============================================================================
457
+ /**
458
+ * Handler for creating a task
459
+ */
460
+ export async function createTaskHandler(params) {
461
+ const { name, description, markdown_description, status, dueDate, startDate, parent, tags, custom_fields, check_required_custom_fields, assignees } = params;
462
+ if (!name)
463
+ throw new Error("Task name is required");
464
+ // Use our helper function to validate and convert priority
465
+ const priority = toTaskPriority(params.priority);
466
+ const listId = await getListId(params.listId, params.listName);
467
+ // Resolve assignees if provided
468
+ let resolvedAssignees = undefined;
469
+ if (assignees) {
470
+ // Parse assignees if it's a string (from MCP serialization)
471
+ let assigneesArray = assignees;
472
+ if (typeof assignees === 'string') {
473
+ try {
474
+ assigneesArray = JSON.parse(assignees);
475
+ }
476
+ catch (error) {
477
+ console.warn('Failed to parse assignees string in createTask:', assignees, error);
478
+ assigneesArray = [];
479
+ }
480
+ }
481
+ resolvedAssignees = await resolveAssignees(assigneesArray);
482
+ }
483
+ const taskData = {
484
+ name,
485
+ description,
486
+ markdown_description,
487
+ status,
488
+ parent,
489
+ tags,
490
+ custom_fields,
491
+ check_required_custom_fields,
492
+ assignees: resolvedAssignees
493
+ };
494
+ // Only include priority if explicitly provided by the user
495
+ if (priority !== undefined) {
496
+ taskData.priority = priority;
497
+ }
498
+ // Add due date if specified
499
+ if (dueDate) {
500
+ taskData.due_date = parseDueDate(dueDate);
501
+ taskData.due_date_time = true;
502
+ }
503
+ // Add start date if specified
504
+ if (startDate) {
505
+ taskData.start_date = parseDueDate(startDate);
506
+ taskData.start_date_time = true;
507
+ }
508
+ return await taskService.createTask(listId, taskData);
509
+ }
510
+ /**
511
+ * Handler for updating a task
512
+ */
513
+ export async function updateTaskHandler(taskService, params) {
514
+ const { taskId, taskName, listName, customTaskId, ...rawUpdateData } = params;
515
+ // Validate task identification with global lookup enabled
516
+ const validationResult = validateTaskIdentification(params, { useGlobalLookup: true });
517
+ if (!validationResult.isValid) {
518
+ throw new Error(validationResult.errorMessage);
519
+ }
520
+ // Build properly formatted update data from raw parameters (now async)
521
+ const updateData = await buildUpdateData(rawUpdateData);
522
+ // Validate update data
523
+ validateTaskUpdateData(updateData);
524
+ try {
525
+ // Get the task ID using global lookup
526
+ const id = await getTaskId(taskId, taskName, listName, customTaskId);
527
+ return await taskService.updateTask(id, updateData);
528
+ }
529
+ catch (error) {
530
+ throw new Error(`Failed to update task: ${error instanceof Error ? error.message : String(error)}`);
531
+ }
532
+ }
533
+ /**
534
+ * Handler for moving a task
535
+ */
536
+ export async function moveTaskHandler(params) {
537
+ const taskId = await getTaskId(params.taskId, params.taskName, undefined, params.customTaskId, false);
538
+ const listId = await getListId(params.listId, params.listName);
539
+ return await taskService.moveTask(taskId, listId);
540
+ }
541
+ /**
542
+ * Handler for duplicating a task
543
+ */
544
+ export async function duplicateTaskHandler(params) {
545
+ const taskId = await getTaskId(params.taskId, params.taskName, undefined, params.customTaskId, false);
546
+ let listId;
547
+ if (params.listId || params.listName) {
548
+ listId = await getListId(params.listId, params.listName);
549
+ }
550
+ return await taskService.duplicateTask(taskId, listId);
551
+ }
552
+ /**
553
+ * Handler for getting tasks
554
+ */
555
+ export async function getTasksHandler(params) {
556
+ const listId = await getListId(params.listId, params.listName);
557
+ return await taskService.getTasks(listId, buildTaskFilters(params));
558
+ }
559
+ /**
560
+ * Handler for getting task comments
561
+ */
562
+ export async function getTaskCommentsHandler(params) {
563
+ const taskId = await getTaskId(params.taskId, params.taskName, params.listName);
564
+ const { start, startId } = params;
565
+ return await taskService.getTaskComments(taskId, start, startId);
566
+ }
567
+ /**
568
+ * Handler for creating a task comment
569
+ */
570
+ export async function createTaskCommentHandler(params) {
571
+ // Validate required parameters
572
+ if (!params.commentText) {
573
+ throw new Error('Comment text is required');
574
+ }
575
+ try {
576
+ // Resolve the task ID
577
+ const taskId = await getTaskId(params.taskId, params.taskName, params.listName);
578
+ // Extract other parameters with defaults
579
+ const { commentText, notifyAll = false, assignee = null } = params;
580
+ // Create the comment
581
+ return await taskService.createTaskComment(taskId, commentText, notifyAll, assignee);
582
+ }
583
+ catch (error) {
584
+ // If this is a task lookup error, provide more helpful message
585
+ if (error.message?.includes('not found') || error.message?.includes('identify task')) {
586
+ if (params.taskName) {
587
+ throw new Error(`Could not find task "${params.taskName}" in list "${params.listName}"`);
588
+ }
589
+ else {
590
+ throw new Error(`Task with ID "${params.taskId}" not found`);
591
+ }
592
+ }
593
+ // Otherwise, rethrow the original error
594
+ throw error;
595
+ }
596
+ }
597
+ /**
598
+ * Estimate tokens for a task response
599
+ * This is a simplified estimation - adjust based on actual token counting needs
600
+ */
601
+ function estimateTaskResponseTokens(task) {
602
+ // Base estimation for task structure
603
+ let tokenCount = 0;
604
+ // Core fields
605
+ tokenCount += (task.name?.length || 0) / 4; // Approximate tokens for name
606
+ tokenCount += (task.description?.length || 0) / 4; // Approximate tokens for description
607
+ tokenCount += (task.text_content?.length || 0) / 4; // Use text_content instead of markdown_description
608
+ // Status and other metadata
609
+ tokenCount += 5; // Basic metadata fields
610
+ // Custom fields
611
+ if (task.custom_fields) {
612
+ tokenCount += Object.keys(task.custom_fields).length * 10; // Rough estimate per custom field
613
+ }
614
+ // Add overhead for JSON structure
615
+ tokenCount *= 1.1;
616
+ return Math.ceil(tokenCount);
617
+ }
618
+ /**
619
+ * Check if response would exceed token limit
620
+ */
621
+ function wouldExceedTokenLimit(response) {
622
+ if (!response.tasks?.length)
623
+ return false;
624
+ // Calculate total estimated tokens
625
+ const totalTokens = response.tasks.reduce((sum, task) => sum + estimateTaskResponseTokens(task), 0);
626
+ // Add overhead for response structure
627
+ const estimatedTotal = totalTokens * 1.1;
628
+ return estimatedTotal > WORKSPACE_TASKS_TOKEN_LIMIT;
629
+ }
630
+ /**
631
+ * Handler for getting workspace tasks with filtering
632
+ */
633
+ export async function getWorkspaceTasksHandler(taskService, params) {
634
+ try {
635
+ // Require at least one filter parameter
636
+ const hasFilter = [
637
+ 'tags',
638
+ 'list_ids',
639
+ 'folder_ids',
640
+ 'space_ids',
641
+ 'statuses',
642
+ 'assignees',
643
+ 'date_created_gt',
644
+ 'date_created_lt',
645
+ 'date_updated_gt',
646
+ 'date_updated_lt',
647
+ 'due_date_gt',
648
+ 'due_date_lt'
649
+ ].some(key => params[key] !== undefined);
650
+ if (!hasFilter) {
651
+ throw new Error('At least one filter parameter is required (tags, list_ids, folder_ids, space_ids, statuses, assignees, or date filters)');
652
+ }
653
+ // Check if list_ids are provided for enhanced filtering via Views API
654
+ if (params.list_ids && params.list_ids.length > 0) {
655
+ logger.info('Using Views API for enhanced list filtering', {
656
+ listIds: params.list_ids,
657
+ listCount: params.list_ids.length
658
+ });
659
+ // Warning for broad queries
660
+ const hasOnlyListIds = Object.keys(params).filter(key => params[key] !== undefined && key !== 'list_ids' && key !== 'detail_level').length === 0;
661
+ if (hasOnlyListIds && params.list_ids.length > 5) {
662
+ logger.warn('Broad query detected: many lists with no additional filters', {
663
+ listCount: params.list_ids.length,
664
+ recommendation: 'Consider adding additional filters (tags, statuses, assignees, etc.) for better performance'
665
+ });
666
+ }
667
+ // Use Views API for enhanced list filtering
668
+ let allTasks = [];
669
+ const processedTaskIds = new Set();
670
+ // Create promises for concurrent fetching
671
+ const fetchPromises = params.list_ids.map(async (listId) => {
672
+ try {
673
+ // Get the default list view ID
674
+ const viewId = await taskService.getListViews(listId);
675
+ if (!viewId) {
676
+ logger.warn(`No default view found for list ${listId}, skipping`);
677
+ return [];
678
+ }
679
+ // Extract filters supported by the Views API
680
+ const supportedFilters = {
681
+ subtasks: params.subtasks,
682
+ include_closed: params.include_closed,
683
+ archived: params.archived,
684
+ order_by: params.order_by,
685
+ reverse: params.reverse,
686
+ page: params.page,
687
+ statuses: params.statuses,
688
+ assignees: params.assignees,
689
+ date_created_gt: params.date_created_gt,
690
+ date_created_lt: params.date_created_lt,
691
+ date_updated_gt: params.date_updated_gt,
692
+ date_updated_lt: params.date_updated_lt,
693
+ due_date_gt: params.due_date_gt,
694
+ due_date_lt: params.due_date_lt,
695
+ custom_fields: params.custom_fields
696
+ };
697
+ // Get tasks from the view
698
+ const tasksFromView = await taskService.getTasksFromView(viewId, supportedFilters);
699
+ return tasksFromView;
700
+ }
701
+ catch (error) {
702
+ logger.error(`Failed to get tasks from list ${listId}`, { error: error.message });
703
+ return []; // Continue with other lists even if one fails
704
+ }
705
+ });
706
+ // Execute all fetches concurrently
707
+ const taskArrays = await Promise.all(fetchPromises);
708
+ // Aggregate tasks and remove duplicates
709
+ for (const tasks of taskArrays) {
710
+ for (const task of tasks) {
711
+ if (!processedTaskIds.has(task.id)) {
712
+ allTasks.push(task);
713
+ processedTaskIds.add(task.id);
714
+ }
715
+ }
716
+ }
717
+ logger.info('Aggregated tasks from Views API', {
718
+ totalTasks: allTasks.length,
719
+ uniqueTasks: processedTaskIds.size
720
+ });
721
+ // Apply client-side filtering for unsupported filters
722
+ if (params.tags && params.tags.length > 0) {
723
+ allTasks = allTasks.filter(task => params.tags.every((tag) => task.tags.some(t => t.name === tag)));
724
+ logger.debug('Applied client-side tag filtering', {
725
+ tags: params.tags,
726
+ remainingTasks: allTasks.length
727
+ });
728
+ }
729
+ if (params.folder_ids && params.folder_ids.length > 0) {
730
+ allTasks = allTasks.filter(task => task.folder && params.folder_ids.includes(task.folder.id));
731
+ logger.debug('Applied client-side folder filtering', {
732
+ folderIds: params.folder_ids,
733
+ remainingTasks: allTasks.length
734
+ });
735
+ }
736
+ if (params.space_ids && params.space_ids.length > 0) {
737
+ allTasks = allTasks.filter(task => params.space_ids.includes(task.space.id));
738
+ logger.debug('Applied client-side space filtering', {
739
+ spaceIds: params.space_ids,
740
+ remainingTasks: allTasks.length
741
+ });
742
+ }
743
+ // Check token limit and format response
744
+ const shouldUseSummary = params.detail_level === 'summary' || wouldExceedTokenLimit({ tasks: allTasks });
745
+ if (shouldUseSummary) {
746
+ logger.info('Using summary format for Views API response', {
747
+ totalTasks: allTasks.length,
748
+ reason: params.detail_level === 'summary' ? 'requested' : 'token_limit'
749
+ });
750
+ return {
751
+ summaries: allTasks.map(task => ({
752
+ id: task.id,
753
+ name: task.name,
754
+ status: task.status.status,
755
+ list: {
756
+ id: task.list.id,
757
+ name: task.list.name
758
+ },
759
+ due_date: task.due_date,
760
+ url: task.url,
761
+ priority: task.priority?.priority || null,
762
+ tags: task.tags.map(tag => ({
763
+ name: tag.name,
764
+ tag_bg: tag.tag_bg,
765
+ tag_fg: tag.tag_fg
766
+ }))
767
+ })),
768
+ total_count: allTasks.length,
769
+ has_more: false,
770
+ next_page: 0
771
+ };
772
+ }
773
+ return {
774
+ tasks: allTasks,
775
+ total_count: allTasks.length,
776
+ has_more: false,
777
+ next_page: 0
778
+ };
779
+ }
780
+ // Fallback to existing workspace-wide task retrieval when list_ids are not provided
781
+ logger.info('Using standard workspace task retrieval');
782
+ const filters = {
783
+ tags: params.tags,
784
+ list_ids: params.list_ids,
785
+ folder_ids: params.folder_ids,
786
+ space_ids: params.space_ids,
787
+ statuses: params.statuses,
788
+ include_closed: params.include_closed,
789
+ include_archived_lists: params.include_archived_lists,
790
+ include_closed_lists: params.include_closed_lists,
791
+ archived: params.archived,
792
+ order_by: params.order_by,
793
+ reverse: params.reverse,
794
+ due_date_gt: params.due_date_gt,
795
+ due_date_lt: params.due_date_lt,
796
+ date_created_gt: params.date_created_gt,
797
+ date_created_lt: params.date_created_lt,
798
+ date_updated_gt: params.date_updated_gt,
799
+ date_updated_lt: params.date_updated_lt,
800
+ assignees: params.assignees,
801
+ page: params.page,
802
+ detail_level: params.detail_level || 'detailed',
803
+ subtasks: params.subtasks,
804
+ include_subtasks: params.include_subtasks,
805
+ include_compact_time_entries: params.include_compact_time_entries,
806
+ custom_fields: params.custom_fields
807
+ };
808
+ // Get tasks with adaptive response format support
809
+ const response = await taskService.getWorkspaceTasks(filters);
810
+ // Check token limit at handler level
811
+ if (params.detail_level !== 'summary' && wouldExceedTokenLimit(response)) {
812
+ logger.info('Response would exceed token limit, fetching summary format instead');
813
+ // Refetch with summary format
814
+ const summaryResponse = await taskService.getWorkspaceTasks({
815
+ ...filters,
816
+ detail_level: 'summary'
817
+ });
818
+ return summaryResponse;
819
+ }
820
+ // Return the response without adding the redundant _note field
821
+ return response;
822
+ }
823
+ catch (error) {
824
+ throw new Error(`Failed to get workspace tasks: ${error.message}`);
825
+ }
826
+ }
827
+ //=============================================================================
828
+ // BULK TASK OPERATIONS
829
+ //=============================================================================
830
+ /**
831
+ * Handler for creating multiple tasks
832
+ */
833
+ export async function createBulkTasksHandler(params) {
834
+ const { tasks, listId, listName, options } = params;
835
+ // Validate tasks array
836
+ validateBulkTasks(tasks, 'create');
837
+ // Validate and resolve list ID
838
+ const targetListId = await resolveListIdWithValidation(listId, listName);
839
+ // Format tasks for creation - resolve assignees for each task
840
+ const formattedTasks = await Promise.all(tasks.map(async (task) => {
841
+ // Resolve assignees if provided
842
+ const resolvedAssignees = task.assignees ? await resolveAssignees(task.assignees) : undefined;
843
+ const taskData = {
844
+ name: task.name,
845
+ description: task.description,
846
+ markdown_description: task.markdown_description,
847
+ status: task.status,
848
+ tags: task.tags,
849
+ custom_fields: task.custom_fields,
850
+ assignees: resolvedAssignees
851
+ };
852
+ // Only include priority if explicitly provided by the user
853
+ const priority = toTaskPriority(task.priority);
854
+ if (priority !== undefined) {
855
+ taskData.priority = priority;
856
+ }
857
+ // Add due date if specified
858
+ if (task.dueDate) {
859
+ taskData.due_date = parseDueDate(task.dueDate);
860
+ taskData.due_date_time = true;
861
+ }
862
+ // Add start date if specified
863
+ if (task.startDate) {
864
+ taskData.start_date = parseDueDate(task.startDate);
865
+ taskData.start_date_time = true;
866
+ }
867
+ return taskData;
868
+ }));
869
+ // Parse bulk options
870
+ const bulkOptions = parseBulkOptions(options);
871
+ // Create tasks - pass arguments in correct order: listId, tasks, options
872
+ return await bulkService.createTasks(targetListId, formattedTasks, bulkOptions);
873
+ }
874
+ /**
875
+ * Handler for updating multiple tasks
876
+ */
877
+ export async function updateBulkTasksHandler(params) {
878
+ const { tasks, options } = params;
879
+ // Validate tasks array
880
+ validateBulkTasks(tasks, 'update');
881
+ // Parse bulk options
882
+ const bulkOptions = parseBulkOptions(options);
883
+ // Update tasks
884
+ return await bulkService.updateTasks(tasks, bulkOptions);
885
+ }
886
+ /**
887
+ * Handler for moving multiple tasks
888
+ */
889
+ export async function moveBulkTasksHandler(params) {
890
+ const { tasks, targetListId, targetListName, options } = params;
891
+ // Validate tasks array
892
+ validateBulkTasks(tasks, 'move');
893
+ // Validate and resolve target list ID
894
+ const resolvedTargetListId = await resolveListIdWithValidation(targetListId, targetListName);
895
+ // Parse bulk options
896
+ const bulkOptions = parseBulkOptions(options);
897
+ // Move tasks
898
+ return await bulkService.moveTasks(tasks, resolvedTargetListId, bulkOptions);
899
+ }
900
+ /**
901
+ * Handler for deleting multiple tasks
902
+ */
903
+ export async function deleteBulkTasksHandler(params) {
904
+ const { tasks, options } = params;
905
+ // Validate tasks array
906
+ validateBulkTasks(tasks, 'delete');
907
+ // Parse bulk options
908
+ const bulkOptions = parseBulkOptions(options);
909
+ // Delete tasks
910
+ return await bulkService.deleteTasks(tasks, bulkOptions);
911
+ }
912
+ /**
913
+ * Handler for deleting a task
914
+ */
915
+ export async function deleteTaskHandler(params) {
916
+ const taskId = await getTaskId(params.taskId, params.taskName, params.listName);
917
+ await taskService.deleteTask(taskId);
918
+ return true;
919
+ }