@taazkareem/clickup-mcp-server
Version:
ClickUp MCP Server - Integrate ClickUp tasks with AI through Model Context Protocol
433 lines (432 loc) • 18.4 kB
JavaScript
/**
* SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
* SPDX-License-Identifier: MIT
*
* 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 from 'axios';
import { Logger, LogLevel } from '../../logger.js';
/**
* Error types for better error handling
*/
export var ErrorCode;
(function (ErrorCode) {
ErrorCode["RATE_LIMIT"] = "rate_limit_exceeded";
ErrorCode["NOT_FOUND"] = "resource_not_found";
ErrorCode["UNAUTHORIZED"] = "unauthorized";
ErrorCode["VALIDATION"] = "validation_error";
ErrorCode["SERVER_ERROR"] = "server_error";
ErrorCode["NETWORK_ERROR"] = "network_error";
ErrorCode["WORKSPACE_ERROR"] = "workspace_error";
ErrorCode["INVALID_PARAMETER"] = "invalid_parameter";
ErrorCode["UNKNOWN"] = "unknown_error";
})(ErrorCode || (ErrorCode = {}));
/**
* Custom error class for ClickUp API errors
*/
export class ClickUpServiceError extends Error {
constructor(message, code = ErrorCode.UNKNOWN, data, status, context) {
super(message);
this.name = 'ClickUpServiceError';
this.code = code;
this.data = data;
this.status = status;
this.context = context;
}
}
/**
* Helper function to safely parse JSON
* @param data Data to parse
* @param fallback Optional fallback value if parsing fails
* @returns Parsed JSON or fallback value
*/
function safeJsonParse(data, fallback = undefined) {
if (typeof data !== 'string') {
return data;
}
try {
return JSON.parse(data);
}
catch (error) {
return fallback;
}
}
/**
* Base ClickUp service class that handles common functionality
*/
export class BaseClickUpService {
/**
* 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, teamId, baseUrl = 'https://api.clickup.com/api/v2') {
this.defaultRequestSpacing = 600; // Default milliseconds between requests
this.rateLimit = 100; // Maximum requests per minute (Free Forever plan)
this.timeout = 65000; // 65 seconds (safely under the 1-minute window)
this.requestQueue = [];
this.processingQueue = false;
this.lastRateLimitReset = 0;
this.apiKey = apiKey;
this.teamId = teamId;
this.requestSpacing = this.defaultRequestSpacing;
// Create a logger with the actual class name for better context
const className = this.constructor.name;
this.logger = new Logger(`ClickUp:${className}`);
// Configure the Axios client with default settings
this.client = axios.create({
baseURL: baseUrl,
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json'
},
timeout: this.timeout,
transformResponse: [
// Add custom response transformer to handle both JSON and text responses
(data) => {
if (!data)
return data;
// If it's already an object, return as is
if (typeof data !== 'string')
return data;
// Try to parse as JSON, fall back to raw text if parsing fails
const parsed = safeJsonParse(data, null);
return parsed !== null ? parsed : data;
}
]
});
this.logger.debug(`Initialized ${className}`, { teamId, baseUrl });
// 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
*/
handleAxiosError(error) {
// Determine error details
const status = error.response?.status;
const responseData = error.response?.data;
const errorMsg = responseData?.err || responseData?.error || error.message || 'Unknown API error';
const path = error.config?.url || 'unknown path';
// Context object for providing more detailed log information
const errorContext = {
path,
status,
method: error.config?.method?.toUpperCase() || 'UNKNOWN',
requestData: error.config?.data ? safeJsonParse(error.config.data, error.config.data) : undefined
};
// Pick the appropriate error code based on status
let code;
let logMessage;
let errorMessage;
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
code = ErrorCode.NETWORK_ERROR;
logMessage = `Request timeout for ${path}`;
errorMessage = 'Request timed out. Please try again.';
}
else if (!error.response) {
code = ErrorCode.NETWORK_ERROR;
logMessage = `Network error accessing ${path}: ${error.message}`;
errorMessage = 'Network error. Please check your connection and try again.';
}
else if (status === 429) {
code = ErrorCode.RATE_LIMIT;
this.handleRateLimitHeaders(error.response.headers);
// Calculate time until reset
const reset = error.response.headers['x-ratelimit-reset'];
const now = Date.now() / 1000; // Convert to seconds
const timeToReset = Math.max(0, reset - now);
const resetMinutes = Math.ceil(timeToReset / 60);
logMessage = `Rate limit exceeded for ${path}`;
errorMessage = `Rate limit exceeded. Please wait ${resetMinutes} minute${resetMinutes === 1 ? '' : 's'} before trying again.`;
// Add more context to the error
errorContext.rateLimitInfo = {
limit: error.response.headers['x-ratelimit-limit'],
remaining: error.response.headers['x-ratelimit-remaining'],
reset: reset,
timeToReset: timeToReset
};
}
else if (status === 401 || status === 403) {
code = ErrorCode.UNAUTHORIZED;
logMessage = `Authorization failed for ${path}`;
errorMessage = 'Authorization failed. Please check your API key.';
}
else if (status === 404) {
code = ErrorCode.NOT_FOUND;
logMessage = `Resource not found: ${path}`;
errorMessage = 'Resource not found.';
}
else if (status >= 400 && status < 500) {
code = ErrorCode.VALIDATION;
logMessage = `Validation error for ${path}: ${errorMsg}`;
errorMessage = errorMsg;
}
else if (status >= 500) {
code = ErrorCode.SERVER_ERROR;
logMessage = `ClickUp server error: ${errorMsg}`;
errorMessage = 'ClickUp server error. Please try again later.';
}
else {
code = ErrorCode.UNKNOWN;
logMessage = `Unknown API error: ${errorMsg}`;
errorMessage = 'An unexpected error occurred. Please try again.';
}
// Log the error with context
this.logger.error(logMessage, errorContext);
// Throw a formatted error with user-friendly message
throw new ClickUpServiceError(errorMessage, code, error);
}
/**
* Handle rate limit headers from ClickUp API
* @private
* @param headers Response headers from ClickUp
*/
handleRateLimitHeaders(headers) {
try {
// Parse the rate limit headers
const limit = headers['x-ratelimit-limit'];
const remaining = headers['x-ratelimit-remaining'];
const reset = headers['x-ratelimit-reset'];
// Only log if we're getting close to the limit
if (remaining < limit * 0.2) {
this.logger.warn('Approaching rate limit', { remaining, limit, reset });
}
else {
this.logger.debug('Rate limit status', { remaining, limit, reset });
}
if (reset) {
this.lastRateLimitReset = reset;
// If reset is in the future, calculate a safe request spacing
const now = Date.now();
const resetTime = reset * 1000; // convert to milliseconds
const timeToReset = Math.max(0, resetTime - now);
// Proactively adjust spacing when remaining requests get low
// This helps avoid hitting rate limits in the first place
if (remaining < limit * 0.3) {
// More aggressive spacing when close to limit
let safeSpacing;
if (remaining <= 5) {
// Very aggressive spacing for last few requests
safeSpacing = Math.ceil((timeToReset / remaining) * 2);
// Start processing in queue mode preemptively
if (!this.processingQueue) {
this.logger.info('Preemptively switching to queue mode (low remaining requests)', {
remaining,
limit
});
this.processingQueue = true;
this.processQueue().catch(err => {
this.logger.error('Error processing request queue', err);
});
}
}
else if (remaining <= 20) {
// More aggressive spacing
safeSpacing = Math.ceil((timeToReset / remaining) * 1.5);
}
else {
// Standard safe spacing with buffer
safeSpacing = Math.ceil((timeToReset / remaining) * 1.1);
}
// Apply updated spacing, but with a reasonable maximum
const maxSpacing = 5000; // 5 seconds max spacing
const adjustedSpacing = Math.min(safeSpacing, maxSpacing);
// Only adjust if it's greater than our current spacing
if (adjustedSpacing > this.requestSpacing) {
this.logger.debug(`Adjusting request spacing: ${this.requestSpacing}ms → ${adjustedSpacing}ms`, {
remaining,
timeToReset
});
this.requestSpacing = adjustedSpacing;
}
}
}
}
catch (error) {
this.logger.warn('Failed to parse rate limit headers', error);
}
}
/**
* Process the request queue, respecting rate limits by spacing out requests
* @private
*/
async processQueue() {
if (this.requestQueue.length === 0) {
this.logger.debug('Queue empty, exiting queue processing mode');
this.processingQueue = false;
return;
}
const queueLength = this.requestQueue.length;
this.logger.debug(`Processing request queue (${queueLength} items)`);
const startTime = Date.now();
try {
// Take the first request from the queue
const request = this.requestQueue.shift();
if (request) {
// Adjust delay based on queue size
// Longer delays for bigger queues to prevent overwhelming the API
let delay = this.requestSpacing;
if (queueLength > 20) {
delay = this.requestSpacing * 2;
}
else if (queueLength > 10) {
delay = this.requestSpacing * 1.5;
}
// Wait for the calculated delay
await new Promise(resolve => setTimeout(resolve, delay));
// Run the request
await request();
}
}
catch (error) {
if (error instanceof ClickUpServiceError && error.code === ErrorCode.RATE_LIMIT) {
// If we still hit rate limits, increase the spacing
this.requestSpacing = Math.min(this.requestSpacing * 1.5, 10000); // Max 10s
this.logger.warn(`Rate limit hit during queue processing, increasing delay to ${this.requestSpacing}ms`);
}
else {
this.logger.error('Error executing queued request', error);
}
}
finally {
const duration = Date.now() - startTime;
this.logger.trace(`Queue item processed in ${duration}ms, ${this.requestQueue.length} items remaining`);
// Continue processing the queue after the calculated delay
setTimeout(() => this.processQueue(), this.requestSpacing);
}
}
/**
* 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
*/
async makeRequest(fn) {
// If we're being rate limited, queue the request rather than executing immediately
if (this.processingQueue) {
const queuePosition = this.requestQueue.length + 1;
const estimatedWaitTime = Math.ceil((queuePosition * this.requestSpacing) / 1000);
this.logger.info('Request queued due to rate limiting', {
queuePosition,
estimatedWaitSeconds: estimatedWaitTime,
currentSpacing: this.requestSpacing
});
return new Promise((resolve, reject) => {
this.requestQueue.push(async () => {
try {
const result = await fn();
resolve(result);
}
catch (error) {
// Enhance error message with queue context if it's a rate limit error
if (error instanceof ClickUpServiceError && error.code === ErrorCode.RATE_LIMIT) {
const enhancedError = new ClickUpServiceError(`${error.message} (Request was queued at position ${queuePosition})`, error.code, error.data);
reject(enhancedError);
}
else {
reject(error);
}
}
});
});
}
// Track request metadata
let requestMethod = 'unknown';
let requestPath = 'unknown';
let requestData = undefined;
// Set up interceptor to capture request details
const requestInterceptorId = this.client.interceptors.request.use((config) => {
// Capture request metadata
requestMethod = config.method?.toUpperCase() || 'unknown';
requestPath = config.url || 'unknown';
requestData = config.data;
return config;
});
const startTime = Date.now();
try {
// Execute the request function
const result = await fn();
// Debug log for successful requests with timing information
const duration = Date.now() - startTime;
this.logger.debug(`Request completed successfully in ${duration}ms`, {
method: requestMethod,
path: requestPath,
duration,
responseType: result ? typeof result : 'undefined'
});
return result;
}
catch (error) {
// If we hit a rate limit, start processing the queue
if (error instanceof ClickUpServiceError && error.code === ErrorCode.RATE_LIMIT) {
this.logger.warn('Rate limit reached, switching to queue mode', {
reset: this.lastRateLimitReset,
queueLength: this.requestQueue.length
});
if (!this.processingQueue) {
this.processingQueue = true;
this.processQueue().catch(err => {
this.logger.error('Error processing request queue', err);
});
}
// Queue this failed request and return a promise that will resolve when it's retried
return new Promise((resolve, reject) => {
this.requestQueue.push(async () => {
try {
const result = await fn();
resolve(result);
}
catch (retryError) {
reject(retryError);
}
});
});
}
// For other errors, just throw
throw error;
}
finally {
// Always remove the interceptor
this.client.interceptors.request.eject(requestInterceptorId);
}
}
/**
* Gets the ClickUp team ID associated with this service instance
* @returns The team ID
*/
getTeamId() {
return this.teamId;
}
/**
* Helper method to log API operations
* @protected
* @param operation - Name of the operation being performed
* @param details - Details about the operation
*/
logOperation(operation, details) {
this.logger.info(`Operation: ${operation}`, details);
}
/**
* Log detailed information about a request (path and payload)
* For trace level logging only
*/
traceRequest(method, url, data) {
if (this.logger.isLevelEnabled(LogLevel.TRACE)) {
this.logger.trace(`${method} ${url}`, {
payload: data,
teamId: this.teamId
});
}
}
}