UNPKG

@taazkareem/clickup-mcp-server

Version:

ClickUp MCP Server - Integrate ClickUp tasks with AI through Model Context Protocol

834 lines (824 loc) 30.2 kB
/** * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com> * SPDX-License-Identifier: MIT * * ClickUp Tag Tools * * Provides tools for managing tags in ClickUp: * - Get space tags * - Create, update, delete space tags * - Add/remove tags to/from tasks */ import { clickUpServices } from '../services/shared.js'; import { Logger } from '../logger.js'; import { sponsorService } from '../utils/sponsor-service.js'; import { processColorCommand } from '../utils/color-processor.js'; import { validateTaskIdentification } from './task/utilities.js'; // Create a logger specific to tag tools const logger = new Logger('TagTools'); // Use shared services instance const { task: taskService } = clickUpServices; //============================================================================= // TOOL DEFINITIONS //============================================================================= /** * Tool definition for getting space tags */ export const getSpaceTagsTool = { name: "get_space_tags", description: `Gets all tags in a ClickUp space. Use spaceId (preferred) or spaceName. Tags are defined at space level - check available tags before adding to tasks.`, inputSchema: { type: "object", properties: { spaceId: { type: "string", description: "ID of the space to get tags from. Use this instead of spaceName if you have the ID." }, spaceName: { type: "string", description: "Name of the space to get tags from. Only use if you don't have spaceId." } } } }; /** * Tool definition for creating a tag in a space */ export const createSpaceTagTool = { name: "create_space_tag", description: `Purpose: Create a new tag in a ClickUp space. Valid Usage: 1. Provide spaceId (preferred if available) 2. Provide spaceName (will be resolved to a space ID) Requirements: - tagName: REQUIRED - EITHER spaceId OR spaceName: REQUIRED Notes: - New tag will be available for all tasks in the space - You can specify background and foreground colors in HEX format (e.g., #FF0000) - You can also provide a color command (e.g., "blue tag") to automatically generate colors - After creating a tag, you can add it to tasks using add_tag_to_task`, inputSchema: { type: "object", properties: { spaceId: { type: "string", description: "ID of the space to create tag in. Use this instead of spaceName if you have the ID." }, spaceName: { type: "string", description: "Name of the space to create tag in. Only use if you don't have spaceId." }, tagName: { type: "string", description: "Name of the tag to create." }, tagBg: { type: "string", description: "Background color for the tag in HEX format (e.g., #FF0000). Defaults to #000000 (black)." }, tagFg: { type: "string", description: "Foreground (text) color for the tag in HEX format (e.g., #FFFFFF). Defaults to #FFFFFF (white)." }, colorCommand: { type: "string", description: "Natural language color command (e.g., 'blue tag', 'dark red background'). When provided, this will override tagBg and tagFg with automatically generated values." } }, required: ["tagName"] } }; /** * Tool definition for updating a tag in a space */ export const updateSpaceTagTool = { name: "update_space_tag", description: `Purpose: Update an existing tag in a ClickUp space. Valid Usage: 1. Provide spaceId (preferred if available) 2. Provide spaceName (will be resolved to a space ID) Requirements: - tagName: REQUIRED - EITHER spaceId OR spaceName: REQUIRED - At least one of newTagName, tagBg, tagFg, or colorCommand must be provided Notes: - Changes to the tag will apply to all tasks in the space that use this tag - You can provide a color command (e.g., "blue tag") to automatically generate colors - You cannot partially update a tag - provide all properties you want to keep`, inputSchema: { type: "object", properties: { spaceId: { type: "string", description: "ID of the space containing the tag. Use this instead of spaceName if you have the ID." }, spaceName: { type: "string", description: "Name of the space containing the tag. Only use if you don't have spaceId." }, tagName: { type: "string", description: "Current name of the tag to update." }, newTagName: { type: "string", description: "New name for the tag." }, tagBg: { type: "string", description: "New background color for the tag in HEX format (e.g., #FF0000)." }, tagFg: { type: "string", description: "New foreground (text) color for the tag in HEX format (e.g., #FFFFFF)." }, colorCommand: { type: "string", description: "Natural language color command (e.g., 'blue tag', 'dark red background'). When provided, this will override tagBg and tagFg with automatically generated values." } }, required: ["tagName"] } }; /** * Tool definition for deleting a tag in a space */ export const deleteSpaceTagTool = { name: "delete_space_tag", description: `Purpose: Delete a tag from a ClickUp space. Valid Usage: 1. Provide spaceId (preferred if available) 2. Provide spaceName (will be resolved to a space ID) Requirements: - tagName: REQUIRED - EITHER spaceId OR spaceName: REQUIRED Warning: - This will remove the tag from all tasks in the space - This action cannot be undone`, inputSchema: { type: "object", properties: { spaceId: { type: "string", description: "ID of the space containing the tag. Use this instead of spaceName if you have the ID." }, spaceName: { type: "string", description: "Name of the space containing the tag. Only use if you don't have spaceId." }, tagName: { type: "string", description: "Name of the tag to delete." } }, required: ["tagName"] } }; /** * Tool definition for adding a tag to a task */ export const addTagToTaskTool = { name: "add_tag_to_task", description: `Adds existing tag to task. Use taskId (preferred) or taskName + optional listName. Tag must exist in space (use get_space_tags to verify, create_space_tag if needed). WARNING: Will fail if tag doesn't exist.`, inputSchema: { type: "object", properties: { taskId: { type: "string", description: "ID of the task to add tag to. Works with both regular task IDs (9 characters) and custom IDs with uppercase prefixes (like 'DEV-1234')." }, customTaskId: { type: "string", description: "Custom task ID (e.g., 'DEV-1234'). Only use if you want to explicitly force custom ID lookup. In most cases, use taskId which auto-detects ID format." }, taskName: { type: "string", description: "Name of the task to add tag to. Will search across all lists unless listName is provided." }, listName: { type: "string", description: "Optional: Name of the list containing the task. Use to disambiguate when multiple tasks have the same name." }, tagName: { type: "string", description: "Name of the tag to add to the task. The tag must already exist in the space." } }, required: ["tagName"] } }; /** * Tool definition for removing a tag from a task */ export const removeTagFromTaskTool = { name: "remove_tag_from_task", description: `Removes tag from task. Use taskId (preferred) or taskName + optional listName. Only removes tag-task association, tag remains in space. For multiple tasks, provide listName to disambiguate.`, inputSchema: { type: "object", properties: { taskId: { type: "string", description: "ID of the task to remove tag from. Works with both regular task IDs (9 characters) and custom IDs with uppercase prefixes (like 'DEV-1234')." }, customTaskId: { type: "string", description: "Custom task ID (e.g., 'DEV-1234'). Only use if you want to explicitly force custom ID lookup. In most cases, use taskId which auto-detects ID format." }, taskName: { type: "string", description: "Name of the task to remove tag from. Will search across all lists unless listName is provided." }, listName: { type: "string", description: "Optional: Name of the list containing the task. Use to disambiguate when multiple tasks have the same name." }, tagName: { type: "string", description: "Name of the tag to remove from the task." } }, required: ["tagName"] } }; //============================================================================= // HANDLER WRAPPER UTILITY //============================================================================= /** * Creates a wrapped handler function with standard error handling and response formatting */ function createHandlerWrapper(handler, formatResponse = (result) => result) { return async (params) => { try { logger.debug('Handler called with params', { params }); // Call the handler const result = await handler(params); // Format the result for response const formattedResult = formatResponse(result); // Use the sponsor service to create the formatted response return sponsorService.createResponse(formattedResult, true); } catch (error) { // Log the error logger.error('Error in handler', { error: error.message, code: error.code }); // Format and return the error using sponsor service return sponsorService.createErrorResponse(error, params); } }; } //============================================================================= // TAG TOOL HANDLERS //============================================================================= /** * Wrapper for getSpaceTags handler */ export const handleGetSpaceTags = createHandlerWrapper(getSpaceTags, (tags) => ({ tags: tags || [], count: Array.isArray(tags) ? tags.length : 0 })); /** * Wrapper for createSpaceTag handler */ export const handleCreateSpaceTag = createHandlerWrapper(createSpaceTag); /** * Wrapper for updateSpaceTag handler */ export const handleUpdateSpaceTag = createHandlerWrapper(updateSpaceTag); /** * Wrapper for deleteSpaceTag handler */ export const handleDeleteSpaceTag = createHandlerWrapper(deleteSpaceTag, () => ({ success: true, message: "Tag deleted successfully" })); /** * Wrapper for addTagToTask handler */ export const handleAddTagToTask = createHandlerWrapper(addTagToTask, (result) => { if (!result.success) { return { success: false, error: result.error }; } return { success: true, message: "Tag added to task successfully" }; }); /** * Wrapper for removeTagFromTask handler */ export const handleRemoveTagFromTask = createHandlerWrapper(removeTagFromTask, () => ({ success: true, message: "Tag removed from task successfully" })); //============================================================================= // TOOL DEFINITIONS AND HANDLERS EXPORT //============================================================================= // Tool definitions with their handler mappings export const tagTools = [ { definition: getSpaceTagsTool, handler: handleGetSpaceTags }, { definition: createSpaceTagTool, handler: handleCreateSpaceTag }, { definition: updateSpaceTagTool, handler: handleUpdateSpaceTag }, { definition: deleteSpaceTagTool, handler: handleDeleteSpaceTag }, { definition: addTagToTaskTool, handler: handleAddTagToTask }, { definition: removeTagFromTaskTool, handler: handleRemoveTagFromTask } ]; /** * Get all tags in a space * @param params - Space identifier (id or name) * @returns Tags in the space */ export async function getSpaceTags(params) { const { spaceId, spaceName } = params; if (!spaceId && !spaceName) { logger.error('getSpaceTags called without required parameters'); throw new Error('Either spaceId or spaceName is required'); } logger.info('Getting tags for space', { spaceId, spaceName }); try { // If spaceName is provided, we need to resolve it to an ID let resolvedSpaceId = spaceId; if (!resolvedSpaceId && spaceName) { logger.debug(`Resolving space name: ${spaceName}`); const spaces = await clickUpServices.workspace.getSpaces(); const space = spaces.find(s => s.name.toLowerCase() === spaceName.toLowerCase()); if (!space) { logger.error(`Space not found: ${spaceName}`); throw new Error(`Space not found: ${spaceName}`); } resolvedSpaceId = space.id; } // Get tags from the space const tagsResponse = await clickUpServices.tag.getSpaceTags(resolvedSpaceId); if (!tagsResponse.success) { logger.error('Failed to get space tags', tagsResponse.error); throw new Error(tagsResponse.error?.message || 'Failed to get space tags'); } logger.info(`Successfully retrieved ${tagsResponse.data?.length || 0} tags`); return tagsResponse.data || []; } catch (error) { logger.error('Error in getSpaceTags', error); throw error; } } /** * Create a new tag in a space * @param params - Space identifier and tag details * @returns Created tag */ export async function createSpaceTag(params) { let { spaceId, spaceName, tagName, tagBg = '#000000', tagFg = '#ffffff', colorCommand } = params; // Process color command if provided if (colorCommand) { const colors = processColorCommand(colorCommand); if (colors) { tagBg = colors.background; tagFg = colors.foreground; logger.info(`Processed color command: "${colorCommand}" → BG: ${tagBg}, FG: ${tagFg}`); } else { logger.warn(`Could not process color command: "${colorCommand}". Using default colors.`); } } if (!tagName) { logger.error('createSpaceTag called without tagName'); return { success: false, error: { message: 'tagName is required' } }; } if (!spaceId && !spaceName) { logger.error('createSpaceTag called without space identifier'); return { success: false, error: { message: 'Either spaceId or spaceName is required' } }; } logger.info('Creating tag in space', { spaceId, spaceName, tagName, tagBg, tagFg }); try { // If spaceName is provided, we need to resolve it to an ID let resolvedSpaceId = spaceId; if (!resolvedSpaceId && spaceName) { logger.debug(`Resolving space name: ${spaceName}`); const spaces = await clickUpServices.workspace.getSpaces(); const space = spaces.find(s => s.name.toLowerCase() === spaceName.toLowerCase()); if (!space) { logger.error(`Space not found: ${spaceName}`); return { success: false, error: { message: `Space not found: ${spaceName}` } }; } resolvedSpaceId = space.id; } // Create tag in the space const tagResponse = await clickUpServices.tag.createSpaceTag(resolvedSpaceId, { tag_name: tagName, tag_bg: tagBg, tag_fg: tagFg }); if (!tagResponse.success) { logger.error('Failed to create space tag', tagResponse.error); return { success: false, error: tagResponse.error || { message: 'Failed to create space tag' } }; } logger.info(`Successfully created tag: ${tagName}`); return { success: true, data: tagResponse.data }; } catch (error) { logger.error('Error in createSpaceTag', error); return { success: false, error: { message: error.message || 'Failed to create space tag', code: error.code, details: error.data } }; } } /** * Update an existing tag in a space * @param params - Space identifier, tag name, and updated properties * @returns Updated tag */ export async function updateSpaceTag(params) { const { spaceId, spaceName, tagName, newTagName, colorCommand } = params; let { tagBg, tagFg } = params; // Process color command if provided if (colorCommand) { const colors = processColorCommand(colorCommand); if (colors) { tagBg = colors.background; tagFg = colors.foreground; logger.info(`Processed color command: "${colorCommand}" → BG: ${tagBg}, FG: ${tagFg}`); } else { logger.warn(`Could not process color command: "${colorCommand}". Using default colors.`); } } if (!tagName) { logger.error('updateSpaceTag called without tagName'); return { success: false, error: { message: 'tagName is required' } }; } if (!spaceId && !spaceName) { logger.error('updateSpaceTag called without space identifier'); return { success: false, error: { message: 'Either spaceId or spaceName is required' } }; } // Make sure there's at least one property to update if (!newTagName && !tagBg && !tagFg && !colorCommand) { logger.error('updateSpaceTag called without properties to update'); return { success: false, error: { message: 'At least one property (newTagName, tagBg, tagFg, or colorCommand) must be provided' } }; } logger.info('Updating tag in space', { spaceId, spaceName, tagName, newTagName, tagBg, tagFg }); try { // If spaceName is provided, we need to resolve it to an ID let resolvedSpaceId = spaceId; if (!resolvedSpaceId && spaceName) { logger.debug(`Resolving space name: ${spaceName}`); const spaces = await clickUpServices.workspace.getSpaces(); const space = spaces.find(s => s.name.toLowerCase() === spaceName.toLowerCase()); if (!space) { logger.error(`Space not found: ${spaceName}`); return { success: false, error: { message: `Space not found: ${spaceName}` } }; } resolvedSpaceId = space.id; } // Prepare update data const updateData = {}; if (newTagName) updateData.tag_name = newTagName; if (tagBg) updateData.tag_bg = tagBg; if (tagFg) updateData.tag_fg = tagFg; // Update tag in the space const tagResponse = await clickUpServices.tag.updateSpaceTag(resolvedSpaceId, tagName, updateData); if (!tagResponse.success) { logger.error('Failed to update space tag', tagResponse.error); return { success: false, error: tagResponse.error || { message: 'Failed to update space tag' } }; } logger.info(`Successfully updated tag: ${tagName}`); return { success: true, data: tagResponse.data }; } catch (error) { logger.error('Error in updateSpaceTag', error); return { success: false, error: { message: error.message || 'Failed to update space tag', code: error.code, details: error.data } }; } } /** * Delete a tag from a space * @param params - Space identifier and tag name * @returns Success status */ export async function deleteSpaceTag(params) { const { spaceId, spaceName, tagName } = params; if (!tagName) { logger.error('deleteSpaceTag called without tagName'); return { success: false, error: { message: 'tagName is required' } }; } if (!spaceId && !spaceName) { logger.error('deleteSpaceTag called without space identifier'); return { success: false, error: { message: 'Either spaceId or spaceName is required' } }; } logger.info('Deleting tag from space', { spaceId, spaceName, tagName }); try { // If spaceName is provided, we need to resolve it to an ID let resolvedSpaceId = spaceId; if (!resolvedSpaceId && spaceName) { logger.debug(`Resolving space name: ${spaceName}`); const spaces = await clickUpServices.workspace.getSpaces(); const space = spaces.find(s => s.name.toLowerCase() === spaceName.toLowerCase()); if (!space) { logger.error(`Space not found: ${spaceName}`); return { success: false, error: { message: `Space not found: ${spaceName}` } }; } resolvedSpaceId = space.id; } // Delete tag from the space const tagResponse = await clickUpServices.tag.deleteSpaceTag(resolvedSpaceId, tagName); if (!tagResponse.success) { logger.error('Failed to delete space tag', tagResponse.error); return { success: false, error: tagResponse.error || { message: 'Failed to delete space tag' } }; } logger.info(`Successfully deleted tag: ${tagName}`); return { success: true }; } catch (error) { logger.error('Error in deleteSpaceTag', error); return { success: false, error: { message: error.message || 'Failed to delete space tag', code: error.code, details: error.data } }; } } /** * Simple task ID resolver */ async function resolveTaskId(params) { const { taskId, customTaskId, taskName, listName } = params; try { // First validate task identification with global lookup enabled const validationResult = validateTaskIdentification({ taskId, customTaskId, taskName, listName }, { useGlobalLookup: true }); if (!validationResult.isValid) { return { success: false, error: { message: validationResult.errorMessage } }; } const result = await taskService.findTasks({ taskId, customTaskId, taskName, listName, allowMultipleMatches: false, useSmartDisambiguation: true, includeFullDetails: false }); if (!result || Array.isArray(result)) { return { success: false, error: { message: 'Task not found with the provided identification' } }; } return { success: true, taskId: result.id }; } catch (error) { return { success: false, error: { message: error.message || 'Failed to resolve task ID', code: error.code, details: error.data } }; } } /** * Add a tag to a task * @param params - Task identifier and tag name * @returns Success status */ export async function addTagToTask(params) { const { taskId, customTaskId, taskName, listName, tagName } = params; if (!tagName) { logger.error('addTagToTask called without tagName'); return { success: false, error: { message: 'tagName is required' } }; } if (!taskId && !customTaskId && !taskName) { logger.error('addTagToTask called without task identifier'); return { success: false, error: { message: 'Either taskId, customTaskId, or taskName is required' } }; } logger.info('Adding tag to task', { taskId, customTaskId, taskName, listName, tagName }); try { // Resolve the task ID const taskIdResult = await resolveTaskId({ taskId, customTaskId, taskName, listName }); if (!taskIdResult.success) { return { success: false, error: taskIdResult.error }; } // Add tag to the task const result = await clickUpServices.tag.addTagToTask(taskIdResult.taskId, tagName); if (!result.success) { logger.error('Failed to add tag to task', result.error); // Provide more specific error messages based on error code if (result.error?.code === 'TAG_NOT_FOUND') { return { success: false, error: { message: `The tag "${tagName}" does not exist in the space. Please create it first using create_space_tag.` } }; } else if (result.error?.code === 'SPACE_NOT_FOUND') { return { success: false, error: { message: 'Could not determine which space the task belongs to.' } }; } else if (result.error?.code === 'TAG_VERIFICATION_FAILED') { return { success: false, error: { message: 'The tag addition could not be verified. Please check if the tag was added manually.' } }; } return { success: false, error: result.error || { message: 'Failed to add tag to task' } }; } logger.info(`Successfully added tag "${tagName}" to task ${taskIdResult.taskId}`); return { success: true }; } catch (error) { logger.error('Error in addTagToTask', error); return { success: false, error: { message: error.message || 'Failed to add tag to task', code: error.code, details: error.data } }; } } /** * Remove a tag from a task * @param params - Task identifier and tag name * @returns Success status */ export async function removeTagFromTask(params) { const { taskId, customTaskId, taskName, listName, tagName } = params; if (!tagName) { logger.error('removeTagFromTask called without tagName'); return { success: false, error: { message: 'tagName is required' } }; } if (!taskId && !customTaskId && !taskName) { logger.error('removeTagFromTask called without task identifier'); return { success: false, error: { message: 'Either taskId, customTaskId, or taskName is required' } }; } logger.info('Removing tag from task', { taskId, customTaskId, taskName, listName, tagName }); try { // Resolve the task ID const taskIdResult = await resolveTaskId({ taskId, customTaskId, taskName, listName }); if (!taskIdResult.success) { return { success: false, error: taskIdResult.error }; } // Remove tag from the task const result = await clickUpServices.tag.removeTagFromTask(taskIdResult.taskId, tagName); if (!result.success) { logger.error('Failed to remove tag from task', result.error); return { success: false, error: result.error || { message: 'Failed to remove tag from task' } }; } logger.info(`Successfully removed tag "${tagName}" from task ${taskIdResult.taskId}`); return { success: true }; } catch (error) { logger.error('Error in removeTagFromTask', error); return { success: false, error: { message: error.message || 'Failed to remove tag from task', code: error.code, details: error.data } }; } }