@twofeetup/clickup-mcp
Version:
Optimized ClickUp MCP Server - High-performance AI integration with consolidated tools and response optimization
627 lines (621 loc) • 22 kB
JavaScript
/**
* SPDX-FileCopyrightText: © 2025 Sjoerd Tiemensma
* SPDX-License-Identifier: MIT
*
* Consolidated Tag Management Tool
*
* Unified MCP tool for managing tags across ClickUp workspaces.
* Combines 6 individual tools into one flexible interface with two scopes:
* - "space": Create, list, update, delete tags in a space
* - "task": Add/remove tags from individual tasks
*
* AI-first design with natural language color support and flexible identification.
*/
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';
import { workspaceCache } from '../utils/cache-service.js';
// Create logger for tag tools
const logger = new Logger('TagTools');
// Use shared services
const { task: taskService } = clickUpServices;
//=============================================================================
// TOOL DEFINITION
//=============================================================================
/**
* Consolidated operate_tags tool definition
*
* Unified interface for all tag operations with two primary scopes:
*
* SPACE SCOPE (tag management in spaces):
* - action: "list" - Get all tags in a space
* - action: "create" - Create a new tag in a space
* - action: "update" - Update an existing tag
* - action: "delete" - Delete a tag from a space
*
* TASK SCOPE (tag assignment to tasks):
* - action: "add" - Add an existing tag to a task
* - action: "remove" - Remove a tag from a task
*
* Example Usage:
* - List space tags: scope="space", action="list", spaceId="123"
* - Create tag: scope="space", action="create", spaceId="123", tagName="bug", colorCommand="red tag"
* - Add tag to task: scope="task", action="add", taskId="abc123", tagName="bug"
* - Remove tag from task: scope="task", action="remove", taskId="abc123", tagName="bug"
*/
export const operateTagsTool = {
name: "operate_tags",
description: `Unified tag management for ClickUp workspaces.
SPACE SCOPE - Manage tags in a space:
- list: Get all tags in a space
- create: Create a new tag (requires tagName, optional colors)
- update: Update existing tag (requires tagName, at least one property to update)
- delete: Remove tag from space (requires tagName)
TASK SCOPE - Manage tag assignments:
- add: Add existing tag to a task (tag must exist in space)
- remove: Remove tag from a task
FLEXIBLE IDENTIFICATION:
- Spaces: Use spaceId (preferred) or spaceName
- Tasks: Use taskId (preferred), customTaskId (for custom IDs like DEV-1234), or taskName
- When using taskName, optionally provide listName to disambiguate
COLOR SUPPORT:
- Use natural language: "red tag", "dark blue background", "bright green"
- Or specify hex: tagBg="#FF0000", tagFg="#FFFFFF"
DETAIL LEVELS:
- minimal: Only essential fields
- standard: Common fields (default)
- detailed: All available information`,
inputSchema: {
type: "object",
properties: {
scope: {
type: "string",
enum: ["space", "task"],
description: 'Operation scope: "space" for tag management, "task" for tag assignment'
},
action: {
type: "string",
enum: ["list", "create", "update", "delete", "add", "remove"],
description: 'Action to perform. For space: list/create/update/delete. For task: add/remove'
},
spaceId: {
type: "string",
description: "ID of the space (for space scope operations). Use instead of spaceName if available."
},
spaceName: {
type: "string",
description: "Name of the space to resolve to ID (for space scope operations)"
},
taskId: {
type: "string",
description: "ID of the task (for task scope operations). Works with both standard and custom IDs."
},
customTaskId: {
type: "string",
description: "Custom task ID like 'DEV-1234' (for task scope operations)"
},
taskName: {
type: "string",
description: "Name of the task to search for (for task scope operations)"
},
listName: {
type: "string",
description: "Name of the list containing the task (optional, helps disambiguate)"
},
tagName: {
type: "string",
description: "Name of the tag to manage"
},
newTagName: {
type: "string",
description: "New name for the tag (for update action)"
},
tagBg: {
type: "string",
description: "Background color in HEX format (e.g., #FF0000). Defaults to #000000."
},
tagFg: {
type: "string",
description: "Foreground (text) color in HEX format (e.g., #FFFFFF). Defaults to #FFFFFF."
},
colorCommand: {
type: "string",
description: "Natural language color command (e.g., 'blue tag', 'dark red'). Overrides tagBg/tagFg."
},
detailLevel: {
type: "string",
enum: ["minimal", "standard", "detailed"],
description: "Detail level for response (default: standard)"
}
},
required: ["scope", "action"]
}
};
//=============================================================================
// UNIFIED HANDLER
//=============================================================================
/**
* Main handler for operate_tags tool
* Routes to appropriate function based on scope and action
*/
export async function handleOperateTags(params) {
try {
logger.debug('handleOperateTags called', { scope: params.scope, action: params.action });
const { scope, action } = params;
// Validate scope and action
if (!scope || !action) {
return sponsorService.createErrorResponse(new Error('Both scope and action are required'), params);
}
let result;
// Route to appropriate handler based on scope
if (scope === 'space') {
result = await handleSpaceTagOperation(params);
}
else if (scope === 'task') {
result = await handleTaskTagOperation(params);
}
else {
return sponsorService.createErrorResponse(new Error(`Invalid scope: ${scope}. Must be 'space' or 'task'`), params);
}
// Handle error responses
if (result && !result.success && result.error) {
const error = new Error(result.error.message || 'Tag operation failed');
error.code = result.error.code;
error.data = result.error.details;
return sponsorService.createErrorResponse(error, params);
}
return sponsorService.createResponse(result);
}
catch (error) {
logger.error('Error in handleOperateTags', { error: error.message });
return sponsorService.createErrorResponse(error, params);
}
}
//=============================================================================
// SPACE TAG OPERATIONS
//=============================================================================
/**
* Handle space-scoped tag operations
*/
async function handleSpaceTagOperation(params) {
const { action, spaceId, spaceName, tagName, newTagName, tagBg, tagFg, colorCommand, detailLevel } = params;
// Resolve space ID
const resolvedSpaceId = await resolveSpaceId(spaceId, spaceName);
if (!resolvedSpaceId) {
return {
success: false,
error: { message: 'Either spaceId or spaceName is required for space operations' }
};
}
switch (action) {
case 'list':
return handleListSpaceTags(resolvedSpaceId, detailLevel);
case 'create':
if (!tagName) {
return {
success: false,
error: { message: 'tagName is required for create action' }
};
}
return handleCreateSpaceTag(resolvedSpaceId, tagName, tagBg, tagFg, colorCommand);
case 'update':
if (!tagName) {
return {
success: false,
error: { message: 'tagName is required for update action' }
};
}
return handleUpdateSpaceTag(resolvedSpaceId, tagName, newTagName, tagBg, tagFg, colorCommand);
case 'delete':
if (!tagName) {
return {
success: false,
error: { message: 'tagName is required for delete action' }
};
}
return handleDeleteSpaceTag(resolvedSpaceId, tagName);
default:
return {
success: false,
error: { message: `Invalid action for space scope: ${action}` }
};
}
}
/**
* List all tags in a space with caching
*/
async function handleListSpaceTags(spaceId, detailLevel = 'standard') {
try {
logger.info('Listing tags for space', { spaceId });
// Try to get from cache first
const cached = workspaceCache.getTags(spaceId);
let tags = null;
if (cached) {
logger.debug('Using cached space tags', { spaceId });
tags = cached;
}
else {
// Fetch from API
const response = await clickUpServices.tag.getSpaceTags(spaceId);
if (!response.success) {
return {
success: false,
error: response.error || { message: 'Failed to get space tags' }
};
}
tags = response.data || [];
// Cache the tags (15-min TTL)
if (tags) {
workspaceCache.setTags(spaceId, tags);
}
}
return {
success: true,
data: {
tags: tags || [],
count: (tags || []).length,
spaceId
}
};
}
catch (error) {
logger.error('Error listing space tags', error);
return {
success: false,
error: {
message: error.message || 'Failed to list space tags',
code: error.code,
details: error.data
}
};
}
}
/**
* Create a new tag in a space
*/
async function handleCreateSpaceTag(spaceId, tagName, tagBg, tagFg, colorCommand) {
try {
// Process color command if provided
let finalBg = tagBg || '#000000';
let finalFg = tagFg || '#ffffff';
if (colorCommand) {
const colors = processColorCommand(colorCommand);
if (colors) {
finalBg = colors.background;
finalFg = colors.foreground;
logger.info(`Processed color command: "${colorCommand}"`, { bg: finalBg, fg: finalFg });
}
else {
logger.warn(`Could not process color command: "${colorCommand}"`);
}
}
logger.info('Creating tag in space', { spaceId, tagName, finalBg, finalFg });
const response = await clickUpServices.tag.createSpaceTag(spaceId, {
tag_name: tagName,
tag_bg: finalBg,
tag_fg: finalFg
});
if (!response.success) {
return {
success: false,
error: response.error || { message: 'Failed to create space tag' }
};
}
// Invalidate cache
workspaceCache.invalidateTags(spaceId);
return {
success: true,
data: {
tag: response.data,
message: `Tag "${tagName}" created successfully`
}
};
}
catch (error) {
logger.error('Error creating space tag', 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
*/
async function handleUpdateSpaceTag(spaceId, tagName, newTagName, tagBg, tagFg, colorCommand) {
try {
// Process color command if provided
let finalBg = tagBg;
let finalFg = tagFg;
if (colorCommand) {
const colors = processColorCommand(colorCommand);
if (colors) {
finalBg = colors.background;
finalFg = colors.foreground;
logger.info(`Processed color command: "${colorCommand}"`, { bg: finalBg, fg: finalFg });
}
else {
logger.warn(`Could not process color command: "${colorCommand}"`);
}
}
// Ensure at least one property to update
if (!newTagName && !finalBg && !finalFg) {
return {
success: false,
error: {
message: 'At least one property (newTagName, tagBg, tagFg, or colorCommand) must be provided'
}
};
}
logger.info('Updating tag in space', { spaceId, tagName, newTagName, finalBg, finalFg });
// Build update data
const updateData = {};
if (newTagName)
updateData.tag_name = newTagName;
if (finalBg)
updateData.tag_bg = finalBg;
if (finalFg)
updateData.tag_fg = finalFg;
const response = await clickUpServices.tag.updateSpaceTag(spaceId, tagName, updateData);
if (!response.success) {
return {
success: false,
error: response.error || { message: 'Failed to update space tag' }
};
}
// Invalidate cache
workspaceCache.invalidateTags(spaceId);
return {
success: true,
data: {
tag: response.data,
message: `Tag "${tagName}" updated successfully`
}
};
}
catch (error) {
logger.error('Error updating space tag', 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
*/
async function handleDeleteSpaceTag(spaceId, tagName) {
try {
logger.info('Deleting tag from space', { spaceId, tagName });
const response = await clickUpServices.tag.deleteSpaceTag(spaceId, tagName);
if (!response.success) {
return {
success: false,
error: response.error || { message: 'Failed to delete space tag' }
};
}
// Invalidate cache
workspaceCache.invalidateTags(spaceId);
return {
success: true,
data: {
message: `Tag "${tagName}" deleted successfully`
}
};
}
catch (error) {
logger.error('Error deleting space tag', error);
return {
success: false,
error: {
message: error.message || 'Failed to delete space tag',
code: error.code,
details: error.data
}
};
}
}
//=============================================================================
// TASK TAG OPERATIONS
//=============================================================================
/**
* Handle task-scoped tag operations
*/
async function handleTaskTagOperation(params) {
const { action, taskId, customTaskId, taskName, listName, tagName } = params;
// Validate required parameters
if (!tagName) {
return {
success: false,
error: { message: 'tagName is required for task operations' }
};
}
// Resolve task ID
const taskIdResult = await resolveTaskId({ taskId, customTaskId, taskName, listName });
if (!taskIdResult.success) {
return {
success: false,
error: taskIdResult.error
};
}
const resolvedTaskId = taskIdResult.taskId;
switch (action) {
case 'add':
return handleAddTagToTask(resolvedTaskId, tagName);
case 'remove':
return handleRemoveTagFromTask(resolvedTaskId, tagName);
default:
return {
success: false,
error: { message: `Invalid action for task scope: ${action}` }
};
}
}
/**
* Add a tag to a task
*/
async function handleAddTagToTask(taskId, tagName) {
try {
logger.info('Adding tag to task', { taskId, tagName });
const response = await clickUpServices.tag.addTagToTask(taskId, tagName);
if (!response.success) {
// Provide more specific error messages
if (response.error?.code === 'TAG_NOT_FOUND') {
return {
success: false,
error: {
message: `Tag "${tagName}" does not exist in the space. Create it first using action="create" with scope="space".`,
code: 'TAG_NOT_FOUND'
}
};
}
return {
success: false,
error: response.error || { message: 'Failed to add tag to task' }
};
}
return {
success: true,
data: {
message: `Tag "${tagName}" added to task successfully`,
taskId
}
};
}
catch (error) {
logger.error('Error adding tag to task', 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
*/
async function handleRemoveTagFromTask(taskId, tagName) {
try {
logger.info('Removing tag from task', { taskId, tagName });
const response = await clickUpServices.tag.removeTagFromTask(taskId, tagName);
if (!response.success) {
return {
success: false,
error: response.error || { message: 'Failed to remove tag from task' }
};
}
return {
success: true,
data: {
message: `Tag "${tagName}" removed from task successfully`,
taskId
}
};
}
catch (error) {
logger.error('Error removing tag from task', error);
return {
success: false,
error: {
message: error.message || 'Failed to remove tag from task',
code: error.code,
details: error.data
}
};
}
}
//=============================================================================
// HELPER FUNCTIONS
//=============================================================================
/**
* Resolve space ID from either direct ID or name
*/
async function resolveSpaceId(spaceId, spaceName) {
if (spaceId) {
return spaceId;
}
if (!spaceName) {
return null;
}
try {
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 null;
}
return space.id;
}
catch (error) {
logger.error('Error resolving space ID', error);
return null;
}
}
/**
* Resolve task ID from various identification methods
*/
async function resolveTaskId(params) {
const { taskId, customTaskId, taskName, listName } = params;
try {
// Validate task identification
const validationResult = validateTaskIdentification(params, { useGlobalLookup: true });
if (!validationResult.isValid) {
return {
success: false,
error: { message: validationResult.errorMessage }
};
}
// Try to find the task
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
}
};
}
}
//=============================================================================
// EXPORTS
//=============================================================================
/**
* Tool definition and handler mapping
*/
export const tagToolDefinition = { definition: operateTagsTool, handler: handleOperateTags };
/**
* Array for easy tool registration (for backward compatibility)
*/
export const tagTools = [tagToolDefinition];