@hauptsache.net/clickup-mcp 1.7.3 → 1.9.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 +4 -4
- package/dist/clickup-text.js +16 -0
- package/dist/shared/comments.d.ts +65 -0
- package/dist/shared/comments.d.ts.map +1 -0
- package/dist/shared/comments.js +143 -0
- package/dist/tools/task-tools.d.ts.map +1 -1
- package/dist/tools/task-tools.js +65 -12
- package/dist/tools/task-write-tools.d.ts.map +1 -1
- package/dist/tools/task-write-tools.js +78 -40
- package/package.json +1 -1
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
|
-
-
|
|
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,10 +186,10 @@ 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
|
|
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
|
-
| `updateTask` | ❌ | ❌ | ✅ | Update tasks (status, priority, assignees, etc.)
|
|
192
|
+
| `updateTask` | ❌ | ❌ | ✅ | Update tasks (status, priority, assignees, etc.); descriptions can be appended to or replaced entirely |
|
|
193
193
|
| `createTask` | ❌ | ❌ | ✅ | Create new tasks with full markdown support |
|
|
194
194
|
| `searchTasks` | ✅ | ✅ | ✅ | Find tasks by content, keywords, assignees, or project context |
|
|
195
195
|
| `searchSpaces` | ❌ | ✅ | ✅ | Browse workspace structure, project organization, and documents |
|
package/dist/clickup-text.js
CHANGED
|
@@ -595,6 +595,14 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0, attachmentsByS
|
|
|
595
595
|
for (let i = 0; i < nodes.length; i++) {
|
|
596
596
|
const node = nodes[i];
|
|
597
597
|
const currentAttrs = { ...inheritedAttrs };
|
|
598
|
+
// ClickUp has no paragraph margins: two paragraphs separated by a single '\n'
|
|
599
|
+
// render as consecutive lines. The UI stores a paragraph break as an extra
|
|
600
|
+
// empty '\n' fragment (what a user gets by pressing Enter twice), so emit one
|
|
601
|
+
// between adjacent flow blocks. Headings, code blocks and blockquotes bring
|
|
602
|
+
// their own spacing - a blank line next to them would double the gap.
|
|
603
|
+
if (i > 0 && needsBlankLineBetween(nodes[i - 1], node)) {
|
|
604
|
+
blocks.push({ text: '\n', attributes: {} });
|
|
605
|
+
}
|
|
598
606
|
switch (node.type) {
|
|
599
607
|
case 'heading':
|
|
600
608
|
// Process heading content with inline formatting
|
|
@@ -689,6 +697,14 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0, attachmentsByS
|
|
|
689
697
|
}
|
|
690
698
|
}
|
|
691
699
|
}
|
|
700
|
+
/**
|
|
701
|
+
* Block types that render without their own vertical margin in ClickUp comments.
|
|
702
|
+
* Two of them in a row need an explicit empty line to read as separate blocks.
|
|
703
|
+
*/
|
|
704
|
+
const FLOW_BLOCK_TYPES = new Set(['paragraph', 'list']);
|
|
705
|
+
function needsBlankLineBetween(prev, next) {
|
|
706
|
+
return FLOW_BLOCK_TYPES.has(prev.type) && FLOW_BLOCK_TYPES.has(next.type);
|
|
707
|
+
}
|
|
692
708
|
/**
|
|
693
709
|
* Emit a (possibly multi-line) code block the way ClickUp's Quill-based format
|
|
694
710
|
* expects it: block attributes apply per line, so EVERY line needs its own '\n'
|
|
@@ -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 +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;
|
|
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"}
|
package/dist/tools/task-tools.js
CHANGED
|
@@ -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", [
|
|
@@ -98,26 +99,52 @@ async function loadTaskContent(taskId) {
|
|
|
98
99
|
return [taskMetadata, ...content];
|
|
99
100
|
}
|
|
100
101
|
async function loadTaskComments(id) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
|
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,QA6pBtE"}
|
|
@@ -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",
|
|
@@ -125,12 +126,26 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
125
126
|
})(), {
|
|
126
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
|
-
|
|
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())}`,
|
|
@@ -204,7 +228,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
204
228
|
return descriptionBase.join("\n");
|
|
205
229
|
})(), {
|
|
206
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"),
|
|
207
|
-
comment_id: zod_1.z.string().min(1).describe("The ID of the comment to edit, as returned by addComment or getTaskById"),
|
|
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,
|
|
@@ -274,11 +298,12 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
274
298
|
"Updates various aspects of an existing task including dependencies and relationships.",
|
|
275
299
|
"ALWAYS include the task URL (https://app.clickup.com/t/TASK_ID) when updating or referencing tasks.",
|
|
276
300
|
"Use getListInfo first to see valid status options.",
|
|
277
|
-
"
|
|
301
|
+
"DESCRIPTIONS: `append_description` adds a dated section below the existing text; `description` REPLACES the whole text (use it to restructure, shorten or correct a description). Pass only one of them.",
|
|
302
|
+
"Before replacing, read the current description via getTaskById and carry over everything that is still needed - ClickUp keeps a description history, so a bad replacement can be restored in the UI (edits within the same minute merge into one version), but the MCP cannot undo it.",
|
|
278
303
|
"STATUS UPDATES: Use the `addComment` tool for progress reports, work logs, and status updates rather than the task description.",
|
|
279
304
|
IMAGE_SUPPORT_HINT,
|
|
280
305
|
"Task descriptions should contain requirements, specifications, and core task information.",
|
|
281
|
-
"LINKING IN DESCRIPTIONS:
|
|
306
|
+
"LINKING IN DESCRIPTIONS: Include links to related tasks, lists, or external resources (format: https://app.clickup.com/t/TASK_ID).",
|
|
282
307
|
"IMPORTANT: When updating tasks (especially when booking time or adding progress), ensure the status makes sense for the work being done - tasks in 'backlog' or 'closed' states usually shouldn't have active work.",
|
|
283
308
|
"Suggest appropriate status transitions and always provide the clickable task URL in responses."
|
|
284
309
|
];
|
|
@@ -289,7 +314,8 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
289
314
|
})(), {
|
|
290
315
|
task_id: zod_1.z.string().min(6).max(16).describe("The 6-16 character task ID to update"),
|
|
291
316
|
name: taskNameSchema.optional(),
|
|
292
|
-
|
|
317
|
+
description: zod_1.z.string().optional().describe("Optional markdown that REPLACES the entire task description. Read the current description first (getTaskById) and repeat everything worth keeping - nothing is merged. Cannot be combined with append_description."),
|
|
318
|
+
append_description: zod_1.z.string().optional().describe("Optional markdown content to APPEND below the existing task description as a dated `**Edit (YYYY-MM-DD):**` section (existing content is preserved). Cannot be combined with description."),
|
|
293
319
|
status: zod_1.z.string().optional().describe("Optional new status name - use getListInfo to see valid options"),
|
|
294
320
|
priority: taskPrioritySchema,
|
|
295
321
|
due_date: taskDueDateSchema,
|
|
@@ -306,8 +332,16 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
306
332
|
destructiveHint: true,
|
|
307
333
|
idempotentHint: false,
|
|
308
334
|
openWorldHint: true
|
|
309
|
-
}, async ({ task_id, name, append_description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees, blocking, waiting_on, linked_tasks }) => {
|
|
335
|
+
}, async ({ task_id, name, description, append_description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees, blocking, waiting_on, linked_tasks }) => {
|
|
310
336
|
try {
|
|
337
|
+
if (description !== undefined && append_description !== undefined) {
|
|
338
|
+
return {
|
|
339
|
+
content: [{
|
|
340
|
+
type: "text",
|
|
341
|
+
text: "Error: `description` (replace) and `append_description` (append) are mutually exclusive - pass only one of them. The task was NOT updated."
|
|
342
|
+
}],
|
|
343
|
+
};
|
|
344
|
+
}
|
|
311
345
|
const userData = await (0, utils_1.getCurrentUser)();
|
|
312
346
|
// Get task details including current markdown description
|
|
313
347
|
const taskResponse = await fetch(`https://api.clickup.com/api/v2/task/${task_id}?include_markdown_description=true`, {
|
|
@@ -320,15 +354,19 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
320
354
|
// Resolve and upload description images FIRST - an image problem must
|
|
321
355
|
// abort before dependencies, tags or the task itself are touched, so the
|
|
322
356
|
// caller can fix the markdown and retry the whole call cleanly.
|
|
323
|
-
|
|
357
|
+
// `description` replaces, `append_description` appends; both go through
|
|
358
|
+
// the same image pipeline. An empty `description` is a valid request to
|
|
359
|
+
// clear the description, so check for undefined rather than truthiness.
|
|
360
|
+
const descriptionInput = description !== undefined ? description : append_description;
|
|
361
|
+
let preparedDescription;
|
|
324
362
|
let uploadedImages = [];
|
|
325
|
-
if (
|
|
363
|
+
if (descriptionInput !== undefined) {
|
|
326
364
|
const abortNotice = "the task was NOT updated";
|
|
327
|
-
const prepared = await resolveImagesOrAbort(
|
|
365
|
+
const prepared = await resolveImagesOrAbort(descriptionInput, abortNotice);
|
|
328
366
|
uploadedImages = await uploadImagesOrAbort(task_id, prepared.images, abortNotice);
|
|
329
367
|
// Descriptions render plain markdown, so no image fragments are involved
|
|
330
368
|
// here - the local paths are simply swapped for the CDN URLs.
|
|
331
|
-
|
|
369
|
+
preparedDescription = (0, clickup_text_1.rewriteMarkdownImageUrls)(prepared.markdown, (0, attachments_1.toAttachmentMap)(uploadedImages));
|
|
332
370
|
}
|
|
333
371
|
// Handle dependencies separately since they need individual API calls
|
|
334
372
|
let dependencyUpdateResults = [];
|
|
@@ -378,13 +416,17 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
378
416
|
}
|
|
379
417
|
}
|
|
380
418
|
}
|
|
381
|
-
//
|
|
419
|
+
// Build the description to write: a full replacement, or the existing
|
|
420
|
+
// text plus a dated append section.
|
|
382
421
|
let finalDescription;
|
|
383
|
-
if (
|
|
422
|
+
if (description !== undefined) {
|
|
423
|
+
finalDescription = preparedDescription;
|
|
424
|
+
}
|
|
425
|
+
else if (preparedDescription !== undefined) {
|
|
384
426
|
const currentDescription = taskData.markdown_description || "";
|
|
385
427
|
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
|
386
428
|
const separator = currentDescription.trim() ? "\n\n---\n" : "";
|
|
387
|
-
finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${
|
|
429
|
+
finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${preparedDescription}`;
|
|
388
430
|
}
|
|
389
431
|
// Build update body without tags (they're handled separately)
|
|
390
432
|
const updateBody = buildTaskRequestBody({
|
|
@@ -436,8 +478,15 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
436
478
|
}
|
|
437
479
|
}
|
|
438
480
|
const responseLines = formatTaskResponse(updatedTask, 'updated', {
|
|
439
|
-
name, append_description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees, blocking, waiting_on, linked_tasks
|
|
481
|
+
name, description, append_description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees, blocking, waiting_on, linked_tasks
|
|
440
482
|
}, userData);
|
|
483
|
+
if (description !== undefined) {
|
|
484
|
+
const previousLength = (taskData.markdown_description || "").length;
|
|
485
|
+
responseLines.push(`description: replaced (previous ${previousLength} chars -> ${finalDescription?.length ?? 0} chars; the old version stays restorable via ClickUp's description history)`);
|
|
486
|
+
}
|
|
487
|
+
else if (append_description !== undefined) {
|
|
488
|
+
responseLines.push('description: appended as dated edit section');
|
|
489
|
+
}
|
|
441
490
|
// Add dependency update results if any
|
|
442
491
|
if (dependencyUpdateResults.length > 0) {
|
|
443
492
|
responseLines.push('dependency_warnings: ' + dependencyUpdateResults.join('; '));
|
|
@@ -636,26 +685,6 @@ function formatTimeEstimate(hours) {
|
|
|
636
685
|
const displayMinutes = Math.round((hours - displayHours) * 60);
|
|
637
686
|
return displayHours > 0 ? `${displayHours}h ${displayMinutes}m` : `${displayMinutes}m`;
|
|
638
687
|
}
|
|
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
688
|
/**
|
|
660
689
|
* Load a single comment of a task.
|
|
661
690
|
*
|
|
@@ -677,8 +706,8 @@ async function findTaskComment(taskId, commentId) {
|
|
|
677
706
|
let checked = 0;
|
|
678
707
|
let pages = 0;
|
|
679
708
|
let sawThreadedReplies = false;
|
|
680
|
-
while (pages < MAX_COMMENT_PAGES) {
|
|
681
|
-
const page = await fetchCommentPage(taskId, cursor);
|
|
709
|
+
while (pages < comments_1.MAX_COMMENT_PAGES) {
|
|
710
|
+
const page = await (0, comments_1.fetchCommentPage)(taskId, cursor);
|
|
682
711
|
pages++;
|
|
683
712
|
if (page.length === 0) {
|
|
684
713
|
break;
|
|
@@ -780,7 +809,16 @@ async function updateTaskDependencies(taskId, taskData, dependencies) {
|
|
|
780
809
|
// Get current dependencies
|
|
781
810
|
const currentBlocking = taskData.blocking?.map((dep) => dep.id) || [];
|
|
782
811
|
const currentWaitingOn = taskData.waiting_on?.map((dep) => dep.id) || [];
|
|
783
|
-
|
|
812
|
+
// `linked_tasks` entries are link records, not tasks: each has `task_id` and
|
|
813
|
+
// `link_id` (the two ends of the link) and no `id` at all. Mapping `.id` here
|
|
814
|
+
// produced `[undefined, ...]`, so every existing link looked like it was no
|
|
815
|
+
// longer requested, and the removal loop then issued
|
|
816
|
+
// `DELETE /task/{id}/link/undefined`, which the API rejects. The result was a
|
|
817
|
+
// `linked_tasks` field documented as "replace" that could never remove
|
|
818
|
+
// anything. Take whichever end of the record is not the task being updated.
|
|
819
|
+
const currentLinked = taskData.linked_tasks
|
|
820
|
+
?.map((link) => (link.task_id === taskData.id ? link.link_id : link.task_id))
|
|
821
|
+
.filter((id) => Boolean(id)) || [];
|
|
784
822
|
// Helper function to make dependency API calls
|
|
785
823
|
async function modifyDependency(operation, type, fromTaskId, toTaskId, dependsOn) {
|
|
786
824
|
try {
|
package/package.json
CHANGED