/**
* 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,
};
}
}