UNPKG

@taazkareem/clickup-mcp-server

Version:

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

398 lines (397 loc) 16.3 kB
/** * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com> * SPDX-License-Identifier: MIT * * ClickUp Workspace Service Module * * Handles workspace hierarchy and space-related operations */ import { BaseClickUpService, ClickUpServiceError, ErrorCode } from './base.js'; import { Logger } from '../../logger.js'; // Create a logger instance for workspace service const logger = new Logger('WorkspaceService'); /** * Service for workspace-related operations */ export class WorkspaceService extends BaseClickUpService { /** * Creates an instance of WorkspaceService * @param apiKey - ClickUp API key * @param teamId - ClickUp team ID * @param baseUrl - Optional custom API URL */ constructor(apiKey, teamId, baseUrl) { super(apiKey, teamId, baseUrl); // Store the workspace hierarchy in memory this.workspaceHierarchy = null; } /** * Helper method to handle errors consistently * @param error - Error caught from a try/catch * @param message - Optional message to add to the error * @returns - A standardized ClickUpServiceError */ handleError(error, message) { logger.error('WorkspaceService error:', error); // If the error is already a ClickUpServiceError, return it if (error instanceof ClickUpServiceError) { return error; } // Otherwise, create a new ClickUpServiceError const errorMessage = message || 'An error occurred in WorkspaceService'; return new ClickUpServiceError(errorMessage, ErrorCode.WORKSPACE_ERROR, error); } /** * Get all spaces for the team * @returns - Promise resolving to array of spaces */ async getSpaces() { try { const response = await this.makeRequest(async () => { const result = await this.client.get(`/team/${this.teamId}/space`); return result.data; }); return response.spaces || []; } catch (error) { throw this.handleError(error, 'Failed to get spaces'); } } /** * Get a specific space by ID * @param spaceId - The ID of the space to retrieve * @returns - Promise resolving to the space object */ async getSpace(spaceId) { try { // Validate spaceId if (!spaceId) { throw new ClickUpServiceError('Space ID is required', ErrorCode.INVALID_PARAMETER); } return await this.makeRequest(async () => { const result = await this.client.get(`/space/${spaceId}`); return result.data; }); } catch (error) { throw this.handleError(error, `Failed to get space with ID ${spaceId}`); } } /** * Find a space by name * @param spaceName - The name of the space to find * @returns - Promise resolving to the space or null if not found */ async findSpaceByName(spaceName) { try { // Validate spaceName if (!spaceName) { throw new ClickUpServiceError('Space name is required', ErrorCode.INVALID_PARAMETER); } // Get all spaces and find the one with the matching name const spaces = await this.getSpaces(); const space = spaces.find(s => s.name === spaceName); return space || null; } catch (error) { throw this.handleError(error, `Failed to find space with name ${spaceName}`); } } /** * Get the complete workspace hierarchy including spaces, folders, and lists * @param forceRefresh - Whether to force a refresh of the hierarchy * @returns - Promise resolving to the workspace tree */ async getWorkspaceHierarchy(forceRefresh = false) { try { // If we have the hierarchy in memory and not forcing refresh, return it if (this.workspaceHierarchy && !forceRefresh) { logger.debug('Returning cached workspace hierarchy'); return this.workspaceHierarchy; } const startTime = Date.now(); logger.info('Starting workspace hierarchy fetch'); // Start building the workspace tree const workspaceTree = { root: { id: this.teamId, name: 'Workspace', children: [] } }; // Get all spaces const spacesStartTime = Date.now(); const spaces = await this.getSpaces(); const spacesTime = Date.now() - spacesStartTime; logger.info(`Fetched ${spaces.length} spaces in ${spacesTime}ms`); // Process spaces in batches to respect rate limits const batchSize = 3; // Process 3 spaces at a time const spaceNodes = []; let totalFolders = 0; let totalLists = 0; for (let i = 0; i < spaces.length; i += batchSize) { const batchStartTime = Date.now(); const spaceBatch = spaces.slice(i, i + batchSize); logger.debug(`Processing space batch ${i / batchSize + 1} of ${Math.ceil(spaces.length / batchSize)} (${spaceBatch.length} spaces)`); const batchNodes = await Promise.all(spaceBatch.map(async (space) => { const spaceStartTime = Date.now(); const spaceNode = { id: space.id, name: space.name, type: 'space', children: [] }; // Fetch initial space data const [folders, listsInSpace] = await Promise.all([ this.getFoldersInSpace(space.id), this.getListsInSpace(space.id) ]); totalFolders += folders.length; totalLists += listsInSpace.length; // Process folders in smaller batches const folderBatchSize = 5; // Process 5 folders at a time const folderNodes = []; for (let j = 0; j < folders.length; j += folderBatchSize) { const folderBatchStartTime = Date.now(); const folderBatch = folders.slice(j, j + folderBatchSize); const batchFolderNodes = await Promise.all(folderBatch.map(async (folder) => { const folderNode = { id: folder.id, name: folder.name, type: 'folder', parentId: space.id, children: [] }; // Get lists in the folder const listsInFolder = await this.getListsInFolder(folder.id); totalLists += listsInFolder.length; folderNode.children = listsInFolder.map(list => ({ id: list.id, name: list.name, type: 'list', parentId: folder.id })); return folderNode; })); folderNodes.push(...batchFolderNodes); const folderBatchTime = Date.now() - folderBatchStartTime; logger.debug(`Processed folder batch in space ${space.name} in ${folderBatchTime}ms (${folderBatch.length} folders)`); } // Add folder nodes to space spaceNode.children?.push(...folderNodes); // Add folderless lists to space logger.debug(`Adding ${listsInSpace.length} lists directly to space ${space.name}`); const listNodes = listsInSpace.map(list => ({ id: list.id, name: list.name, type: 'list', parentId: space.id })); spaceNode.children?.push(...listNodes); const spaceTime = Date.now() - spaceStartTime; logger.info(`Processed space ${space.name} in ${spaceTime}ms (${folders.length} folders, ${listsInSpace.length} lists)`); return spaceNode; })); spaceNodes.push(...batchNodes); const batchTime = Date.now() - batchStartTime; logger.info(`Processed space batch in ${batchTime}ms (${spaceBatch.length} spaces)`); } // Add all space nodes to the workspace tree workspaceTree.root.children.push(...spaceNodes); const totalTime = Date.now() - startTime; logger.info('Workspace hierarchy fetch completed', { duration: totalTime, spaces: spaces.length, folders: totalFolders, lists: totalLists, averageTimePerSpace: totalTime / spaces.length, averageTimePerNode: totalTime / (spaces.length + totalFolders + totalLists) }); // Store the hierarchy for later use this.workspaceHierarchy = workspaceTree; return workspaceTree; } catch (error) { throw this.handleError(error, 'Failed to get workspace hierarchy'); } } /** * Clear the stored workspace hierarchy, forcing a fresh fetch on next request */ clearWorkspaceHierarchy() { this.workspaceHierarchy = null; } /** * Find a node in the workspace tree by name and type * @param node - The node to start searching from * @param name - The name to search for * @param type - The type of node to search for * @returns - The node and its path if found, null otherwise */ findNodeInTree(node, name, type) { // If this is the node we're looking for, return it if ('type' in node && node.type === type && node.name === name) { return { node, path: node.name }; } // Otherwise, search its children recursively for (const child of (node.children || [])) { const result = this.findNodeInTree(child, name, type); if (result) { // Prepend this node's name to the path const currentNodeName = 'name' in node ? node.name : 'Workspace'; result.path = `${currentNodeName} > ${result.path}`; return result; } } // Not found in this subtree return null; } /** * Find an ID by name and type in the workspace hierarchy * @param hierarchy - The workspace hierarchy * @param name - The name to search for * @param type - The type of node to search for * @returns - The ID and path if found, null otherwise */ findIDByNameInHierarchy(hierarchy, name, type) { const result = this.findNodeInTree(hierarchy.root, name, type); if (result) { return { id: result.node.id, path: result.path }; } return null; } /** * Find a space ID by name * @param spaceName - The name of the space to find * @returns - Promise resolving to the space ID or null if not found */ async findSpaceIDByName(spaceName) { const space = await this.findSpaceByName(spaceName); return space ? space.id : null; } /** * Get folderless lists from the API (lists that are directly in a space) * @param spaceId - The ID of the space * @returns - Promise resolving to array of lists */ async getFolderlessLists(spaceId) { try { const response = await this.makeRequest(async () => { const result = await this.client.get(`/space/${spaceId}/list`); return result.data; }); return response.lists || []; } catch (error) { throw this.handleError(error, `Failed to get folderless lists for space ${spaceId}`); } } /** * Get lists in a space (not in any folder) * @param spaceId - The ID of the space * @returns - Promise resolving to array of lists */ async getListsInSpace(spaceId) { try { // The /space/{space_id}/list endpoint already returns folderless lists only const lists = await this.getFolderlessLists(spaceId); logger.debug(`Found ${lists.length} folderless lists in space ${spaceId}`); // Return all lists without filtering since the API already returns folderless lists return lists; } catch (error) { throw this.handleError(error, `Failed to get lists in space ${spaceId}`); } } /** * Get folders from the API * @param spaceId - The ID of the space * @returns - Promise resolving to array of folders */ async getFolders(spaceId) { try { const response = await this.makeRequest(async () => { const result = await this.client.get(`/space/${spaceId}/folder`); return result.data; }); return response.folders || []; } catch (error) { throw this.handleError(error, `Failed to get folders for space ${spaceId}`); } } /** * Get a specific folder by ID * @param folderId - The ID of the folder to retrieve * @returns - Promise resolving to the folder */ async getFolder(folderId) { try { return await this.makeRequest(async () => { const result = await this.client.get(`/folder/${folderId}`); return result.data; }); } catch (error) { throw this.handleError(error, `Failed to get folder with ID ${folderId}`); } } /** * Get folders in a space * @param spaceId - The ID of the space * @returns - Promise resolving to array of folders */ async getFoldersInSpace(spaceId) { try { return await this.getFolders(spaceId); } catch (error) { throw this.handleError(error, `Failed to get folders in space ${spaceId}`); } } /** * Get lists in a folder * @param folderId - The ID of the folder * @returns - Promise resolving to array of lists */ async getListsInFolder(folderId) { try { const response = await this.makeRequest(async () => { const result = await this.client.get(`/folder/${folderId}/list`); return result.data; }); return response.lists || []; } catch (error) { throw this.handleError(error, `Failed to get lists in folder ${folderId}`); } } /** * Get all members in a workspace * @returns Array of workspace members */ async getWorkspaceMembers() { try { // Use the existing team/workspace endpoint which typically returns member information const teamId = this.teamId; const response = await this.client.get(`/team/${teamId}`); if (!response || !response.data || !response.data.team) { throw new Error('Invalid response from ClickUp API'); } // Extract and normalize member data const members = response.data.team.members || []; return members.map((member) => ({ id: member.user?.id, name: member.user?.username || member.user?.email, username: member.user?.username, email: member.user?.email, role: member.role, profilePicture: member.user?.profilePicture })); } catch (error) { console.error('Error getting workspace members:', error); throw error; } } }