UNPKG

@twofeetup/clickup-mcp

Version:

Optimized ClickUp MCP Server - High-performance AI integration with consolidated tools and response optimization

246 lines (245 loc) 8.87 kB
/** * SPDX-FileCopyrightText: (c) 2025 Sjoerd Tiemensma * SPDX-License-Identifier: MIT * * Task Type Service * * This service manages ClickUp custom task types (also known as custom items). * It fetches available task types at startup and provides mapping between * friendly names (e.g., "milestone", "Bug/Issue") and numeric IDs used by the API. * * Key Features: * - Fetches task types once at startup * - Provides name-to-ID mapping with case-insensitive matching * - Normalizes names for flexible matching (Bug/Issue -> bug_issue) * - Returns available types for dynamic tool schema generation * - Caches results to avoid repeated API calls */ import axios from 'axios'; import { Logger } from '../logger.js'; /** * Task Type Service Class * Singleton service for managing custom task type mappings */ class TaskTypeService { constructor() { this.taskTypes = []; this.nameToIdMap = new Map(); this.idToNameMap = new Map(); this.initialized = false; this.logger = new Logger('TaskTypeService'); } /** * Initialize the service by fetching task types from ClickUp API * @param teamId - The ClickUp team/workspace ID * @param apiKey - The ClickUp API key */ async initialize(teamId, apiKey) { if (this.initialized) { this.logger.info('Task type service already initialized, skipping'); return; } this.logger.info('Initializing task type service...', { teamId }); // Create axios instance for API calls this.api = axios.create({ baseURL: 'https://api.clickup.com/api/v2', headers: { 'Authorization': apiKey, 'Content-Type': 'application/json' } }); try { // Fetch custom task types from ClickUp API const response = await this.api.get(`/team/${teamId}/custom_item`); this.taskTypes = response.data.custom_items || []; // Build name-to-ID and ID-to-name mappings this.buildMappings(); this.initialized = true; this.logger.info(`Successfully loaded ${this.taskTypes.length} task types`, { types: this.taskTypes.map(t => t.name) }); } catch (error) { // If custom task types endpoint fails, continue with empty mappings // This allows the server to work even if custom types aren't available this.logger.warn('Failed to load custom task types, continuing with defaults', { error: error.message, status: error.response?.status }); this.initialized = true; // Mark as initialized even if it failed } } /** * Build name-to-ID and ID-to-name mappings * Creates multiple entries for flexible matching: * - Original name (e.g., "Bug/Issue") * - Lowercase name (e.g., "bug/issue") * - Normalized name (e.g., "bug_issue", "bugissue") */ buildMappings() { this.nameToIdMap.clear(); this.idToNameMap.clear(); for (const type of this.taskTypes) { // Store ID to original name mapping this.idToNameMap.set(type.id, type.name); // Store original name this.nameToIdMap.set(type.name, type.id); // Store lowercase version const lowerName = type.name.toLowerCase(); this.nameToIdMap.set(lowerName, type.id); // Store normalized version (replace non-alphanumeric with underscore) const normalizedName = type.name.toLowerCase().replace(/[^a-z0-9]/g, '_'); this.nameToIdMap.set(normalizedName, type.id); // Store version without special chars at all const compactName = type.name.toLowerCase().replace(/[^a-z0-9]/g, ''); this.nameToIdMap.set(compactName, type.id); } this.logger.debug('Built task type mappings', { totalTypes: this.taskTypes.length, totalMappings: this.nameToIdMap.size }); } /** * Get the numeric ID for a given task type name * @param name - The task type name (case-insensitive) * @returns The numeric ID, or undefined if not found * * @example * getIdFromName("milestone") // returns 1 * getIdFromName("Bug/Issue") // returns 1002 * getIdFromName("bug_issue") // returns 1002 (normalized) * getIdFromName("bugissue") // returns 1002 (compact) */ getIdFromName(name) { if (!name) return undefined; // Try exact match first const id = this.nameToIdMap.get(name); if (id !== undefined) return id; // Try lowercase match const lowerId = this.nameToIdMap.get(name.toLowerCase()); if (lowerId !== undefined) return lowerId; // Try normalized match const normalized = name.toLowerCase().replace(/[^a-z0-9]/g, '_'); const normalizedId = this.nameToIdMap.get(normalized); if (normalizedId !== undefined) return normalizedId; // Try compact match const compact = name.toLowerCase().replace(/[^a-z0-9]/g, ''); return this.nameToIdMap.get(compact); } /** * Get the original name for a given task type ID * @param id - The numeric task type ID * @returns The original name, or undefined if not found * * @example * getNameFromId(1) // returns "milestone" * getNameFromId(1002) // returns "Bug/Issue" */ getNameFromId(id) { return this.idToNameMap.get(id); } /** * Get all available task type names * @returns Array of task type names * * @example * getAvailableTypes() // ["milestone", "form_response", "Bug/Issue", ...] */ getAvailableTypes() { return this.taskTypes.map(t => t.name); } /** * Get all task types with full details * @returns Array of ClickUpCustomItem objects */ getAllTaskTypes() { return [...this.taskTypes]; // Return copy to prevent mutations } /** * Get a task type by ID with full details * @param id - The numeric task type ID * @returns The ClickUpCustomItem object, or undefined if not found */ getTaskTypeById(id) { return this.taskTypes.find(t => t.id === id); } /** * Get a task type by name with full details * @param name - The task type name (case-insensitive) * @returns The ClickUpCustomItem object, or undefined if not found */ getTaskTypeByName(name) { const id = this.getIdFromName(name); if (id === undefined) return undefined; return this.getTaskTypeById(id); } /** * Check if the service has been initialized * @returns true if initialized, false otherwise */ isInitialized() { return this.initialized; } /** * Get count of available task types * @returns Number of task types */ getTypeCount() { return this.taskTypes.length; } /** * Find the closest matching task type name (for error suggestions) * @param input - The input string to match * @returns The closest matching task type name, or undefined */ findClosestMatch(input) { if (!input || this.taskTypes.length === 0) return undefined; const lowerInput = input.toLowerCase(); const matches = []; for (const type of this.taskTypes) { const lowerName = type.name.toLowerCase(); // Exact match if (lowerName === lowerInput) { return type.name; } // Calculate similarity score let score = 0; // Starts with if (lowerName.startsWith(lowerInput)) score += 5; // Contains if (lowerName.includes(lowerInput)) score += 3; // Levenshtein-like simple similarity const commonChars = lowerInput.split('').filter(c => lowerName.includes(c)).length; score += commonChars; if (score > 0) { matches.push({ name: type.name, score }); } } // Return best match if any if (matches.length > 0) { matches.sort((a, b) => b.score - a.score); return matches[0].name; } return undefined; } /** * Reset the service (useful for testing) */ reset() { this.taskTypes = []; this.nameToIdMap.clear(); this.idToNameMap.clear(); this.initialized = false; this.logger.info('Task type service reset'); } } // Export singleton instance export const taskTypeService = new TaskTypeService();