@taazkareem/clickup-mcp-server 0.6.4 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/build/config.js +2 -3
- package/build/index.js +17 -33
- package/build/logger.js +5 -6
- package/build/server.js +32 -17
- package/build/services/clickup/base.js +156 -45
- package/build/services/clickup/bulk.js +10 -22
- package/build/services/clickup/tag.js +51 -2
- package/build/services/clickup/task/task-core.js +76 -11
- package/build/services/clickup/task/task-search.js +378 -25
- package/build/services/clickup/workspace.js +14 -12
- package/build/tools/tag.js +88 -36
- package/build/tools/task/attachments.js +15 -9
- package/build/tools/task/handlers.js +253 -136
- package/build/tools/task/main.js +9 -33
- package/build/tools/task/single-operations.js +9 -7
- package/build/tools/task/utilities.js +59 -12
- package/build/utils/date-utils.js +7 -4
- package/build/utils/resolver-utils.js +102 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
A Model Context Protocol (MCP) server for integrating ClickUp tasks with AI applications. This server allows AI agents to interact with ClickUp tasks, spaces, lists, and folders through a standardized protocol.
|
|
8
8
|
|
|
9
|
-
> 🚧 **Status Update:** Rolling out v0.6.
|
|
9
|
+
> 🚧 **Status Update:** Rolling out v0.6.5 will add Global Task Lookup with smart disambiguation, Start Date Support for tasks with natural language expressions, Complete Tag Support including natural language tag color commands, Subtasks Support, Custom ID Support, and Logging Fixes
|
|
10
10
|
|
|
11
11
|
## Setup
|
|
12
12
|
|
|
@@ -18,7 +18,7 @@ A Model Context Protocol (MCP) server for integrating ClickUp tasks with AI appl
|
|
|
18
18
|
|
|
19
19
|
## Smithery Installation (Quick Start)
|
|
20
20
|
|
|
21
|
-
[](https://smithery.ai/server/@TaazKareem/clickup-mcp-server)
|
|
22
22
|
|
|
23
23
|
The server is hosted on [Smithery](https://smithery.ai/server/@TaazKareem/clickup-mcp-server). There, you can preview the available tools or copy the commands to run on your specific client app.
|
|
24
24
|
|
package/build/config.js
CHANGED
|
@@ -43,7 +43,7 @@ export const parseLogLevel = (levelStr) => {
|
|
|
43
43
|
case 'WARN': return LogLevel.WARN;
|
|
44
44
|
case 'ERROR': return LogLevel.ERROR;
|
|
45
45
|
default:
|
|
46
|
-
console.error
|
|
46
|
+
// Don't use console.error as it interferes with JSON-RPC communication
|
|
47
47
|
return LogLevel.ERROR;
|
|
48
48
|
}
|
|
49
49
|
};
|
|
@@ -54,8 +54,7 @@ const configuration = {
|
|
|
54
54
|
enableSponsorMessage: process.env.ENABLE_SPONSOR_MESSAGE !== 'false',
|
|
55
55
|
logLevel: parseLogLevel(envArgs.logLevel || process.env.LOG_LEVEL)
|
|
56
56
|
};
|
|
57
|
-
//
|
|
58
|
-
console.debug(`Log level set to: ${LogLevel[configuration.logLevel]}`);
|
|
57
|
+
// Don't log to console as it interferes with JSON-RPC communication
|
|
59
58
|
// Validate only the required variables are present
|
|
60
59
|
const requiredVars = ['clickupApiKey', 'clickupTeamId'];
|
|
61
60
|
const missingEnvVars = requiredVars
|
package/build/index.js
CHANGED
|
@@ -23,43 +23,27 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
25
25
|
import { configureServer, server } from "./server.js";
|
|
26
|
-
import { info, error
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
info("Use 'pkill -9 -f clickup-mcp-server' to kill all instances before starting a new one.");
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
return true;
|
|
45
|
-
}
|
|
46
|
-
catch (err) {
|
|
47
|
-
error("Failed to check for other running instances", err);
|
|
48
|
-
// Continue execution even if check fails
|
|
49
|
-
return true;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
26
|
+
import { info, error } from "./logger.js";
|
|
27
|
+
import { dirname } from 'path';
|
|
28
|
+
import { fileURLToPath } from 'url';
|
|
29
|
+
// Get directory name for module paths
|
|
30
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
// Handle uncaught exceptions
|
|
32
|
+
process.on('uncaughtException', (err) => {
|
|
33
|
+
error("Uncaught Exception", { message: err.message, stack: err.stack });
|
|
34
|
+
process.exit(1);
|
|
35
|
+
});
|
|
36
|
+
// Handle unhandled promise rejections
|
|
37
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
38
|
+
error("Unhandled Rejection", { reason });
|
|
39
|
+
process.exit(1);
|
|
40
|
+
});
|
|
52
41
|
/**
|
|
53
42
|
* Application entry point that configures and starts the MCP server.
|
|
54
43
|
*/
|
|
55
44
|
async function main() {
|
|
56
45
|
try {
|
|
57
46
|
info("Starting ClickUp MCP Server...");
|
|
58
|
-
// Check if we're the only instance
|
|
59
|
-
const isSingleInstance = await checkSingleInstance();
|
|
60
|
-
if (!isSingleInstance) {
|
|
61
|
-
warn("Continuing startup despite multiple instances detected");
|
|
62
|
-
}
|
|
63
47
|
// Log essential information about the environment
|
|
64
48
|
info("Server environment", {
|
|
65
49
|
pid: process.pid,
|
|
@@ -77,11 +61,11 @@ async function main() {
|
|
|
77
61
|
info("Server startup complete - ready to handle requests");
|
|
78
62
|
}
|
|
79
63
|
catch (err) {
|
|
80
|
-
error("Error during server startup", err);
|
|
64
|
+
error("Error during server startup", { message: err.message, stack: err.stack });
|
|
81
65
|
process.exit(1);
|
|
82
66
|
}
|
|
83
67
|
}
|
|
84
68
|
main().catch((err) => {
|
|
85
|
-
error("Unhandled server error", err);
|
|
69
|
+
error("Unhandled server error", { message: err.message, stack: err.stack });
|
|
86
70
|
process.exit(1);
|
|
87
71
|
});
|
package/build/logger.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Logger module for MCP Server
|
|
6
6
|
*
|
|
7
7
|
* This module provides logging functionality for the server,
|
|
8
|
-
* writing logs to
|
|
8
|
+
* writing logs to only the log file to avoid interfering with JSON-RPC.
|
|
9
9
|
*/
|
|
10
10
|
import { createWriteStream } from 'fs';
|
|
11
11
|
import { join, dirname } from 'path';
|
|
@@ -18,7 +18,8 @@ const pid = process.pid;
|
|
|
18
18
|
// Create a write stream for logging - use a fixed filename in the build directory
|
|
19
19
|
const logFileName = 'server.log';
|
|
20
20
|
const logStream = createWriteStream(join(__dirname, logFileName), { flags: 'w' });
|
|
21
|
-
|
|
21
|
+
// Write init message to log file only
|
|
22
|
+
logStream.write(`Logging initialized to ${join(__dirname, logFileName)}\n`);
|
|
22
23
|
// Use the configured log level from config.ts
|
|
23
24
|
const configuredLevel = config.logLevel;
|
|
24
25
|
// Re-export LogLevel enum
|
|
@@ -32,7 +33,7 @@ export function isLevelEnabled(level) {
|
|
|
32
33
|
return level >= configuredLevel;
|
|
33
34
|
}
|
|
34
35
|
/**
|
|
35
|
-
* Log function that writes to
|
|
36
|
+
* Log function that writes only to file to avoid interfering with JSON-RPC
|
|
36
37
|
* @param level Log level (trace, debug, info, warn, error)
|
|
37
38
|
* @param message Message to log
|
|
38
39
|
* @param data Optional data to include in log
|
|
@@ -74,9 +75,7 @@ export function log(level, message, data) {
|
|
|
74
75
|
logMessage += '\n' + JSON.stringify(data, null, 2);
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
|
-
//
|
|
78
|
-
console.error(logMessage);
|
|
79
|
-
// Write to file
|
|
78
|
+
// Write to file only, not to stderr which would interfere with JSON-RPC
|
|
80
79
|
logStream.write(logMessage + '\n');
|
|
81
80
|
}
|
|
82
81
|
/**
|
package/build/server.js
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
* MCP Server for ClickUp integration
|
|
6
6
|
*/
|
|
7
7
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
|
-
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
9
9
|
import { workspaceHierarchyTool, handleGetWorkspaceHierarchy } from "./tools/workspace.js";
|
|
10
|
-
import { createTaskTool, updateTaskTool, moveTaskTool, duplicateTaskTool, getTaskTool,
|
|
10
|
+
import { createTaskTool, updateTaskTool, moveTaskTool, duplicateTaskTool, getTaskTool, deleteTaskTool, getTaskCommentsTool, createTaskCommentTool, createBulkTasksTool, updateBulkTasksTool, moveBulkTasksTool, deleteBulkTasksTool, attachTaskFileTool, getWorkspaceTasksTool, handleCreateTask, handleUpdateTask, handleMoveTask, handleDuplicateTask, handleDeleteTask, handleGetTaskComments, handleCreateTaskComment, handleCreateBulkTasks, handleUpdateBulkTasks, handleMoveBulkTasks, handleDeleteBulkTasks, handleGetTask, handleAttachTaskFile, handleGetWorkspaceTasks } from "./tools/task/index.js";
|
|
11
11
|
import { createListTool, handleCreateList, createListInFolderTool, handleCreateListInFolder, getListTool, handleGetList, updateListTool, handleUpdateList, deleteListTool, handleDeleteList } from "./tools/list.js";
|
|
12
12
|
import { createFolderTool, handleCreateFolder, getFolderTool, handleGetFolder, updateFolderTool, handleUpdateFolder, deleteFolderTool, handleDeleteFolder } from "./tools/folder.js";
|
|
13
|
-
import { getSpaceTagsTool, handleGetSpaceTags,
|
|
13
|
+
import { getSpaceTagsTool, handleGetSpaceTags, addTagToTaskTool, handleAddTagToTask, removeTagFromTaskTool, handleRemoveTagFromTask } from "./tools/tag.js";
|
|
14
14
|
import { Logger } from "./logger.js";
|
|
15
15
|
import { clickUpServices } from "./services/shared.js";
|
|
16
16
|
// Create a logger instance for server
|
|
@@ -24,6 +24,7 @@ export const server = new Server({
|
|
|
24
24
|
capabilities: {
|
|
25
25
|
tools: {},
|
|
26
26
|
prompts: {},
|
|
27
|
+
resources: {},
|
|
27
28
|
},
|
|
28
29
|
});
|
|
29
30
|
/**
|
|
@@ -39,7 +40,6 @@ export function configureServer() {
|
|
|
39
40
|
workspaceHierarchyTool,
|
|
40
41
|
createTaskTool,
|
|
41
42
|
getTaskTool,
|
|
42
|
-
getTasksTool,
|
|
43
43
|
updateTaskTool,
|
|
44
44
|
moveTaskTool,
|
|
45
45
|
duplicateTaskTool,
|
|
@@ -62,14 +62,16 @@ export function configureServer() {
|
|
|
62
62
|
updateFolderTool,
|
|
63
63
|
deleteFolderTool,
|
|
64
64
|
getSpaceTagsTool,
|
|
65
|
-
createSpaceTagTool,
|
|
66
|
-
updateSpaceTagTool,
|
|
67
|
-
deleteSpaceTagTool,
|
|
68
65
|
addTagToTaskTool,
|
|
69
66
|
removeTagFromTaskTool
|
|
70
67
|
]
|
|
71
68
|
};
|
|
72
69
|
});
|
|
70
|
+
// Add handler for resources/list
|
|
71
|
+
server.setRequestHandler(ListResourcesRequestSchema, async (req) => {
|
|
72
|
+
logger.debug("Received ListResources request");
|
|
73
|
+
return { resources: [] };
|
|
74
|
+
});
|
|
73
75
|
// Register CallTool handler with proper logging
|
|
74
76
|
logger.info("Registering tool handlers", {
|
|
75
77
|
toolCount: 31,
|
|
@@ -96,8 +98,6 @@ export function configureServer() {
|
|
|
96
98
|
return handleDuplicateTask(params);
|
|
97
99
|
case "get_task":
|
|
98
100
|
return handleGetTask(params);
|
|
99
|
-
case "get_tasks":
|
|
100
|
-
return handleGetTasks(params);
|
|
101
101
|
case "delete_task":
|
|
102
102
|
return handleDeleteTask(params);
|
|
103
103
|
case "get_task_comments":
|
|
@@ -136,24 +136,39 @@ export function configureServer() {
|
|
|
136
136
|
return handleDeleteFolder(params);
|
|
137
137
|
case "get_space_tags":
|
|
138
138
|
return handleGetSpaceTags(params);
|
|
139
|
-
case "create_space_tag":
|
|
140
|
-
return handleCreateSpaceTag(params);
|
|
141
|
-
case "update_space_tag":
|
|
142
|
-
return handleUpdateSpaceTag(params);
|
|
143
|
-
case "delete_space_tag":
|
|
144
|
-
return handleDeleteSpaceTag(params);
|
|
145
139
|
case "add_tag_to_task":
|
|
146
140
|
return handleAddTagToTask(params);
|
|
147
141
|
case "remove_tag_from_task":
|
|
148
142
|
return handleRemoveTagFromTask(params);
|
|
149
143
|
default:
|
|
150
144
|
logger.error(`Unknown tool requested: ${name}`);
|
|
151
|
-
|
|
145
|
+
const error = new Error(`Unknown tool: ${name}`);
|
|
146
|
+
error.name = "UnknownToolError";
|
|
147
|
+
throw error;
|
|
152
148
|
}
|
|
153
149
|
}
|
|
154
150
|
catch (err) {
|
|
155
151
|
logger.error(`Error executing tool: ${name}`, err);
|
|
156
|
-
|
|
152
|
+
// Transform error to a more descriptive JSON-RPC error
|
|
153
|
+
if (err.name === "UnknownToolError") {
|
|
154
|
+
throw {
|
|
155
|
+
code: -32601,
|
|
156
|
+
message: `Method not found: ${name}`
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
else if (err.name === "ValidationError") {
|
|
160
|
+
throw {
|
|
161
|
+
code: -32602,
|
|
162
|
+
message: `Invalid params for tool ${name}: ${err.message}`
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
// Generic server error
|
|
167
|
+
throw {
|
|
168
|
+
code: -32000,
|
|
169
|
+
message: `Error executing tool ${name}: ${err.message}`
|
|
170
|
+
};
|
|
171
|
+
}
|
|
157
172
|
}
|
|
158
173
|
});
|
|
159
174
|
server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
@@ -40,6 +40,23 @@ export class ClickUpServiceError extends Error {
|
|
|
40
40
|
this.context = context;
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Helper function to safely parse JSON
|
|
45
|
+
* @param data Data to parse
|
|
46
|
+
* @param fallback Optional fallback value if parsing fails
|
|
47
|
+
* @returns Parsed JSON or fallback value
|
|
48
|
+
*/
|
|
49
|
+
function safeJsonParse(data, fallback = undefined) {
|
|
50
|
+
if (typeof data !== 'string') {
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(data);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
43
60
|
/**
|
|
44
61
|
* Base ClickUp service class that handles common functionality
|
|
45
62
|
*/
|
|
@@ -70,7 +87,20 @@ export class BaseClickUpService {
|
|
|
70
87
|
'Authorization': apiKey,
|
|
71
88
|
'Content-Type': 'application/json'
|
|
72
89
|
},
|
|
73
|
-
timeout: this.timeout
|
|
90
|
+
timeout: this.timeout,
|
|
91
|
+
transformResponse: [
|
|
92
|
+
// Add custom response transformer to handle both JSON and text responses
|
|
93
|
+
(data) => {
|
|
94
|
+
if (!data)
|
|
95
|
+
return data;
|
|
96
|
+
// If it's already an object, return as is
|
|
97
|
+
if (typeof data !== 'string')
|
|
98
|
+
return data;
|
|
99
|
+
// Try to parse as JSON, fall back to raw text if parsing fails
|
|
100
|
+
const parsed = safeJsonParse(data, null);
|
|
101
|
+
return parsed !== null ? parsed : data;
|
|
102
|
+
}
|
|
103
|
+
]
|
|
74
104
|
});
|
|
75
105
|
this.logger.debug(`Initialized ${className}`, { teamId, baseUrl });
|
|
76
106
|
// Add response interceptor for error handling
|
|
@@ -93,80 +123,69 @@ export class BaseClickUpService {
|
|
|
93
123
|
path,
|
|
94
124
|
status,
|
|
95
125
|
method: error.config?.method?.toUpperCase() || 'UNKNOWN',
|
|
96
|
-
requestData: error.config?.data ?
|
|
126
|
+
requestData: error.config?.data ? safeJsonParse(error.config.data, error.config.data) : undefined
|
|
97
127
|
};
|
|
98
128
|
// Pick the appropriate error code based on status
|
|
99
129
|
let code;
|
|
100
130
|
let logMessage;
|
|
131
|
+
let errorMessage;
|
|
101
132
|
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
|
102
133
|
code = ErrorCode.NETWORK_ERROR;
|
|
103
134
|
logMessage = `Request timeout for ${path}`;
|
|
135
|
+
errorMessage = 'Request timed out. Please try again.';
|
|
104
136
|
}
|
|
105
137
|
else if (!error.response) {
|
|
106
138
|
code = ErrorCode.NETWORK_ERROR;
|
|
107
139
|
logMessage = `Network error accessing ${path}: ${error.message}`;
|
|
140
|
+
errorMessage = 'Network error. Please check your connection and try again.';
|
|
108
141
|
}
|
|
109
142
|
else if (status === 429) {
|
|
110
143
|
code = ErrorCode.RATE_LIMIT;
|
|
111
144
|
this.handleRateLimitHeaders(error.response.headers);
|
|
145
|
+
// Calculate time until reset
|
|
146
|
+
const reset = error.response.headers['x-ratelimit-reset'];
|
|
147
|
+
const now = Date.now() / 1000; // Convert to seconds
|
|
148
|
+
const timeToReset = Math.max(0, reset - now);
|
|
149
|
+
const resetMinutes = Math.ceil(timeToReset / 60);
|
|
112
150
|
logMessage = `Rate limit exceeded for ${path}`;
|
|
151
|
+
errorMessage = `Rate limit exceeded. Please wait ${resetMinutes} minute${resetMinutes === 1 ? '' : 's'} before trying again.`;
|
|
152
|
+
// Add more context to the error
|
|
153
|
+
errorContext.rateLimitInfo = {
|
|
154
|
+
limit: error.response.headers['x-ratelimit-limit'],
|
|
155
|
+
remaining: error.response.headers['x-ratelimit-remaining'],
|
|
156
|
+
reset: reset,
|
|
157
|
+
timeToReset: timeToReset
|
|
158
|
+
};
|
|
113
159
|
}
|
|
114
160
|
else if (status === 401 || status === 403) {
|
|
115
161
|
code = ErrorCode.UNAUTHORIZED;
|
|
116
162
|
logMessage = `Authorization failed for ${path}`;
|
|
163
|
+
errorMessage = 'Authorization failed. Please check your API key.';
|
|
117
164
|
}
|
|
118
165
|
else if (status === 404) {
|
|
119
166
|
code = ErrorCode.NOT_FOUND;
|
|
120
167
|
logMessage = `Resource not found: ${path}`;
|
|
168
|
+
errorMessage = 'Resource not found.';
|
|
121
169
|
}
|
|
122
170
|
else if (status >= 400 && status < 500) {
|
|
123
171
|
code = ErrorCode.VALIDATION;
|
|
124
172
|
logMessage = `Validation error for ${path}: ${errorMsg}`;
|
|
173
|
+
errorMessage = errorMsg;
|
|
125
174
|
}
|
|
126
175
|
else if (status >= 500) {
|
|
127
176
|
code = ErrorCode.SERVER_ERROR;
|
|
128
177
|
logMessage = `ClickUp server error: ${errorMsg}`;
|
|
178
|
+
errorMessage = 'ClickUp server error. Please try again later.';
|
|
129
179
|
}
|
|
130
180
|
else {
|
|
131
181
|
code = ErrorCode.UNKNOWN;
|
|
132
182
|
logMessage = `Unknown API error: ${errorMsg}`;
|
|
183
|
+
errorMessage = 'An unexpected error occurred. Please try again.';
|
|
133
184
|
}
|
|
134
185
|
// Log the error with context
|
|
135
186
|
this.logger.error(logMessage, errorContext);
|
|
136
|
-
// Throw a
|
|
137
|
-
throw new ClickUpServiceError(
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* Process the request queue, respecting rate limits by spacing out requests
|
|
141
|
-
* @private
|
|
142
|
-
*/
|
|
143
|
-
async processQueue() {
|
|
144
|
-
if (this.requestQueue.length === 0) {
|
|
145
|
-
this.logger.debug('Queue empty, exiting queue processing mode');
|
|
146
|
-
this.processingQueue = false;
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
this.logger.debug(`Processing request queue (${this.requestQueue.length} items)`);
|
|
150
|
-
const startTime = Date.now();
|
|
151
|
-
try {
|
|
152
|
-
// Take the first request from the queue
|
|
153
|
-
const request = this.requestQueue.shift();
|
|
154
|
-
if (request) {
|
|
155
|
-
// Wait for the request spacing interval
|
|
156
|
-
await new Promise(resolve => setTimeout(resolve, this.requestSpacing));
|
|
157
|
-
// Run the request
|
|
158
|
-
await request();
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
catch (error) {
|
|
162
|
-
this.logger.error('Error executing queued request', error);
|
|
163
|
-
}
|
|
164
|
-
finally {
|
|
165
|
-
const duration = Date.now() - startTime;
|
|
166
|
-
this.logger.trace(`Queue item processed in ${duration}ms, ${this.requestQueue.length} items remaining`);
|
|
167
|
-
// Continue processing the queue after a short delay
|
|
168
|
-
setTimeout(() => this.processQueue(), this.requestSpacing);
|
|
169
|
-
}
|
|
187
|
+
// Throw a formatted error with user-friendly message
|
|
188
|
+
throw new ClickUpServiceError(errorMessage, code, error);
|
|
170
189
|
}
|
|
171
190
|
/**
|
|
172
191
|
* Handle rate limit headers from ClickUp API
|
|
@@ -192,14 +211,44 @@ export class BaseClickUpService {
|
|
|
192
211
|
const now = Date.now();
|
|
193
212
|
const resetTime = reset * 1000; // convert to milliseconds
|
|
194
213
|
const timeToReset = Math.max(0, resetTime - now);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
214
|
+
// Proactively adjust spacing when remaining requests get low
|
|
215
|
+
// This helps avoid hitting rate limits in the first place
|
|
216
|
+
if (remaining < limit * 0.3) {
|
|
217
|
+
// More aggressive spacing when close to limit
|
|
218
|
+
let safeSpacing;
|
|
219
|
+
if (remaining <= 5) {
|
|
220
|
+
// Very aggressive spacing for last few requests
|
|
221
|
+
safeSpacing = Math.ceil((timeToReset / remaining) * 2);
|
|
222
|
+
// Start processing in queue mode preemptively
|
|
223
|
+
if (!this.processingQueue) {
|
|
224
|
+
this.logger.info('Preemptively switching to queue mode (low remaining requests)', {
|
|
225
|
+
remaining,
|
|
226
|
+
limit
|
|
227
|
+
});
|
|
228
|
+
this.processingQueue = true;
|
|
229
|
+
this.processQueue().catch(err => {
|
|
230
|
+
this.logger.error('Error processing request queue', err);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else if (remaining <= 20) {
|
|
235
|
+
// More aggressive spacing
|
|
236
|
+
safeSpacing = Math.ceil((timeToReset / remaining) * 1.5);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
// Standard safe spacing with buffer
|
|
240
|
+
safeSpacing = Math.ceil((timeToReset / remaining) * 1.1);
|
|
241
|
+
}
|
|
242
|
+
// Apply updated spacing, but with a reasonable maximum
|
|
243
|
+
const maxSpacing = 5000; // 5 seconds max spacing
|
|
244
|
+
const adjustedSpacing = Math.min(safeSpacing, maxSpacing);
|
|
199
245
|
// Only adjust if it's greater than our current spacing
|
|
200
|
-
if (
|
|
201
|
-
this.logger.debug(`Adjusting request spacing: ${this.requestSpacing}ms → ${
|
|
202
|
-
|
|
246
|
+
if (adjustedSpacing > this.requestSpacing) {
|
|
247
|
+
this.logger.debug(`Adjusting request spacing: ${this.requestSpacing}ms → ${adjustedSpacing}ms`, {
|
|
248
|
+
remaining,
|
|
249
|
+
timeToReset
|
|
250
|
+
});
|
|
251
|
+
this.requestSpacing = adjustedSpacing;
|
|
203
252
|
}
|
|
204
253
|
}
|
|
205
254
|
}
|
|
@@ -208,6 +257,55 @@ export class BaseClickUpService {
|
|
|
208
257
|
this.logger.warn('Failed to parse rate limit headers', error);
|
|
209
258
|
}
|
|
210
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* Process the request queue, respecting rate limits by spacing out requests
|
|
262
|
+
* @private
|
|
263
|
+
*/
|
|
264
|
+
async processQueue() {
|
|
265
|
+
if (this.requestQueue.length === 0) {
|
|
266
|
+
this.logger.debug('Queue empty, exiting queue processing mode');
|
|
267
|
+
this.processingQueue = false;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const queueLength = this.requestQueue.length;
|
|
271
|
+
this.logger.debug(`Processing request queue (${queueLength} items)`);
|
|
272
|
+
const startTime = Date.now();
|
|
273
|
+
try {
|
|
274
|
+
// Take the first request from the queue
|
|
275
|
+
const request = this.requestQueue.shift();
|
|
276
|
+
if (request) {
|
|
277
|
+
// Adjust delay based on queue size
|
|
278
|
+
// Longer delays for bigger queues to prevent overwhelming the API
|
|
279
|
+
let delay = this.requestSpacing;
|
|
280
|
+
if (queueLength > 20) {
|
|
281
|
+
delay = this.requestSpacing * 2;
|
|
282
|
+
}
|
|
283
|
+
else if (queueLength > 10) {
|
|
284
|
+
delay = this.requestSpacing * 1.5;
|
|
285
|
+
}
|
|
286
|
+
// Wait for the calculated delay
|
|
287
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
288
|
+
// Run the request
|
|
289
|
+
await request();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (error instanceof ClickUpServiceError && error.code === ErrorCode.RATE_LIMIT) {
|
|
294
|
+
// If we still hit rate limits, increase the spacing
|
|
295
|
+
this.requestSpacing = Math.min(this.requestSpacing * 1.5, 10000); // Max 10s
|
|
296
|
+
this.logger.warn(`Rate limit hit during queue processing, increasing delay to ${this.requestSpacing}ms`);
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
this.logger.error('Error executing queued request', error);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
const duration = Date.now() - startTime;
|
|
304
|
+
this.logger.trace(`Queue item processed in ${duration}ms, ${this.requestQueue.length} items remaining`);
|
|
305
|
+
// Continue processing the queue after the calculated delay
|
|
306
|
+
setTimeout(() => this.processQueue(), this.requestSpacing);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
211
309
|
/**
|
|
212
310
|
* Makes an API request with rate limiting.
|
|
213
311
|
* @protected
|
|
@@ -217,7 +315,13 @@ export class BaseClickUpService {
|
|
|
217
315
|
async makeRequest(fn) {
|
|
218
316
|
// If we're being rate limited, queue the request rather than executing immediately
|
|
219
317
|
if (this.processingQueue) {
|
|
220
|
-
this.
|
|
318
|
+
const queuePosition = this.requestQueue.length + 1;
|
|
319
|
+
const estimatedWaitTime = Math.ceil((queuePosition * this.requestSpacing) / 1000);
|
|
320
|
+
this.logger.info('Request queued due to rate limiting', {
|
|
321
|
+
queuePosition,
|
|
322
|
+
estimatedWaitSeconds: estimatedWaitTime,
|
|
323
|
+
currentSpacing: this.requestSpacing
|
|
324
|
+
});
|
|
221
325
|
return new Promise((resolve, reject) => {
|
|
222
326
|
this.requestQueue.push(async () => {
|
|
223
327
|
try {
|
|
@@ -225,7 +329,14 @@ export class BaseClickUpService {
|
|
|
225
329
|
resolve(result);
|
|
226
330
|
}
|
|
227
331
|
catch (error) {
|
|
228
|
-
|
|
332
|
+
// Enhance error message with queue context if it's a rate limit error
|
|
333
|
+
if (error instanceof ClickUpServiceError && error.code === ErrorCode.RATE_LIMIT) {
|
|
334
|
+
const enhancedError = new ClickUpServiceError(`${error.message} (Request was queued at position ${queuePosition})`, error.code, error.data);
|
|
335
|
+
reject(enhancedError);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
reject(error);
|
|
339
|
+
}
|
|
229
340
|
}
|
|
230
341
|
});
|
|
231
342
|
});
|
|
@@ -65,30 +65,18 @@ export class BulkService {
|
|
|
65
65
|
*/
|
|
66
66
|
async findTaskInList(taskName, listName) {
|
|
67
67
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// Find the task by name - first try exact match
|
|
77
|
-
let matchingTask = taskList.find(t => t.name === taskName);
|
|
78
|
-
// If no exact match, try case-insensitive match
|
|
79
|
-
if (!matchingTask) {
|
|
80
|
-
matchingTask = taskList.find(t => t.name.toLowerCase() === taskName.toLowerCase());
|
|
81
|
-
// If still no match, try substring match as a fallback
|
|
82
|
-
if (!matchingTask) {
|
|
83
|
-
matchingTask = taskList.find(t => t.name.toLowerCase().includes(taskName.toLowerCase()) ||
|
|
84
|
-
taskName.toLowerCase().includes(t.name.toLowerCase()));
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (!matchingTask) {
|
|
68
|
+
const result = await this.taskService.findTasks({
|
|
69
|
+
taskName,
|
|
70
|
+
listName,
|
|
71
|
+
allowMultipleMatches: false,
|
|
72
|
+
useSmartDisambiguation: true,
|
|
73
|
+
includeFullDetails: false
|
|
74
|
+
});
|
|
75
|
+
if (!result || Array.isArray(result)) {
|
|
88
76
|
throw new ClickUpServiceError(`Task "${taskName}" not found in list "${listName}"`, ErrorCode.NOT_FOUND);
|
|
89
77
|
}
|
|
90
|
-
logger.info(`Task "${taskName}" found with ID: ${
|
|
91
|
-
return
|
|
78
|
+
logger.info(`Task "${taskName}" found with ID: ${result.id}`);
|
|
79
|
+
return result.id;
|
|
92
80
|
}
|
|
93
81
|
catch (error) {
|
|
94
82
|
// Enhance the error message
|
|
@@ -135,16 +135,65 @@ export class ClickUpTagService extends BaseClickUpService {
|
|
|
135
135
|
* Add a tag to a task
|
|
136
136
|
* @param taskId - ID of the task
|
|
137
137
|
* @param tagName - Name of the tag to add
|
|
138
|
-
* @returns Promise with success status
|
|
138
|
+
* @returns Promise with success status and tag data
|
|
139
139
|
*/
|
|
140
140
|
async addTagToTask(taskId, tagName) {
|
|
141
141
|
try {
|
|
142
142
|
this.logger.debug(`Adding tag "${tagName}" to task: ${taskId}`);
|
|
143
|
+
// First get the task to get its space ID
|
|
144
|
+
const taskResponse = await this.client.get(`/task/${taskId}`);
|
|
145
|
+
if (!taskResponse.data?.space?.id) {
|
|
146
|
+
return {
|
|
147
|
+
success: false,
|
|
148
|
+
error: {
|
|
149
|
+
message: 'Could not determine space ID from task',
|
|
150
|
+
code: 'SPACE_NOT_FOUND'
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// Get space tags to verify tag exists
|
|
155
|
+
const spaceId = taskResponse.data.space.id;
|
|
156
|
+
const spaceTags = await this.getSpaceTags(spaceId);
|
|
157
|
+
if (!spaceTags.success || !spaceTags.data) {
|
|
158
|
+
return {
|
|
159
|
+
success: false,
|
|
160
|
+
error: {
|
|
161
|
+
message: 'Failed to verify tag existence in space',
|
|
162
|
+
code: 'TAG_VERIFICATION_FAILED',
|
|
163
|
+
details: spaceTags.error
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// Check if tag exists
|
|
168
|
+
const tagExists = spaceTags.data.some(tag => tag.name === tagName);
|
|
169
|
+
if (!tagExists) {
|
|
170
|
+
return {
|
|
171
|
+
success: false,
|
|
172
|
+
error: {
|
|
173
|
+
message: `Tag "${tagName}" does not exist in the space`,
|
|
174
|
+
code: 'TAG_NOT_FOUND'
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
143
178
|
// Encode the tag name in the URL
|
|
144
179
|
const encodedTagName = encodeURIComponent(tagName);
|
|
180
|
+
// Add the tag
|
|
145
181
|
await this.client.post(`/task/${taskId}/tag/${encodedTagName}`, {});
|
|
182
|
+
// Verify the tag was added by getting the task again
|
|
183
|
+
const verifyResponse = await this.client.get(`/task/${taskId}`);
|
|
184
|
+
const tagAdded = verifyResponse.data?.tags?.some(tag => tag.name === tagName) ?? false;
|
|
185
|
+
if (!tagAdded) {
|
|
186
|
+
return {
|
|
187
|
+
success: false,
|
|
188
|
+
error: {
|
|
189
|
+
message: 'Tag addition failed verification',
|
|
190
|
+
code: 'TAG_VERIFICATION_FAILED'
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
146
194
|
return {
|
|
147
|
-
success: true
|
|
195
|
+
success: true,
|
|
196
|
+
data: { tagAdded: true }
|
|
148
197
|
};
|
|
149
198
|
}
|
|
150
199
|
catch (error) {
|