ClickUp MCP Server

by windalfin
Verified
/** * Base ClickUp Service Class * * This class provides core functionality for all ClickUp service modules: * - Axios client configuration * - Rate limiting and request throttling * - Error handling * - Common request methods */ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; /** * Basic service response interface */ export interface ServiceResponse<T> { success: boolean; data?: T; error?: { message: string; code?: string; details?: any; } } /** * Error types for better error handling */ export enum ErrorCode { RATE_LIMIT = 'rate_limit_exceeded', NOT_FOUND = 'resource_not_found', UNAUTHORIZED = 'unauthorized', VALIDATION = 'validation_error', SERVER_ERROR = 'server_error', NETWORK_ERROR = 'network_error', WORKSPACE_ERROR = 'workspace_error', INVALID_PARAMETER = 'invalid_parameter', UNKNOWN = 'unknown_error' } /** * Custom error class for ClickUp API errors */ export class ClickUpServiceError extends Error { readonly code: ErrorCode; readonly data?: any; readonly status?: number; context?: Record<string, any>; constructor( message: string, code: ErrorCode = ErrorCode.UNKNOWN, data?: any, status?: number, context?: Record<string, any> ) { super(message); this.name = 'ClickUpServiceError'; this.code = code; this.data = data; this.status = status; this.context = context; } } /** * Rate limit response headers from ClickUp API */ interface RateLimitHeaders { 'x-ratelimit-limit': number; 'x-ratelimit-remaining': number; 'x-ratelimit-reset': number; } /** * Base ClickUp service class that handles common functionality */ export class BaseClickUpService { protected readonly apiKey: string; protected readonly teamId: string; protected readonly client: AxiosInstance; protected readonly defaultRequestSpacing = 600; // Default milliseconds between requests protected readonly rateLimit = 100; // Maximum requests per minute (Free Forever plan) protected requestSpacing: number; // Current request spacing, can be adjusted protected readonly timeout = 65000; // 65 seconds (safely under the 1-minute window) protected requestQueue: (() => Promise<any>)[] = []; protected processingQueue = false; protected lastRateLimitReset: number = 0; /** * Creates an instance of BaseClickUpService. * @param apiKey - ClickUp API key for authentication * @param teamId - ClickUp team ID for targeting the correct workspace * @param baseUrl - Optional custom base URL for the ClickUp API */ constructor(apiKey: string, teamId: string, baseUrl: string = 'https://api.clickup.com/api/v2') { this.apiKey = apiKey; this.teamId = teamId; this.requestSpacing = this.defaultRequestSpacing; // Configure the Axios client with default settings this.client = axios.create({ baseURL: baseUrl, headers: { 'Authorization': apiKey, 'Content-Type': 'application/json' }, timeout: this.timeout }); // Add response interceptor for error handling this.client.interceptors.response.use( response => response, error => this.handleAxiosError(error) ); } /** * Handle errors from Axios requests * @private * @param error Error from Axios * @returns Never - always throws an error */ private handleAxiosError(error: any): never { let message = 'Unknown error occurred'; let code = ErrorCode.UNKNOWN; let details: any = null; let status: number | undefined = undefined; if (error.response) { // Server responded with an error status code status = error.response.status; details = error.response.data; switch (status) { case 401: message = 'Unauthorized: Invalid API key'; code = ErrorCode.UNAUTHORIZED; break; case 403: message = 'Forbidden: Insufficient permissions'; code = ErrorCode.UNAUTHORIZED; break; case 404: message = 'Resource not found'; code = ErrorCode.NOT_FOUND; break; case 429: message = 'Rate limit exceeded'; code = ErrorCode.RATE_LIMIT; break; case 400: message = 'Invalid request: ' + (error.response.data?.err || 'Validation error'); code = ErrorCode.VALIDATION; break; case 500: case 502: case 503: case 504: message = 'ClickUp server error'; code = ErrorCode.SERVER_ERROR; break; default: message = `ClickUp API error (${status}): ${error.response.data?.err || 'Unknown error'}`; } } else if (error.request) { // Request was made but no response received message = 'Network error: No response received from ClickUp'; code = ErrorCode.NETWORK_ERROR; details = { request: error.request }; } else { // Error setting up the request message = `Request setup error: ${error.message}`; details = { message: error.message }; } throw new ClickUpServiceError(message, code, details, status); } /** * Process the request queue, respecting rate limits by spacing out requests * @private */ private async processQueue(): Promise<void> { if (this.processingQueue || this.requestQueue.length === 0) { return; } this.processingQueue = true; try { while (this.requestQueue.length > 0) { const request = this.requestQueue.shift(); if (request) { try { await request(); } catch (error) { console.error('Request failed:', error); // Continue processing queue even if one request fails } // Space out requests to stay within rate limit if (this.requestQueue.length > 0) { await new Promise(resolve => setTimeout(resolve, this.requestSpacing)); } } } } finally { this.processingQueue = false; } } /** * Handle rate limit headers from ClickUp API * @private * @param headers Response headers from ClickUp */ private handleRateLimitHeaders(headers: any): void { const limit = parseInt(headers['x-ratelimit-limit'], 10); const remaining = parseInt(headers['x-ratelimit-remaining'], 10); const reset = parseInt(headers['x-ratelimit-reset'], 10); if (!isNaN(reset)) { this.lastRateLimitReset = reset; } // If we're running low on remaining requests, increase spacing if (!isNaN(remaining) && remaining < 10) { const timeUntilReset = (this.lastRateLimitReset * 1000) - Date.now(); if (timeUntilReset > 0) { this.requestSpacing = Math.max(this.defaultRequestSpacing, Math.floor(timeUntilReset / remaining)); } } else { this.requestSpacing = this.defaultRequestSpacing; // Reset to default spacing } } /** * Makes an API request with rate limiting. * @protected * @param fn - Function that executes the API request * @returns Promise that resolves with the result of the API request */ protected async makeRequest<T>(fn: () => Promise<T>): Promise<T> { return new Promise<T>((resolve, reject) => { this.requestQueue.push(async () => { try { const result = await fn(); // Handle rate limit headers if present if (result && typeof result === 'object' && 'headers' in result) { this.handleRateLimitHeaders(result.headers); } resolve(result); } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 429) { const retryAfter = parseInt(error.response.headers['retry-after'] || '60', 10); const resetTime = parseInt(error.response.headers['x-ratelimit-reset'] || '0', 10); // Use the more precise reset time if available const waitTime = resetTime > 0 ? (resetTime * 1000) - Date.now() : retryAfter * 1000; await new Promise(resolve => setTimeout(resolve, waitTime)); try { // Retry the request once after waiting const result = await fn(); resolve(result); } catch (retryError) { reject(retryError); } } else { reject(error); } } }); this.processQueue().catch(reject); }); } /** * Gets the ClickUp team ID associated with this service instance * @returns The team ID */ getTeamId(): string { return this.teamId; } /** * Helper method to log API operations * @protected * @param operation - Name of the operation being performed * @param details - Details about the operation */ protected logOperation(operation: string, details: any): void { console.log(`[${new Date().toISOString()}] ${operation}:`, details); } }