@taazkareem/clickup-mcp-server
Version:
ClickUp MCP Server - Integrate ClickUp tasks with AI through Model Context Protocol
311 lines (310 loc) • 11.2 kB
JavaScript
/**
* SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
* SPDX-License-Identifier: MIT
*
* ClickUp MCP Task Utilities
*
* This module provides utility functions for task-related operations including
* data formatting, validation, and resolution of IDs from names.
*/
import { formatDueDate } from '../utils.js';
import { clickUpServices } from '../../services/shared.js';
import { findListIDByName } from '../../tools/list.js';
// Use shared services instance for ID resolution
const { workspace: workspaceService, task: taskService } = clickUpServices;
//=============================================================================
// DATA FORMATTING UTILITIES
//=============================================================================
/**
* Formats task data for response
*/
export function formatTaskData(task, additional = {}) {
return {
id: task.id,
custom_id: task.custom_id,
name: task.name,
text_content: task.text_content,
description: task.description,
url: task.url,
status: task.status?.status || "Unknown",
status_color: task.status?.color,
orderindex: task.orderindex,
date_created: task.date_created,
date_updated: task.date_updated,
date_closed: task.date_closed,
creator: task.creator,
assignees: task.assignees,
watchers: task.watchers,
checklists: task.checklists,
tags: task.tags,
parent: task.parent,
priority: task.priority,
due_date: task.due_date ? formatDueDate(Number(task.due_date)) : undefined,
start_date: task.start_date ? formatDueDate(Number(task.start_date)) : undefined,
time_estimate: task.time_estimate,
time_spent: task.time_spent,
custom_fields: task.custom_fields,
dependencies: task.dependencies,
linked_tasks: task.linked_tasks,
team_id: task.team_id,
list: {
id: task.list.id,
name: task.list.name,
access: task.list.access
},
folder: task.folder ? {
id: task.folder.id,
name: task.folder.name,
hidden: task.folder.hidden,
access: task.folder.access
} : null,
space: {
id: task.space.id,
name: task.space.name
},
...additional
};
}
//=============================================================================
// TASK ID DETECTION UTILITIES
//=============================================================================
/**
* Detects if a task ID is a custom task ID based on common patterns
* Custom task IDs typically:
* - Contain hyphens (e.g., "DEV-1234", "PROJ-456")
* - Have uppercase prefixes followed by numbers
* - Are not 9-character alphanumeric strings (regular ClickUp task IDs)
*
* @param taskId The task ID to check
* @returns true if the ID appears to be a custom task ID
*/
export function isCustomTaskId(taskId) {
if (!taskId || typeof taskId !== 'string') {
return false;
}
// Trim whitespace
taskId = taskId.trim();
// Regular ClickUp task IDs are typically 9 characters, alphanumeric
// Custom task IDs usually have different patterns
// Check if it's a standard 9-character ClickUp ID (letters and numbers only)
const standardIdPattern = /^[a-zA-Z0-9]{9}$/;
if (standardIdPattern.test(taskId)) {
return false;
}
// Check for common custom task ID patterns:
// 1. Contains hyphens (most common pattern: PREFIX-NUMBER)
if (taskId.includes('-')) {
// Additional validation: should have letters before hyphen and numbers after
const hyphenPattern = /^[A-Za-z]+[-][0-9]+$/;
return hyphenPattern.test(taskId);
}
// 2. Contains underscores (another common pattern: PREFIX_NUMBER)
if (taskId.includes('_')) {
const underscorePattern = /^[A-Za-z]+[_][0-9]+$/;
return underscorePattern.test(taskId);
}
// 3. Contains uppercase letters followed by numbers (without separators)
const customIdPattern = /^[A-Z]+\d+$/;
if (customIdPattern.test(taskId)) {
return true;
}
// 4. Mixed case with numbers but not 9 characters (less common)
const mixedCasePattern = /^[A-Za-z]+\d+$/;
if (mixedCasePattern.test(taskId) && taskId.length !== 9) {
return true;
}
// 5. Contains dots (some organizations use PROJECT.TASK format)
if (taskId.includes('.')) {
const dotPattern = /^[A-Za-z]+[.][0-9]+$/;
return dotPattern.test(taskId);
}
// If none of the patterns match, assume it's a regular task ID
return false;
}
/**
* Validates task identification parameters
*
* @param params - Task identification parameters
* @param options - Validation options
* @returns Validation result with error message if any
*/
export function validateTaskIdentification(params, options = {}) {
const { taskId, taskName, customTaskId, listName } = params;
const { requireTaskId = false, useGlobalLookup = true } = options;
// If taskId is required, it must be provided
if (requireTaskId && !taskId) {
return {
isValid: false,
errorMessage: 'Task ID is required for this operation'
};
}
// At least one identification method must be provided
if (!taskId && !taskName && !customTaskId) {
return {
isValid: false,
errorMessage: 'Either taskId, taskName, or customTaskId must be provided to identify the task'
};
}
// When using taskName without global lookup, listName is required
if (taskName && !taskId && !customTaskId && !useGlobalLookup && !listName) {
return {
isValid: false,
errorMessage: 'When identifying a task by name, you must also provide the listName parameter'
};
}
return { isValid: true };
}
/**
* Validates list identification parameters
* Ensures either listId or listName is provided
*/
export function validateListIdentification(listId, listName) {
if (!listId && !listName) {
throw new Error("Either listId or listName must be provided");
}
}
/**
* Validates task update data
* Ensures at least one update field is provided
*/
export function validateTaskUpdateData(updateData) {
// Validate custom_fields if provided
if (updateData.custom_fields) {
if (!Array.isArray(updateData.custom_fields)) {
throw new Error("custom_fields must be an array");
}
for (const field of updateData.custom_fields) {
if (!field.id || field.value === undefined) {
throw new Error("Each custom field must have both id and value properties");
}
}
}
// Ensure there's at least one field to update
if (Object.keys(updateData).length === 0) {
throw new Error("At least one field to update must be provided");
}
}
/**
* Validate bulk task array and task identification
* @param tasks Array of tasks to validate
* @param operation The bulk operation type ('create', 'update', 'move', 'delete')
*/
export function validateBulkTasks(tasks, operation = 'update') {
if (!Array.isArray(tasks) || tasks.length === 0) {
throw new Error("tasks must be a non-empty array");
}
tasks.forEach((task, index) => {
if (!task || typeof task !== 'object') {
throw new Error(`Task at index ${index} must be an object`);
}
// Skip task identification validation for create operations
if (operation === 'create') {
return;
}
// For bulk operations, require listName when using taskName
if (task.taskName && !task.listName) {
throw new Error(`Task at index ${index} using taskName must also provide listName`);
}
// At least one identifier is required for non-create operations
if (!task.taskId && !task.taskName && !task.customTaskId) {
throw new Error(`Task at index ${index} must provide either taskId, taskName + listName, or customTaskId`);
}
});
}
/**
* Parse options for bulk operations
*/
export function parseBulkOptions(rawOptions) {
if (typeof rawOptions === 'string') {
try {
return JSON.parse(rawOptions);
}
catch (error) {
return undefined;
}
}
return rawOptions;
}
/**
* Resolves a list ID from either direct ID or name
* Handles validation and throws appropriate errors
*/
export async function resolveListIdWithValidation(listId, listName) {
// Validate parameters
validateListIdentification(listId, listName);
// If listId is provided, use it directly
if (listId)
return listId;
// At this point we know we have listName (validation ensures this)
const listInfo = await findListIDByName(workspaceService, listName);
if (!listInfo) {
throw new Error(`List "${listName}" not found`);
}
return listInfo.id;
}
//=============================================================================
// PATH EXTRACTION HELPER FUNCTIONS
//=============================================================================
/**
* Extract path from node to root
*/
export function extractPath(node) {
if (!node)
return '';
if (!node.parent)
return node.name;
return `${extractPath(node.parent)} > ${node.name}`;
}
/**
* Extract path from root to a specific node
*/
export function extractTreePath(root, targetId) {
if (!root)
return [];
// If this node is the target, return it in an array
if (root.id === targetId) {
return [root];
}
// Check children if they exist
if (root.children) {
for (const child of root.children) {
const path = extractTreePath(child, targetId);
if (path.length > 0) {
return [root, ...path];
}
}
}
// Not found in this branch
return [];
}
/**
* Get task ID from various identification methods
*/
export async function getTaskId(taskId, taskName, listName, customTaskId, requireId = false) {
// Validate task identification
const validationResult = validateTaskIdentification({ taskId, taskName, listName, customTaskId }, { requireTaskId: requireId, useGlobalLookup: true });
if (!validationResult.isValid) {
throw new Error(validationResult.errorMessage);
}
try {
const result = await taskService.findTasks({
taskId,
customTaskId,
taskName,
listName,
allowMultipleMatches: false,
useSmartDisambiguation: true,
includeFullDetails: false
});
if (!result || Array.isArray(result)) {
throw new Error(`Task not found with the provided identification`);
}
return result.id;
}
catch (error) {
if (error.message.includes('Multiple tasks found')) {
throw new Error(`Multiple tasks found with name "${taskName}". Please provide list name to disambiguate.`);
}
throw error;
}
}