@hauptsache.net/clickup-mcp 1.7.2 → 1.8.0

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 CHANGED
@@ -78,7 +78,7 @@ Turn natural language into powerful ClickUp actions:
78
78
  - Filter by assignees, projects, status, and metadata
79
79
 
80
80
  ### 💬 **Complete Context**
81
- - Full comment histories and team discussions
81
+ - Comment histories and team discussions (up to 250 top-level comments), including threaded comment replies
82
82
  - Task descriptions with embedded images
83
83
  - List descriptions and project guidelines
84
84
  - Document content with page navigation
@@ -186,8 +186,8 @@ The ClickUp MCP supports three operational modes to balance functionality, secur
186
186
 
187
187
  | Tool | read-minimal | read | write | Description |
188
188
  |------------------------|:------------:|:----:|:-----:|-----------------------------------------------------------------------------------------|
189
- | `getTaskById` | ✅ | ✅ | ✅ | Get complete task details including comments, images, and metadata |
190
- | `addComment` | ❌ | ❌ | ✅ | Add comments to tasks for collaboration |
189
+ | `getTaskById` | ✅ | ✅ | ✅ | Get complete task details including comments (with threaded replies), images, and metadata |
190
+ | `addComment` | ❌ | ❌ | ✅ | Add comments to tasks, or reply inside a comment thread via `parent_comment_id` |
191
191
  | `editComment` | ❌ | ❌ | ✅ | Correct your own comment within 24h instead of posting a follow-up |
192
192
  | `updateTask` | ❌ | ❌ | ✅ | Update tasks (status, priority, assignees, etc.) with **SAFE APPEND-ONLY** descriptions |
193
193
  | `createTask` | ❌ | ❌ | ✅ | Create new tasks with full markdown support |
