Skip to main content
Glama
clickup.ts18.1 kB
/** * Developed by eBrook Group. * Copyright © 2026 eBrook Group (https://www.ebrook.com.tw) */ /** * ClickUp API service */ import type { ClickUpApiResponse, ClickUpTaskResponse, TaskDetailsSummary, TaskWorkflowAnalysis, } from "../types/index.js"; import { debug, error } from "../utils/logger.js"; import { RateLimiter } from "../utils/rate-limiter.js"; import type { AppConfig } from "../config/env.js"; /** * ClickUp API service class */ export class ClickUpService { private readonly config: AppConfig; private readonly rateLimiter: RateLimiter; constructor(config: AppConfig) { this.config = config; // ClickUp API limit: 100 requests per minute this.rateLimiter = new RateLimiter(100, 60000); } /** * Get and validate ClickUp token * @returns Validated token or empty string */ private getToken(): string { const token = this.config.clickupToken.trim(); if (!token || token.length === 0) { error("CLICKUP_TOKEN not configured."); debug( `process.env.CLICKUP_TOKEN=${process.env.CLICKUP_TOKEN ? `SET (length: ${process.env.CLICKUP_TOKEN.length})` : "NOT SET"}` ); return ""; } return token; } /** * Build ClickUp API URL with proper query parameters * @param taskId - Task ID * @param endpoint - API endpoint path * @returns Complete URL with query parameters * @throws Error if team ID is required but not set */ private buildUrl(taskId: string, endpoint: string): string { const isCustomId = /^[A-Z]+-\d+$/i.test(taskId); let url = `https://api.clickup.com/api/v2/task/${encodeURIComponent(taskId)}${endpoint}`; if (isCustomId) { if (!this.config.clickupTeamId) { throw new Error( 'CLICKUP_TEAM_ID is required when using custom task IDs. ' + 'Please set the CLICKUP_TEAM_ID environment variable.' ); } const params = new URLSearchParams({ custom_task_ids: "true" }); params.append("team_id", this.config.clickupTeamId); url += `?${params.toString()}`; } return url; } /** * Fetch a ClickUp task by ID * @param taskId - The ClickUp task ID * @returns API response with status and body */ async getTask(taskId: string): Promise<ClickUpApiResponse> { // Check rate limit try { await this.rateLimiter.checkLimit('clickup-api'); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); error(`Rate limit error: ${errorMessage}`); return { status: 429, body: { raw: errorMessage, err: "Rate limit exceeded", ECODE: "RATE_LIMIT_001" }, }; } const token = this.getToken(); if (!token) { return { status: 401, body: { raw: "CLICKUP_TOKEN not configured. Please set CLICKUP_TOKEN environment variable in MCP server configuration.", err: "Oauth token not found", ECODE: "OAUTH_019", }, }; } try { const url = this.buildUrl(taskId, ""); const isCustomId = /^[A-Z]+-\d+$/i.test(taskId); debug(`Fetching ClickUp task: ${url} (custom_id: ${isCustomId})`); debug(`Authorization header: ${token.substring(0, 10)}... (length: ${token.length})`); const res = await fetch(url, { headers: { Authorization: token, // ClickUp personal API tokens (pk_*) don't use Bearer prefix "Content-Type": "application/json", }, }); const text = await res.text(); let json: ClickUpTaskResponse | { raw: string }; try { json = JSON.parse(text) as ClickUpTaskResponse; } catch { json = { raw: text }; } if (res.status !== 200) { error(`ClickUp API error: HTTP ${res.status}`); debug(`Response body: ${text.substring(0, 200)}`); } return { status: res.status, body: json }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); error(`Network error: ${errorMessage}`); return { status: 0, body: { raw: `Network error: ${errorMessage}` }, }; } } /** * Update ClickUp task status * @param taskId - The ClickUp task ID * @param status - The status to set * @returns API response with status and body */ async updateStatus(taskId: string, status: string): Promise<ClickUpApiResponse> { // Check rate limit try { await this.rateLimiter.checkLimit('clickup-api'); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); error(`Rate limit error: ${errorMessage}`); return { status: 429, body: { raw: errorMessage, err: "Rate limit exceeded", ECODE: "RATE_LIMIT_001" }, }; } const token = this.getToken(); if (!token) { return { status: 401, body: { raw: "CLICKUP_TOKEN not configured. Please set CLICKUP_TOKEN environment variable in MCP server configuration.", err: "Oauth token not found", ECODE: "OAUTH_019", }, }; } try { const url = this.buildUrl(taskId, ""); const isCustomId = /^[A-Z]+-\d+$/i.test(taskId); const body = JSON.stringify({ status }); debug(`Updating ClickUp task status: ${url}, status: ${status} (custom_id: ${isCustomId})`); debug(`Authorization header: ${token.substring(0, 10)}... (length: ${token.length})`); const res = await fetch(url, { method: "PUT", headers: { Authorization: token, "Content-Type": "application/json", }, body, }); const text = await res.text(); let json: ClickUpTaskResponse | { raw: string }; try { json = JSON.parse(text) as ClickUpTaskResponse; } catch { json = { raw: text }; } if (res.status !== 200) { error(`ClickUp API error: HTTP ${res.status}`); debug(`Response body: ${text.substring(0, 200)}`); } return { status: res.status, body: json }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); error(`Network error: ${errorMessage}`); return { status: 0, body: { raw: `Network error: ${errorMessage}` }, }; } } /** * Update ClickUp task custom field value * @param taskId - The ClickUp task ID * @param fieldId - The custom field ID to update * @param value - The value to set * @returns API response with status and body */ async updateCustomField(taskId: string, fieldId: string, value: string): Promise<ClickUpApiResponse> { // Check rate limit try { await this.rateLimiter.checkLimit('clickup-api'); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); error(`Rate limit error: ${errorMessage}`); return { status: 429, body: { raw: errorMessage, err: "Rate limit exceeded", ECODE: "RATE_LIMIT_001" }, }; } const token = this.getToken(); if (!token) { return { status: 401, body: { raw: "CLICKUP_TOKEN not configured. Please set CLICKUP_TOKEN environment variable in MCP server configuration.", err: "Oauth token not found", ECODE: "OAUTH_019", }, }; } try { const url = this.buildUrl(taskId, `/field/${encodeURIComponent(fieldId)}`); const isCustomId = /^[A-Z]+-\d+$/i.test(taskId); const body = JSON.stringify({ value }); debug(`Updating ClickUp custom field: ${url}, field: ${fieldId}, value: ${value} (custom_id: ${isCustomId})`); const res = await fetch(url, { method: "POST", headers: { Authorization: token, "Content-Type": "application/json", }, body, }); const text = await res.text(); let json: ClickUpTaskResponse | { raw: string }; try { json = JSON.parse(text) as ClickUpTaskResponse; } catch { json = { raw: text }; } if (res.status !== 200) { error(`ClickUp custom field update error: HTTP ${res.status}`); debug(`Response body: ${text.substring(0, 200)}`); } else { debug(`Successfully updated custom field ${fieldId} to value: ${value}`); } return { status: res.status, body: json }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); error(`Network error updating custom field: ${errorMessage}`); return { status: 0, body: { raw: `Network error: ${errorMessage}` }, }; } } /** * Add a comment to a ClickUp task * @param taskId - The ClickUp task ID * @param commentText - The comment text to add * @returns API response with status and body */ async addComment(taskId: string, commentText: string): Promise<ClickUpApiResponse> { // Check rate limit try { await this.rateLimiter.checkLimit('clickup-api'); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); error(`Rate limit error: ${errorMessage}`); return { status: 429, body: { raw: errorMessage, err: "Rate limit exceeded", ECODE: "RATE_LIMIT_001" }, }; } const token = this.getToken(); if (!token) { return { status: 401, body: { raw: "CLICKUP_TOKEN not configured. Please set CLICKUP_TOKEN environment variable in MCP server configuration.", err: "Oauth token not found", ECODE: "OAUTH_019", }, }; } try { const url = this.buildUrl(taskId, "/comment"); const isCustomId = /^[A-Z]+-\d+$/i.test(taskId); const body = JSON.stringify({ comment_text: commentText }); debug(`Adding comment to ClickUp task: ${url} (custom_id: ${isCustomId})`); debug(`Comment text length: ${commentText.length}`); const res = await fetch(url, { method: "POST", headers: { Authorization: token, "Content-Type": "application/json", }, body, }); const text = await res.text(); let json: ClickUpTaskResponse | { raw: string }; try { json = JSON.parse(text) as ClickUpTaskResponse; } catch { json = { raw: text }; } if (res.status !== 200) { error(`ClickUp comment add error: HTTP ${res.status}`); debug(`Response body: ${text.substring(0, 200)}`); } else { debug(`Successfully added comment to task ${taskId}`); } return { status: res.status, body: json }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); error(`Network error adding comment: ${errorMessage}`); return { status: 0, body: { raw: `Network error: ${errorMessage}` }, }; } } /** * Find custom field ID by name from task response * @param taskBody - The task response body * @param fieldName - The name of the custom field to find * @returns The field ID if found, null otherwise */ findCustomFieldId(taskBody: unknown, fieldName: string): string | null { if (!taskBody || typeof taskBody !== "object") { return null; } const task = taskBody as { custom_fields?: Array<{ id?: string; name?: string }>; }; if (!task.custom_fields || !Array.isArray(task.custom_fields)) { return null; } const field = task.custom_fields.find((f) => f.name === fieldName); return field?.id ?? null; } /** * Format task details into a human-readable summary * @param task - ClickUp task response * @returns Formatted task details summary */ formatTaskDetails(task: ClickUpTaskResponse): TaskDetailsSummary { const customFieldsMap: Record<string, unknown> = {}; if (task.custom_fields && Array.isArray(task.custom_fields)) { task.custom_fields.forEach((field) => { if (field.name) { customFieldsMap[field.name] = field.value; } }); } // Calculate time estimates const timeEstimateHours = task.time_estimate ? task.time_estimate / 3600000 : null; const timeSpentHours = task.time_spent ? task.time_spent / 3600000 : null; return { id: task.id ?? "", custom_id: task.custom_id ?? null, name: task.name ?? "", status: task.status?.status ?? "Unknown", description: task.text_content ?? task.description ?? "", created_at: task.date_created ?? "", updated_at: task.date_updated ?? "", creator: task.creator?.username ?? task.creator?.email ?? "Unknown", assignees: task.assignees?.map((a) => a.username ?? a.email ?? "Unknown") ?? [], tags: task.tags?.map((t) => t.name ?? "") ?? [], priority: task.priority?.priority ?? null, due_date: task.due_date ?? null, time_estimate: timeEstimateHours ? `${timeEstimateHours.toFixed(2)}h` : null, time_spent: timeSpentHours ? `${timeSpentHours.toFixed(2)}h` : null, url: task.url ?? "", list: task.list?.name ?? "Unknown", folder: task.folder?.name ?? "Unknown", space: task.space?.name ?? "Unknown", custom_fields: customFieldsMap, dependencies_count: task.dependencies?.length ?? 0, subtasks_count: 0, // Would need separate API call to get subtasks }; } /** * Analyze task workflow and provide insights * @param task - ClickUp task response * @returns Workflow analysis with insights and recommendations */ analyzeTaskWorkflow(task: ClickUpTaskResponse): TaskWorkflowAnalysis { const now = Date.now(); const createdAt = task.date_created ? parseInt(task.date_created) : now; const updatedAt = task.date_updated ? parseInt(task.date_updated) : now; const daysSinceCreation = Math.floor((now - createdAt) / (1000 * 60 * 60 * 24)); const daysSinceUpdate = Math.floor((now - updatedAt) / (1000 * 60 * 60 * 24)); // Time analysis const timeEstimate = task.time_estimate ? task.time_estimate / 3600000 : null; // Convert to hours const timeSpent = task.time_spent ? task.time_spent / 3600000 : null; const remainingHours = timeEstimate && timeSpent ? Math.max(0, timeEstimate - timeSpent) : null; // Team analysis const creator = task.creator?.username ?? task.creator?.email ?? "Unknown"; const assignees = task.assignees?.map((a) => a.username ?? a.email ?? "Unknown") ?? []; const watchersCount = task.watchers?.length ?? 0; // Dependency analysis const dependencies = task.dependencies ?? []; const dependsOnTasks = dependencies .filter((d) => d.type === 0) .map((d) => d.depends_on ?? "") .filter((id) => id); const blockedByCount = dependencies.filter((d) => d.type === 1).length; const blockingCount = dependencies.filter((d) => d.type === 2).length; // Calculate progress (simple estimation based on status) let progressPercentage = 0; const statusName = task.status?.status?.toLowerCase() ?? ""; if (statusName.includes("done") || statusName.includes("complete") || statusName.includes("closed")) { progressPercentage = 100; } else if (statusName.includes("progress") || statusName.includes("active")) { progressPercentage = 50; } else if (statusName.includes("review") || statusName.includes("testing")) { progressPercentage = 75; } // Risk factors const riskFactors: string[] = []; if (daysSinceUpdate > 7) { riskFactors.push(`No updates in ${daysSinceUpdate} days - task may be stale`); } if (assignees.length === 0) { riskFactors.push("No assignees - task ownership unclear"); } if (task.due_date && parseInt(task.due_date) < now) { riskFactors.push("Task is overdue"); } if (blockedByCount > 0) { riskFactors.push(`Blocked by ${blockedByCount} tasks`); } if (timeEstimate && timeSpent && timeSpent > timeEstimate) { riskFactors.push("Time spent exceeds estimate"); } // Recommendations const recommendations: string[] = []; if (assignees.length === 0) { recommendations.push("Assign team members to this task"); } if (daysSinceUpdate > 7 && progressPercentage < 100) { recommendations.push("Check task status and update progress"); } if (blockedByCount > 0) { recommendations.push("Review and resolve blocking dependencies"); } if (task.priority === null) { recommendations.push("Set task priority for better planning"); } if (!task.due_date) { recommendations.push("Set a due date for better time management"); } if (!task.time_estimate) { recommendations.push("Add time estimate for better resource planning"); } return { task_id: task.id ?? "", task_name: task.name ?? "", current_status: task.status?.status ?? "Unknown", progress_percentage: progressPercentage, time_analysis: { created_at: new Date(createdAt).toISOString(), last_updated: new Date(updatedAt).toISOString(), days_since_creation: daysSinceCreation, days_since_update: daysSinceUpdate, estimated_hours: timeEstimate, spent_hours: timeSpent, remaining_hours: remainingHours, }, team_analysis: { creator, assignees, watchers_count: watchersCount, }, dependency_analysis: { depends_on_count: dependsOnTasks.length, depends_on_tasks: dependsOnTasks, blocked_by_count: blockedByCount, blocking_count: blockingCount, }, subtask_analysis: { total_subtasks: 0, // Would need separate API call completed_subtasks: 0, pending_subtasks: 0, }, risk_factors: riskFactors, recommendations, }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/tangbodie/clickup-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server