@taazkareem/clickup-mcp-server 0.6.5 → 0.6.7
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 +4 -4
- package/build/server.js +3 -6
- 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/folder.js +5 -53
- package/build/tools/list.js +5 -66
- package/build/tools/tag.js +92 -76
- package/build/tools/task/attachments.js +16 -29
- package/build/tools/task/bulk-operations.js +7 -52
- package/build/tools/task/handlers.js +253 -136
- package/build/tools/task/main.js +9 -33
- package/build/tools/task/single-operations.js +15 -106
- package/build/tools/task/utilities.js +59 -12
- package/build/tools/task/workspace-operations.js +15 -8
- package/build/tools/workspace.js +1 -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
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<img src="assets/images/clickup_mcp_server_social_image.png" alt="ClickUp MCP Server" width="100%">
|
|
2
2
|
|
|
3
|
-

|
|
4
4
|
[](https://github.com/TaazKareem/clickup-mcp-server/stargazers)
|
|
5
5
|
[](https://github.com/TaazKareem/clickup-mcp-server/graphs/commit-activity)
|
|
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.6 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,9 +18,9 @@ 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
|
-
The server is hosted on [Smithery](https://smithery.ai/server/@
|
|
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
|
|
|
25
25
|
## NPX Installation
|
|
26
26
|
|
package/build/server.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
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
13
|
import { getSpaceTagsTool, handleGetSpaceTags, addTagToTaskTool, handleAddTagToTask, removeTagFromTaskTool, handleRemoveTagFromTask } from "./tools/tag.js";
|
|
@@ -19,7 +19,7 @@ const logger = new Logger('Server');
|
|
|
19
19
|
const { workspace } = clickUpServices;
|
|
20
20
|
export const server = new Server({
|
|
21
21
|
name: "clickup-mcp-server",
|
|
22
|
-
version: "0.6.
|
|
22
|
+
version: "0.6.4",
|
|
23
23
|
}, {
|
|
24
24
|
capabilities: {
|
|
25
25
|
tools: {},
|
|
@@ -40,7 +40,6 @@ export function configureServer() {
|
|
|
40
40
|
workspaceHierarchyTool,
|
|
41
41
|
createTaskTool,
|
|
42
42
|
getTaskTool,
|
|
43
|
-
getTasksTool,
|
|
44
43
|
updateTaskTool,
|
|
45
44
|
moveTaskTool,
|
|
46
45
|
duplicateTaskTool,
|
|
@@ -75,7 +74,7 @@ export function configureServer() {
|
|
|
75
74
|
});
|
|
76
75
|
// Register CallTool handler with proper logging
|
|
77
76
|
logger.info("Registering tool handlers", {
|
|
78
|
-
toolCount:
|
|
77
|
+
toolCount: 31,
|
|
79
78
|
categories: ["workspace", "task", "list", "folder", "tag"]
|
|
80
79
|
});
|
|
81
80
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
@@ -99,8 +98,6 @@ export function configureServer() {
|
|
|
99
98
|
return handleDuplicateTask(params);
|
|
100
99
|
case "get_task":
|
|
101
100
|
return handleGetTask(params);
|
|
102
|
-
case "get_tasks":
|
|
103
|
-
return handleGetTasks(params);
|
|
104
101
|
case "delete_task":
|
|
105
102
|
return handleDeleteTask(params);
|
|
106
103
|
case "get_task_comments":
|
|
@@ -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) {
|
|
@@ -23,6 +23,8 @@ export class TaskServiceCore extends BaseClickUpService {
|
|
|
23
23
|
tasks: new Map(),
|
|
24
24
|
lists: new Map()
|
|
25
25
|
};
|
|
26
|
+
// Cache for task name to ID mapping
|
|
27
|
+
this.nameToIdCache = new Map();
|
|
26
28
|
// Cache TTL in milliseconds (5 minutes)
|
|
27
29
|
this.CACHE_TTL = 5 * 60 * 1000;
|
|
28
30
|
if (workspaceService) {
|
|
@@ -155,24 +157,40 @@ export class TaskServiceCore extends BaseClickUpService {
|
|
|
155
157
|
try {
|
|
156
158
|
return await this.makeRequest(async () => {
|
|
157
159
|
const response = await this.client.post(`/list/${listId}/task`, taskData);
|
|
158
|
-
|
|
160
|
+
// Handle both JSON and text responses
|
|
161
|
+
const data = response.data;
|
|
162
|
+
if (typeof data === 'string') {
|
|
163
|
+
// If we got a text response, try to extract task ID from common patterns
|
|
164
|
+
const idMatch = data.match(/task.*?(\w{9})/i);
|
|
165
|
+
if (idMatch) {
|
|
166
|
+
// If we found an ID, fetch the full task details
|
|
167
|
+
return await this.getTask(idMatch[1]);
|
|
168
|
+
}
|
|
169
|
+
throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data);
|
|
170
|
+
}
|
|
171
|
+
return data;
|
|
159
172
|
});
|
|
160
173
|
}
|
|
161
174
|
catch (error) {
|
|
162
|
-
throw this.handleError(error,
|
|
175
|
+
throw this.handleError(error, 'Failed to create task');
|
|
163
176
|
}
|
|
164
177
|
}
|
|
165
178
|
/**
|
|
166
|
-
* Get a
|
|
179
|
+
* Get a task by its ID
|
|
167
180
|
* @param taskId The ID of the task to retrieve
|
|
168
|
-
* @returns The task
|
|
181
|
+
* @returns The task
|
|
169
182
|
*/
|
|
170
183
|
async getTask(taskId) {
|
|
171
184
|
this.logOperation('getTask', { taskId });
|
|
172
185
|
try {
|
|
173
186
|
return await this.makeRequest(async () => {
|
|
174
187
|
const response = await this.client.get(`/task/${taskId}`);
|
|
175
|
-
|
|
188
|
+
// Handle both JSON and text responses
|
|
189
|
+
const data = response.data;
|
|
190
|
+
if (typeof data === 'string') {
|
|
191
|
+
throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data);
|
|
192
|
+
}
|
|
193
|
+
return data;
|
|
176
194
|
});
|
|
177
195
|
}
|
|
178
196
|
catch (error) {
|
|
@@ -180,22 +198,27 @@ export class TaskServiceCore extends BaseClickUpService {
|
|
|
180
198
|
}
|
|
181
199
|
}
|
|
182
200
|
/**
|
|
183
|
-
* Get all tasks in a list
|
|
201
|
+
* Get all tasks in a list
|
|
184
202
|
* @param listId The ID of the list to get tasks from
|
|
185
203
|
* @param filters Optional filters to apply
|
|
186
|
-
* @returns
|
|
204
|
+
* @returns Array of tasks
|
|
187
205
|
*/
|
|
188
206
|
async getTasks(listId, filters = {}) {
|
|
189
207
|
this.logOperation('getTasks', { listId, filters });
|
|
190
208
|
try {
|
|
191
209
|
return await this.makeRequest(async () => {
|
|
192
210
|
const params = this.buildTaskFilterParams(filters);
|
|
193
|
-
const response = await this.client.get(`/list/${listId}/task
|
|
194
|
-
|
|
211
|
+
const response = await this.client.get(`/list/${listId}/task`, { params });
|
|
212
|
+
// Handle both JSON and text responses
|
|
213
|
+
const data = response.data;
|
|
214
|
+
if (typeof data === 'string') {
|
|
215
|
+
throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data);
|
|
216
|
+
}
|
|
217
|
+
return Array.isArray(data) ? data : data.tasks || [];
|
|
195
218
|
});
|
|
196
219
|
}
|
|
197
220
|
catch (error) {
|
|
198
|
-
throw this.handleError(error, `Failed to get tasks
|
|
221
|
+
throw this.handleError(error, `Failed to get tasks for list ${listId}`);
|
|
199
222
|
}
|
|
200
223
|
}
|
|
201
224
|
/**
|
|
@@ -247,7 +270,18 @@ export class TaskServiceCore extends BaseClickUpService {
|
|
|
247
270
|
try {
|
|
248
271
|
return await this.makeRequest(async () => {
|
|
249
272
|
const response = await this.client.put(`/task/${taskId}`, updateData);
|
|
250
|
-
|
|
273
|
+
// Handle both JSON and text responses
|
|
274
|
+
const data = response.data;
|
|
275
|
+
if (typeof data === 'string') {
|
|
276
|
+
// If we got a text response, try to extract task ID from common patterns
|
|
277
|
+
const idMatch = data.match(/task.*?(\w{9})/i);
|
|
278
|
+
if (idMatch) {
|
|
279
|
+
// If we found an ID, fetch the full task details
|
|
280
|
+
return await this.getTask(idMatch[1]);
|
|
281
|
+
}
|
|
282
|
+
throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data);
|
|
283
|
+
}
|
|
284
|
+
return data;
|
|
251
285
|
});
|
|
252
286
|
}
|
|
253
287
|
catch (error) {
|
|
@@ -436,4 +470,35 @@ export class TaskServiceCore extends BaseClickUpService {
|
|
|
436
470
|
throw error;
|
|
437
471
|
}
|
|
438
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* Try to get a task ID from the name cache
|
|
475
|
+
* @param taskName The name of the task
|
|
476
|
+
* @param listId Optional list ID for context
|
|
477
|
+
* @returns The cached task ID if found and not expired, otherwise null
|
|
478
|
+
*/
|
|
479
|
+
getCachedTaskId(taskName, listId) {
|
|
480
|
+
const cached = this.nameToIdCache.get(taskName);
|
|
481
|
+
if (cached && Date.now() - cached.validatedAt < this.CACHE_TTL) {
|
|
482
|
+
// If listId is provided, ensure it matches the cached context
|
|
483
|
+
if (!listId || cached.listId === listId) {
|
|
484
|
+
this.logger.debug('Using cached task ID for name', { taskName, cachedId: cached.taskId });
|
|
485
|
+
return cached.taskId;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Cache a task name to ID mapping
|
|
492
|
+
* @param taskName The name of the task
|
|
493
|
+
* @param taskId The ID of the task
|
|
494
|
+
* @param listId Optional list ID for context
|
|
495
|
+
*/
|
|
496
|
+
cacheTaskNameToId(taskName, taskId, listId) {
|
|
497
|
+
this.nameToIdCache.set(taskName, {
|
|
498
|
+
taskId,
|
|
499
|
+
validatedAt: Date.now(),
|
|
500
|
+
listId
|
|
501
|
+
});
|
|
502
|
+
this.logger.debug('Cached task name to ID mapping', { taskName, taskId, listId });
|
|
503
|
+
}
|
|
439
504
|
}
|