@@ -476,7 +476,7 @@ function buildImageFragment(attachment, caption) {
476
476
  * fragment (e.g. ?comment=... deep links) do NOT match, because a mention would
477
477
  * either not resolve or lose the anchor - those stay ordinary links.
478
478
  */
479
- const CLICKUP_TASK_URL_PATTERN = /^https?:\/\/app\.clickup\.com\/t\/(?:\d+\/)?([a-z0-9]{6,12})\/?$/;
479
+ const CLICKUP_TASK_URL_PATTERN = /^https?:\/\/app\.clickup\.com\/t\/(?:\d+\/)?([a-z0-9]{6,16})\/?$/;
480
480
  /**
481
481
  * Extract the task ID from a ClickUp task URL, or null if it is not one.
482
482
  */
@@ -0,0 +1,65 @@
1
+ /** A task comment as returned by GET /api/v2/task/{task_id}/comment or GET /comment/{id}/reply */
2
+ export interface ExistingComment {
3
+ id: string;
4
+ date: string;
5
+ comment?: any[];
6
+ comment_text?: string;
7
+ user?: {
8
+ id?: number | string;
9
+ username?: string;
10
+ };
11
+ reply_count?: number;
12
+ }
13
+ /** Cursor into the comment list: the date and id of the last comment of the previous page */
14
+ export interface CommentPageCursor {
15
+ start: string;
16
+ startId: string;
17
+ }
18
+ /**
19
+ * Never page further back than this. A busy ticket would otherwise walk its
20
+ * entire comment history and eat the 100 calls/minute budget.
21
+ */
22
+ export declare const MAX_COMMENT_PAGES = 10;
23
+ /** One page of task comments, newest first, 25 per page */
24
+ export declare function fetchCommentPage(taskId: string, cursor?: CommentPageCursor): Promise<ExistingComment[]>;
25
+ /**
26
+ * All top-level comments of a task, newest first.
27
+ *
28
+ * The comment list endpoint returns 25 comments per page, so longer histories are
29
+ * paged with `start`/`start_id`. Paging is capped at MAX_COMMENT_PAGES (250 comments)
30
+ * to protect the API budget; hitting the cap is logged instead of failing.
31
+ */
32
+ export declare function fetchAllTopLevelComments(taskId: string): Promise<ExistingComment[]>;
33
+ /**
34
+ * Find a top-level comment of a task by id, paging as far as MAX_COMMENT_PAGES.
35
+ *
36
+ * Unlike editComment's lookup this does not stop at the edit window, because a
37
+ * thread parent can be arbitrarily old. Returns undefined when the id is not a
38
+ * top-level comment of this task - which also catches reply ids, since replies
39
+ * never appear in the task's comment list.
40
+ */
41
+ export declare function findTopLevelComment(taskId: string, commentId: string): Promise<ExistingComment | undefined>;
42
+ /**
43
+ * The replies inside a comment thread, oldest first.
44
+ *
45
+ * `GET /task/{id}/comment` only returns top-level comments; the replies of a
46
+ * thread (Threaded Comments ClickApp) live behind `GET /comment/{id}/reply`.
47
+ * Any failure (non-ok response, network error, malformed body) is logged and
48
+ * returns an empty array so one broken thread does not take down the whole
49
+ * task view.
50
+ */
51
+ export declare function fetchCommentReplies(commentId: string): Promise<ExistingComment[]>;
52
+ /**
53
+ * Never fetch more threads than this per task read, and never more than a few
54
+ * at once - together with MAX_COMMENT_PAGES this keeps a single getTaskById
55
+ * within ClickUp's 100 calls/minute budget even on a heavily threaded task.
56
+ */
57
+ export declare const MAX_REPLY_FETCHES = 30;
58
+ /**
59
+ * Fetch the replies of every comment with reply_count > 0, bounded in count and
60
+ * concurrency. Returns a map of comment id -> replies; a thread that was skipped
61
+ * (over the cap) or failed to load is simply absent or empty, so callers can
62
+ * render a "replies not loaded" hint from reply_count.
63
+ */
64
+ export declare function fetchRepliesByComment(comments: ExistingComment[]): Promise<Map<string, ExistingComment[]>>;
65
+ //# sourceMappingURL=comments.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comments.d.ts","sourceRoot":"","sources":["../../src/shared/comments.ts"],"names":[],"mappings":"AAEA,kGAAkG;AAClG,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,GAAG,EAAE,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,6FAA6F;AAC7F,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAKpC,2DAA2D;AAC3D,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,iBAAiB,GACzB,OAAO,CAAC,eAAe,EAAE,CAAC,CAqB5B;AAED;;;;;;GAMG;AACH,wBAAsB,wBAAwB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CA+BzF;AAED;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,GAAG,SAAS,CAAC,CAiBtC;AAED;;;;;;;;GAQG;AACH,wBAAsB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAsBvF;AAED;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAGpC;;;;;GAKG;AACH,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,eAAe,EAAE,GAC1B,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,eAAe,EAAE,CAAC,CAAC,CAsBzC"}
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_REPLY_FETCHES = exports.MAX_COMMENT_PAGES = void 0;
4
+ exports.fetchCommentPage = fetchCommentPage;
5
+ exports.fetchAllTopLevelComments = fetchAllTopLevelComments;
6
+ exports.findTopLevelComment = findTopLevelComment;
7
+ exports.fetchCommentReplies = fetchCommentReplies;
8
+ exports.fetchRepliesByComment = fetchRepliesByComment;
9
+ const config_1 = require("./config");
10
+ /**
11
+ * Never page further back than this. A busy ticket would otherwise walk its
12
+ * entire comment history and eat the 100 calls/minute budget.
13
+ */
14
+ exports.MAX_COMMENT_PAGES = 10;
15
+ /** Page size ClickUp uses for the comment list - a shorter page means the last page. */
16
+ const COMMENTS_PER_PAGE = 25;
17
+ /** One page of task comments, newest first, 25 per page */
18
+ async function fetchCommentPage(taskId, cursor) {
19
+ // Note there is no `start_date` parameter - passing one is silently ignored.
20
+ // Older pages are reached with `start` + `start_id` of the previous page's last entry.
21
+ const query = cursor
22
+ ? `?${new URLSearchParams({ start: cursor.start, start_id: cursor.startId })}`
23
+ : "";
24
+ const response = await fetch(`https://api.clickup.com/api/v2/task/${taskId}/comment${query}`, { headers: { Authorization: config_1.CONFIG.apiKey } });
25
+ if (!response.ok) {
26
+ const errorData = await response.json().catch(() => ({}));
27
+ throw new Error(`Error loading comments of task ${taskId}: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
28
+ }
29
+ const data = await response.json();
30
+ return Array.isArray(data.comments) ? data.comments : [];
31
+ }
32
+ /**
33
+ * All top-level comments of a task, newest first.
34
+ *
35
+ * The comment list endpoint returns 25 comments per page, so longer histories are
36
+ * paged with `start`/`start_id`. Paging is capped at MAX_COMMENT_PAGES (250 comments)
37
+ * to protect the API budget; hitting the cap is logged instead of failing.
38
+ */
39
+ async function fetchAllTopLevelComments(taskId) {
40
+ const comments = [];
41
+ let cursor;
42
+ let pages = 0;
43
+ let lastPageWasFull = false;
44
+ while (pages < exports.MAX_COMMENT_PAGES) {
45
+ const page = await fetchCommentPage(taskId, cursor);
46
+ pages++;
47
+ if (page.length === 0) {
48
+ lastPageWasFull = false;
49
+ break;
50
+ }
51
+ comments.push(...page);
52
+ lastPageWasFull = page.length >= COMMENTS_PER_PAGE;
53
+ if (!lastPageWasFull) {
54
+ break;
55
+ }
56
+ const oldest = page[page.length - 1];
57
+ cursor = { start: String(oldest.date), startId: String(oldest.id) };
58
+ }
59
+ if (lastPageWasFull && pages >= exports.MAX_COMMENT_PAGES) {
60
+ console.error(`Task ${taskId} has more than ${comments.length} top-level comments - older comments were not loaded (capped at ${exports.MAX_COMMENT_PAGES} pages).`);
61
+ }
62
+ return comments;
63
+ }
64
+ /**
65
+ * Find a top-level comment of a task by id, paging as far as MAX_COMMENT_PAGES.
66
+ *
67
+ * Unlike editComment's lookup this does not stop at the edit window, because a
68
+ * thread parent can be arbitrarily old. Returns undefined when the id is not a
69
+ * top-level comment of this task - which also catches reply ids, since replies
70
+ * never appear in the task's comment list.
71
+ */
72
+ async function findTopLevelComment(taskId, commentId) {
73
+ let cursor;
74
+ for (let pages = 0; pages < exports.MAX_COMMENT_PAGES; pages++) {
75
+ const page = await fetchCommentPage(taskId, cursor);
76
+ const match = page.find((entry) => String(entry.id) === String(commentId));
77
+ if (match) {
78
+ return match;
79
+ }
80
+ if (page.length < COMMENTS_PER_PAGE) {
81
+ return undefined;
82
+ }
83
+ const oldest = page[page.length - 1];
84
+ cursor = { start: String(oldest.date), startId: String(oldest.id) };
85
+ }
86
+ return undefined;
87
+ }
88
+ /**
89
+ * The replies inside a comment thread, oldest first.
90
+ *
91
+ * `GET /task/{id}/comment` only returns top-level comments; the replies of a
92
+ * thread (Threaded Comments ClickApp) live behind `GET /comment/{id}/reply`.
93
+ * Any failure (non-ok response, network error, malformed body) is logged and
94
+ * returns an empty array so one broken thread does not take down the whole
95
+ * task view.
96
+ */
97
+ async function fetchCommentReplies(commentId) {
98
+ try {
99
+ const response = await fetch(`https://api.clickup.com/api/v2/comment/${commentId}/reply`, { headers: { Authorization: config_1.CONFIG.apiKey } });
100
+ if (!response.ok) {
101
+ const errorData = await response.json().catch(() => ({}));
102
+ console.error(`Error fetching replies for comment ${commentId}: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
103
+ return [];
104
+ }
105
+ const data = await response.json();
106
+ const replies = Array.isArray(data?.comments) ? data.comments : [];
107
+ return replies.sort((a, b) => Number(a.date) - Number(b.date));
108
+ }
109
+ catch (error) {
110
+ console.error(`Error fetching replies for comment ${commentId}:`, error);
111
+ return [];
112
+ }
113
+ }
114
+ /**
115
+ * Never fetch more threads than this per task read, and never more than a few
116
+ * at once - together with MAX_COMMENT_PAGES this keeps a single getTaskById
117
+ * within ClickUp's 100 calls/minute budget even on a heavily threaded task.
118
+ */
119
+ exports.MAX_REPLY_FETCHES = 30;
120
+ const REPLY_FETCH_CONCURRENCY = 5;
121
+ /**
122
+ * Fetch the replies of every comment with reply_count > 0, bounded in count and
123
+ * concurrency. Returns a map of comment id -> replies; a thread that was skipped
124
+ * (over the cap) or failed to load is simply absent or empty, so callers can
125
+ * render a "replies not loaded" hint from reply_count.
126
+ */
127
+ async function fetchRepliesByComment(comments) {
128
+ const threaded = comments.filter((comment) => (comment.reply_count ?? 0) > 0);
129
+ const toFetch = threaded.slice(0, exports.MAX_REPLY_FETCHES);
130
+ if (threaded.length > toFetch.length) {
131
+ console.error(`Skipping replies of ${threaded.length - toFetch.length} comment thread(s) - only the ${exports.MAX_REPLY_FETCHES} newest threads are loaded to stay within the API budget.`);
132
+ }
133
+ const replies = new Map();
134
+ let next = 0;
135
+ const worker = async () => {
136
+ while (next < toFetch.length) {
137
+ const comment = toFetch[next++];
138
+ replies.set(String(comment.id), await fetchCommentReplies(String(comment.id)));
139
+ }
140
+ };
141
+ await Promise.all(Array.from({ length: Math.min(REPLY_FETCH_CONCURRENCY, toFetch.length) }, worker));
142
+ return replies;
143
+ }
@@ -1,7 +1,7 @@
1
1
  import Fuse from 'fuse.js';
2
2
  /**
3
3
  * Checks if a string looks like a valid ClickUp task ID
4
- * Valid task IDs are 6-9 characters long and contain only alphanumeric characters
4
+ * Valid task IDs are 6-16 characters long and contain only alphanumeric characters
5
5
  */
6
6
  export declare function isTaskId(str: string): boolean;
7
7
  /**
@@ -23,11 +23,11 @@ const fuse_js_1 = __importDefault(require("fuse.js"));
23
23
  const GLOBAL_REFRESH_INTERVAL = 60000; // 60 seconds - that is the rate limit time frame
24
24
  /**
25
25
  * Checks if a string looks like a valid ClickUp task ID
26
- * Valid task IDs are 6-9 characters long and contain only alphanumeric characters
26
+ * Valid task IDs are 6-16 characters long and contain only alphanumeric characters
27
27
  */
28
28
  function isTaskId(str) {
29
- // Task IDs are 6-9 characters long and contain only alphanumeric characters
30
- return /^[a-z0-9]{6,9}$/i.test(str);
29
+ // Task IDs are 6-16 characters long and contain only alphanumeric characters
30
+ return /^[a-z0-9]{6,16}$/i.test(str);
31
31
  }
32
32
  // Cache for current user info to avoid repeated API calls and race conditions
33
33
  let cachedUserPromise = null;
@@ -1 +1 @@
1
- {"version":3,"file":"task-tools.d.ts","sourceRoot":"","sources":["../../src/tools/task-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGpE,OAAO,EAAE,YAAY,EAAyC,MAAM,iBAAiB,CAAC;AAOtF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QA2DrE;AA+MD;;GAEG;AACH,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,GAAG,EAAE,WAAW,CAAC,EAAE,GAAG,EAAE,EAAE,YAAY,GAAE,OAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAmH/H"}
1
+ {"version":3,"file":"task-tools.d.ts","sourceRoot":"","sources":["../../src/tools/task-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGpE,OAAO,EAAE,YAAY,EAAyC,MAAM,iBAAiB,CAAC;AAQtF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QA2DrE;AA2OD;;GAEG;AACH,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,GAAG,EAAE,WAAW,CAAC,EAAE,GAAG,EAAE,EAAE,YAAY,GAAE,OAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAgJ/H"}
@@ -7,6 +7,7 @@ const clickup_text_1 = require("../clickup-text");
7
7
  const config_1 = require("../shared/config");
8
8
  const utils_1 = require("../shared/utils");
9
9
  const image_processing_1 = require("../shared/image-processing");
10
+ const comments_1 = require("../shared/comments");
10
11
  // Read-specific utility functions
11
12
  function registerTaskToolsRead(server, userData) {
12
13
  server.tool("getTaskById", [
@@ -17,11 +18,11 @@ function registerTaskToolsRead(server, userData) {
17
18
  id: zod_1.z
18
19
  .string()
19
20
  .min(6)
20
- .max(9)
21
+ .max(16)
21
22
  .refine(val => (0, utils_1.isTaskId)(val), {
22
- message: "Task ID must be 6-9 alphanumeric characters only"
23
+ message: "Task ID must be 6-16 alphanumeric characters only"
23
24
  })
24
- .describe(`The 6-9 character ID of the task to get without a prefix like "#", "CU-" or "https://app.clickup.com/t/"`),
25
+ .describe(`The 6-16 character ID of the task to get without a prefix like "#", "CU-" or "https://app.clickup.com/t/"`),
25
26
  }, {
26
27
  readOnlyHint: true
27
28
  }, async ({ id }) => {
@@ -98,26 +99,52 @@ async function loadTaskContent(taskId) {
98
99
  return [taskMetadata, ...content];
99
100
  }
100
101
  async function loadTaskComments(id) {
101
- const response = await fetch(`https://api.clickup.com/api/v2/task/${id}/comment?start_date=0`, // Ensure all comments are fetched
102
- { headers: { Authorization: config_1.CONFIG.apiKey } });
103
- if (!response.ok) {
104
- console.error(`Error fetching comments for task ${id}: ${response.status} ${response.statusText}`);
105
- return [];
102
+ let comments;
103
+ try {
104
+ // The comment list only returns 25 top-level comments per page - page through
105
+ // all of them (the previous `?start_date=0` was silently ignored by ClickUp).
106
+ comments = await (0, comments_1.fetchAllTopLevelComments)(id);
106
107
  }
107
- const commentsData = await response.json();
108
- if (!commentsData.comments || !Array.isArray(commentsData.comments)) {
109
- console.error(`Unexpected comment data structure for task ${id}`);
108
+ catch (error) {
109
+ console.error(`Error fetching comments for task ${id}:`, error);
110
110
  return [];
111
111
  }
112
- const commentEvents = await Promise.all(commentsData.comments.map(async (comment) => {
112
+ // Replies live behind their own endpoint and are missing from the comment
113
+ // list. Only threads (reply_count > 0) cost extra requests, bounded in count
114
+ // and concurrency to protect the API budget.
115
+ const repliesByComment = await (0, comments_1.fetchRepliesByComment)(comments);
116
+ const formatUser = (user) => `${user?.username ?? "unknown"} (user_id: ${user?.id ?? "unknown"})`;
117
+ const commentEvents = await Promise.all(comments.map(async (comment) => {
118
+ // The comment_id makes the comment addressable for editComment and for
119
+ // threaded replies via addComment's parent_comment_id.
113
120
  const headerBlock = {
114
121
  type: "text",
115
- text: `Comment by ${comment.user.username} on ${timestampToIso(comment.date)}:`,
122
+ text: `Comment by ${formatUser(comment.user)} on ${timestampToIso(comment.date)} (comment_id: ${comment.id}):`,
116
123
  };
117
- const commentBodyBlocks = await (0, clickup_text_1.convertClickUpTextItemsToToolCallResult)(comment.comment);
124
+ const commentBodyBlocks = await (0, clickup_text_1.convertClickUpTextItemsToToolCallResult)(comment.comment ?? []);
125
+ const contentBlocks = [headerBlock, ...commentBodyBlocks];
126
+ const replyCount = comment.reply_count ?? 0;
127
+ if (replyCount > 0) {
128
+ const replies = repliesByComment.get(String(comment.id)) ?? [];
129
+ for (const reply of replies) {
130
+ contentBlocks.push({
131
+ type: "text",
132
+ text: `↳ Reply by ${formatUser(reply.user)} on ${timestampToIso(reply.date)} (comment_id: ${reply.id}):`,
133
+ });
134
+ contentBlocks.push(...await (0, clickup_text_1.convertClickUpTextItemsToToolCallResult)(reply.comment ?? []));
135
+ }
136
+ if (replies.length === 0) {
137
+ // The thread exists (reply_count says so) but its replies were skipped
138
+ // over the budget cap or failed to load - never pretend it is empty.
139
+ contentBlocks.push({
140
+ type: "text",
141
+ text: `↳ This comment has ${replyCount} repl${replyCount === 1 ? "y" : "ies"} that could not be loaded.`,
142
+ });
143
+ }
144
+ }
118
145
  return {
119
146
  date: comment.date, // String timestamp from ClickUp for sorting
120
- contentBlocks: [headerBlock, ...commentBodyBlocks],
147
+ contentBlocks,
121
148
  };
122
149
  }));
123
150
  return commentEvents;
@@ -294,6 +321,32 @@ async function generateTaskMetadata(task, timeEntries, isDetailView = false) {
294
321
  if (task.subtasks && task.subtasks.length > 0) {
295
322
  metadataLines.push(`child_task_ids: ${task.subtasks.map((st) => st.id).join(', ')}`);
296
323
  }
324
+ // Add dependencies if they exist. The API returns a single flat `dependencies`
325
+ // array for both directions; which side of the pair this task sits on decides
326
+ // whether it is waiting on the other task or blocking it.
327
+ if (task.dependencies && task.dependencies.length > 0) {
328
+ const waitingOn = task.dependencies
329
+ .filter((dep) => dep.task_id === task.id)
330
+ .map((dep) => dep.depends_on);
331
+ const blocking = task.dependencies
332
+ .filter((dep) => dep.depends_on === task.id)
333
+ .map((dep) => dep.task_id);
334
+ if (waitingOn.length > 0) {
335
+ metadataLines.push(`waiting_on: ${waitingOn.join(', ')}`);
336
+ }
337
+ if (blocking.length > 0) {
338
+ metadataLines.push(`blocking: ${blocking.join(', ')}`);
339
+ }
340
+ }
341
+ // Add linked (related, non-blocking) tasks if they exist
342
+ if (task.linked_tasks && task.linked_tasks.length > 0) {
343
+ const linked = task.linked_tasks
344
+ .map((link) => (link.task_id === task.id ? link.link_id : link.task_id))
345
+ .filter((id) => id);
346
+ if (linked.length > 0) {
347
+ metadataLines.push(`linked_tasks: ${linked.join(', ')}`);
348
+ }
349
+ }
297
350
  // Add archived status if true
298
351
  if (task.archived) {
299
352
  metadataLines.push(`archived: true`);
@@ -1 +1 @@
1
- {"version":3,"file":"task-write-tools.d.ts","sourceRoot":"","sources":["../../src/tools/task-write-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA+IpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QAumBtE"}
1
+ {"version":3,"file":"task-write-tools.d.ts","sourceRoot":"","sources":["../../src/tools/task-write-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAsJpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QAooBtE"}
@@ -6,6 +6,7 @@ const config_1 = require("../shared/config");
6
6
  const utils_1 = require("../shared/utils");
7
7
  const clickup_text_1 = require("../clickup-text");
8
8
  const attachments_1 = require("../shared/attachments");
9
+ const comments_1 = require("../shared/comments");
9
10
  /**
10
11
  * Shared wording for the image support of every markdown field in this file.
11
12
  * Kept in one place so the tools stay consistent about what a client may pass.
@@ -105,7 +106,7 @@ const taskTagsSchema = zod_1.z.array(zod_1.z.string()).optional().describe("Opti
105
106
  function registerTaskToolsWrite(server, userData) {
106
107
  server.tool("addComment", (() => {
107
108
  const descriptionBase = [
108
- "Adds a comment to a specific task.",
109
+ "Adds a comment to a specific task, or a threaded reply to an existing comment when `parent_comment_id` is set.",
109
110
  "LINKING BEST PRACTICES:",
110
111
  "- Always reference related tasks using ClickUp URLs (https://app.clickup.com/t/TASK_ID)",
111
112
  "- Task URLs become live task references (chip with task name and status), so write them bare - any custom link text on a task URL is replaced by the live task name",
@@ -123,14 +124,28 @@ function registerTaskToolsWrite(server, userData) {
123
124
  }
124
125
  return descriptionBase.join("\n");
125
126
  })(), {
126
- task_id: zod_1.z.string().min(6).max(9).describe("The 6-9 character task ID to comment on"),
127
+ task_id: zod_1.z.string().min(6).max(16).describe("The 6-16 character task ID to comment on"),
127
128
  comment: zod_1.z.string().min(1).describe("The comment text to add to the task"),
129
+ parent_comment_id: zod_1.z.string().min(1).optional().describe("Optional: reply inside an existing comment thread instead of posting a new top-level comment. Pass the comment_id of a TOP-LEVEL comment as returned by getTaskById or addComment - ClickUp threads are one level deep, so a reply cannot have replies of its own."),
128
130
  }, {
129
131
  readOnlyHint: false,
130
132
  destructiveHint: false,
131
133
  idempotentHint: false,
132
- }, async ({ task_id, comment }) => {
134
+ }, async ({ task_id, comment, parent_comment_id }) => {
133
135
  try {
136
+ // The reply endpoint anchors the reply to the parent comment's task and
137
+ // ignores task_id, while images below are uploaded to task_id - so the
138
+ // parent must verifiably be a top-level comment of THIS task before
139
+ // anything is written. This also rejects reply ids (nested replies are
140
+ // not supported by ClickUp's one-level threads).
141
+ if (parent_comment_id) {
142
+ const parent = await (0, comments_1.findTopLevelComment)(task_id, parent_comment_id);
143
+ if (!parent) {
144
+ throw new Error(`Comment ${parent_comment_id} is not a top-level comment of task ${task_id}, so no reply was posted. ` +
145
+ `parent_comment_id must be the comment_id of a top-level comment of this task as returned by getTaskById - ` +
146
+ `a reply's id or a comment of another task cannot be used.`);
147
+ }
148
+ }
134
149
  // Resolve and upload referenced images first - the fragments need the
135
150
  // attachment objects from the upload response, a bare URL renders as an
136
151
  // empty tile. Any image problem aborts BEFORE the comment is posted, so
@@ -144,7 +159,12 @@ function registerTaskToolsWrite(server, userData) {
144
159
  comment: commentBlocks,
145
160
  notify_all: true
146
161
  };
147
- const response = await fetch(`https://api.clickup.com/api/v2/task/${task_id}/comment`, {
162
+ // A threaded reply goes to the parent comment's reply endpoint; the body
163
+ // is identical to a top-level comment.
164
+ const url = parent_comment_id
165
+ ? `https://api.clickup.com/api/v2/comment/${parent_comment_id}/reply`
166
+ : `https://api.clickup.com/api/v2/task/${task_id}/comment`;
167
+ const response = await fetch(url, {
148
168
  method: 'POST',
149
169
  headers: {
150
170
  Authorization: config_1.CONFIG.apiKey,
@@ -162,8 +182,12 @@ function registerTaskToolsWrite(server, userData) {
162
182
  {
163
183
  type: "text",
164
184
  text: [
165
- `Comment added successfully!`,
185
+ parent_comment_id ? `Reply added successfully!` : `Comment added successfully!`,
166
186
  `comment_id: ${commentData.id || 'N/A'}`,
187
+ ...(parent_comment_id ? [
188
+ `parent_comment_id: ${parent_comment_id}`,
189
+ `note: ClickUp threads are one level deep - a reply's comment_id cannot be used as parent_comment_id or with editComment.`,
190
+ ] : []),
167
191
  `task_id: ${task_id}`,
168
192
  `comment: ${summarizeMarkdownForEcho(comment)}`,
169
193
  `date: ${timestampToIso(commentData.date || Date.now())}`,
@@ -203,8 +227,8 @@ function registerTaskToolsWrite(server, userData) {
203
227
  }
204
228
  return descriptionBase.join("\n");
205
229
  })(), {
206
- task_id: zod_1.z.string().min(6).max(9).describe("The 6-9 character ID of the task the comment belongs to - needed to locate the comment and to upload images"),
207
- comment_id: zod_1.z.string().min(1).describe("The ID of the comment to edit, as returned by addComment or getTaskById"),
230
+ task_id: zod_1.z.string().min(6).max(16).describe("The 6-16 character ID of the task the comment belongs to - needed to locate the comment and to upload images"),
231
+ comment_id: zod_1.z.string().min(1).describe("The ID of the comment to edit, as returned by addComment or getTaskById. Only top-level comments can be edited - replies inside a thread cannot."),
208
232
  comment: zod_1.z.string().min(1).describe("The new comment text, replacing the previous text completely"),
209
233
  }, {
210
234
  readOnlyHint: false,
@@ -287,7 +311,7 @@ function registerTaskToolsWrite(server, userData) {
287
311
  }
288
312
  return descriptionBase.join("\n");
289
313
  })(), {
290
- task_id: zod_1.z.string().min(6).max(9).describe("The 6-9 character task ID to update"),
314
+ task_id: zod_1.z.string().min(6).max(16).describe("The 6-16 character task ID to update"),
291
315
  name: taskNameSchema.optional(),
292
316
  append_description: zod_1.z.string().optional().describe("Optional markdown content to APPEND to existing task description (preserves existing content for safety)"),
293
317
  status: zod_1.z.string().optional().describe("Optional new status name - use getListInfo to see valid options"),
@@ -636,26 +660,6 @@ function formatTimeEstimate(hours) {
636
660
  const displayMinutes = Math.round((hours - displayHours) * 60);
637
661
  return displayHours > 0 ? `${displayHours}h ${displayMinutes}m` : `${displayMinutes}m`;
638
662
  }
639
- /**
640
- * Never page further back than this. A generous edit window would otherwise walk
641
- * the entire comment history of a busy ticket and eat the 100 calls/minute budget.
642
- */
643
- const MAX_COMMENT_PAGES = 10;
644
- /** One page of task comments, newest first, 25 per page */
645
- async function fetchCommentPage(taskId, cursor) {
646
- // Note there is no `start_date` parameter - passing one is silently ignored.
647
- // Older pages are reached with `start` + `start_id` of the previous page's last entry.
648
- const query = cursor
649
- ? `?${new URLSearchParams({ start: cursor.start, start_id: cursor.startId })}`
650
- : "";
651
- const response = await fetch(`https://api.clickup.com/api/v2/task/${taskId}/comment${query}`, { headers: { Authorization: config_1.CONFIG.apiKey } });
652
- if (!response.ok) {
653
- const errorData = await response.json().catch(() => ({}));
654
- throw new Error(`Error loading comments of task ${taskId}: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
655
- }
656
- const data = await response.json();
657
- return Array.isArray(data.comments) ? data.comments : [];
658
- }
659
663
  /**
660
664
  * Load a single comment of a task.
661
665
  *
@@ -677,8 +681,8 @@ async function findTaskComment(taskId, commentId) {
677
681
  let checked = 0;
678
682
  let pages = 0;
679
683
  let sawThreadedReplies = false;
680
- while (pages < MAX_COMMENT_PAGES) {
681
- const page = await fetchCommentPage(taskId, cursor);
684
+ while (pages < comments_1.MAX_COMMENT_PAGES) {
685
+ const page = await (0, comments_1.fetchCommentPage)(taskId, cursor);
682
686
  pages++;
683
687
  if (page.length === 0) {
684
688
  break;
@@ -780,7 +784,16 @@ async function updateTaskDependencies(taskId, taskData, dependencies) {
780
784
  // Get current dependencies
781
785
  const currentBlocking = taskData.blocking?.map((dep) => dep.id) || [];
782
786
  const currentWaitingOn = taskData.waiting_on?.map((dep) => dep.id) || [];
783
- const currentLinked = taskData.linked_tasks?.map((task) => task.id) || [];
787
+ // `linked_tasks` entries are link records, not tasks: each has `task_id` and
788
+ // `link_id` (the two ends of the link) and no `id` at all. Mapping `.id` here
789
+ // produced `[undefined, ...]`, so every existing link looked like it was no
790
+ // longer requested, and the removal loop then issued
791
+ // `DELETE /task/{id}/link/undefined`, which the API rejects. The result was a
792
+ // `linked_tasks` field documented as "replace" that could never remove
793
+ // anything. Take whichever end of the record is not the task being updated.
794
+ const currentLinked = taskData.linked_tasks
795
+ ?.map((link) => (link.task_id === taskData.id ? link.link_id : link.task_id))
796
+ .filter((id) => Boolean(id)) || [];
784
797
  // Helper function to make dependency API calls
785
798
  async function modifyDependency(operation, type, fromTaskId, toTaskId, dependsOn) {
786
799
  try {
@@ -52,7 +52,7 @@ function formatEntryTime(timestamp) {
52
52
  }
53
53
  function registerTimeToolsRead(server) {
54
54
  server.tool("getTimeEntries", "Gets time entries for a specific task or all user's time entries. Returns last 30 days by default if no dates specified.", {
55
- task_id: zod_1.z.string().min(6).max(9).optional().describe("Optional 6-9 character task ID to filter entries. If not provided, returns all user's time entries."),
55
+ task_id: zod_1.z.string().min(6).max(16).optional().describe("Optional 6-16 character task ID to filter entries. If not provided, returns all user's time entries."),
56
56
  start_date: zod_1.z.string().optional().describe("Optional start date filter as ISO date string (e.g., '2024-10-06T00:00:00+02:00'). Defaults to 30 days ago."),
57
57
  end_date: zod_1.z.string().optional().describe("Optional end date filter as ISO date string (e.g., '2024-10-06T23:59:59+02:00'). Defaults to current date."),
58
58
  list_id: zod_1.z.string().optional().describe("Optional single list ID to filter time entries by a specific list"),
@@ -267,7 +267,7 @@ function registerTimeToolsWrite(server) {
267
267
  "IMPORTANT: Before booking time, check the task's status - booking time on tasks in 'backlog', 'closed', or similar inactive states usually doesn't make sense.",
268
268
  "Suggest moving the task to an active status like 'in progress' first."
269
269
  ].join("\n"), {
270
- task_id: zod_1.z.string().min(6).max(9).describe("The 6-9 character task ID to book time against"),
270
+ task_id: zod_1.z.string().min(6).max(16).describe("The 6-16 character task ID to book time against"),
271
271
  hours: zod_1.z.number().min(0.01).max(24).describe("Hours to book (decimal format, e.g., 0.25 = 15min, 1.5 = 1h 30min)"),
272
272
  description: zod_1.z.string().optional().describe("Optional description for the time entry"),
273
273
  start_time: zod_1.z.string().optional().describe("Optional start time as ISO date string (e.g., '2024-10-06T09:00:00+02:00', defaults to current time)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hauptsache.net/clickup-mcp",
3
- "version": "1.7.2",
3
+ "version": "1.8.0",
4
4
  "description": "Search, create, and retrieve tasks, add comments, and track time through natural language commands.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",