@sebastienheyd/clickup-mcp 1.9.0 → 1.10.2
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 +26 -4
- package/dist/clickup-text.d.ts +1 -0
- package/dist/clickup-text.d.ts.map +1 -1
- package/dist/clickup-text.js +175 -28
- package/dist/shared/attachments.d.ts.map +1 -1
- package/dist/shared/attachments.js +9 -3
- package/dist/shared/comments.d.ts +98 -0
- package/dist/shared/comments.d.ts.map +1 -0
- package/dist/shared/comments.js +158 -0
- package/dist/shared/config.d.ts.map +1 -1
- package/dist/shared/config.js +31 -10
- package/dist/shared/dates.d.ts +25 -0
- package/dist/shared/dates.d.ts.map +1 -0
- package/dist/shared/dates.js +42 -0
- package/dist/shared/utils.d.ts +1 -1
- package/dist/shared/utils.js +4 -4
- package/dist/tools/list-tools.js +9 -9
- package/dist/tools/task-tools.d.ts.map +1 -1
- package/dist/tools/task-tools.js +82 -18
- package/dist/tools/task-write-tools.d.ts.map +1 -1
- package/dist/tools/task-write-tools.js +306 -116
- package/dist/tools/time-tools.d.ts.map +1 -1
- package/dist/tools/time-tools.js +17 -7
- package/package.json +2 -2
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_REPLY_FETCHES = exports.COMMENTS_PER_PAGE = 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
|
+
exports.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. Never throws: a failing page (typically a 429 close to
|
|
38
|
+
* the API limit on exactly the busy tasks that need paging) keeps the comments
|
|
39
|
+
* already loaded and is reported through `incomplete`, so the caller can show a
|
|
40
|
+
* partial history with a warning instead of silently showing none.
|
|
41
|
+
*/
|
|
42
|
+
async function fetchAllTopLevelComments(taskId) {
|
|
43
|
+
const comments = [];
|
|
44
|
+
let cursor;
|
|
45
|
+
let pages = 0;
|
|
46
|
+
let lastPageWasFull = false;
|
|
47
|
+
while (pages < exports.MAX_COMMENT_PAGES) {
|
|
48
|
+
let page;
|
|
49
|
+
try {
|
|
50
|
+
page = await fetchCommentPage(taskId, cursor);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
54
|
+
console.error(`Error fetching comments of task ${taskId} (page ${pages + 1}, ${comments.length} loaded so far): ${detail}`);
|
|
55
|
+
return { comments, incomplete: { reason: "error", detail } };
|
|
56
|
+
}
|
|
57
|
+
pages++;
|
|
58
|
+
if (page.length === 0) {
|
|
59
|
+
lastPageWasFull = false;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
comments.push(...page);
|
|
63
|
+
lastPageWasFull = page.length >= exports.COMMENTS_PER_PAGE;
|
|
64
|
+
if (!lastPageWasFull) {
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
const oldest = page[page.length - 1];
|
|
68
|
+
cursor = { start: String(oldest.date), startId: String(oldest.id) };
|
|
69
|
+
}
|
|
70
|
+
if (lastPageWasFull && pages >= exports.MAX_COMMENT_PAGES) {
|
|
71
|
+
const detail = `the task has more than ${comments.length} top-level comments; loading stops after ${exports.MAX_COMMENT_PAGES} pages to protect the API budget`;
|
|
72
|
+
console.error(`Task ${taskId}: older comments were not loaded - ${detail}.`);
|
|
73
|
+
return { comments, incomplete: { reason: "capped", detail } };
|
|
74
|
+
}
|
|
75
|
+
return { comments, incomplete: null };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Find a top-level comment of a task by id, paging as far as MAX_COMMENT_PAGES.
|
|
79
|
+
*
|
|
80
|
+
* Unlike editComment's lookup this does not stop at the edit window, because a
|
|
81
|
+
* thread parent can be arbitrarily old. `comment` is undefined when the id is not
|
|
82
|
+
* a top-level comment of this task - which also catches reply ids, since replies
|
|
83
|
+
* never appear in the task's comment list. Callers must check `exhaustive` before
|
|
84
|
+
* telling the user the id is invalid: past the page cap the answer is only
|
|
85
|
+
* "not among the newest comments".
|
|
86
|
+
*/
|
|
87
|
+
async function findTopLevelComment(taskId, commentId) {
|
|
88
|
+
let cursor;
|
|
89
|
+
for (let pages = 0; pages < exports.MAX_COMMENT_PAGES; pages++) {
|
|
90
|
+
const page = await fetchCommentPage(taskId, cursor);
|
|
91
|
+
const match = page.find((entry) => String(entry.id) === String(commentId));
|
|
92
|
+
if (match) {
|
|
93
|
+
return { comment: match, exhaustive: true };
|
|
94
|
+
}
|
|
95
|
+
if (page.length < exports.COMMENTS_PER_PAGE) {
|
|
96
|
+
return { comment: undefined, exhaustive: true };
|
|
97
|
+
}
|
|
98
|
+
const oldest = page[page.length - 1];
|
|
99
|
+
cursor = { start: String(oldest.date), startId: String(oldest.id) };
|
|
100
|
+
}
|
|
101
|
+
return { comment: undefined, exhaustive: false };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The replies inside a comment thread, oldest first.
|
|
105
|
+
*
|
|
106
|
+
* `GET /task/{id}/comment` only returns top-level comments; the replies of a
|
|
107
|
+
* thread (Threaded Comments ClickApp) live behind `GET /comment/{id}/reply`.
|
|
108
|
+
* Any failure (non-ok response, network error, malformed body) is logged and
|
|
109
|
+
* returns an empty array so one broken thread does not take down the whole
|
|
110
|
+
* task view.
|
|
111
|
+
*/
|
|
112
|
+
async function fetchCommentReplies(commentId) {
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetch(`https://api.clickup.com/api/v2/comment/${commentId}/reply`, { headers: { Authorization: config_1.CONFIG.apiKey } });
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
const errorData = await response.json().catch(() => ({}));
|
|
117
|
+
console.error(`Error fetching replies for comment ${commentId}: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
const data = await response.json();
|
|
121
|
+
const replies = Array.isArray(data?.comments) ? data.comments : [];
|
|
122
|
+
return replies.sort((a, b) => Number(a.date) - Number(b.date));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
console.error(`Error fetching replies for comment ${commentId}:`, error);
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Never fetch more threads than this per task read, and never more than a few
|
|
131
|
+
* at once - together with MAX_COMMENT_PAGES this keeps a single getTaskById
|
|
132
|
+
* within ClickUp's 100 calls/minute budget even on a heavily threaded task.
|
|
133
|
+
*/
|
|
134
|
+
exports.MAX_REPLY_FETCHES = 30;
|
|
135
|
+
const REPLY_FETCH_CONCURRENCY = 5;
|
|
136
|
+
/**
|
|
137
|
+
* Fetch the replies of every comment with reply_count > 0, bounded in count and
|
|
138
|
+
* concurrency. Returns a map of comment id -> replies; a thread that was skipped
|
|
139
|
+
* (over the cap) or failed to load is simply absent or empty, so callers can
|
|
140
|
+
* render a "replies not loaded" hint from reply_count.
|
|
141
|
+
*/
|
|
142
|
+
async function fetchRepliesByComment(comments) {
|
|
143
|
+
const threaded = comments.filter((comment) => (comment.reply_count ?? 0) > 0);
|
|
144
|
+
const toFetch = threaded.slice(0, exports.MAX_REPLY_FETCHES);
|
|
145
|
+
if (threaded.length > toFetch.length) {
|
|
146
|
+
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.`);
|
|
147
|
+
}
|
|
148
|
+
const replies = new Map();
|
|
149
|
+
let next = 0;
|
|
150
|
+
const worker = async () => {
|
|
151
|
+
while (next < toFetch.length) {
|
|
152
|
+
const comment = toFetch[next++];
|
|
153
|
+
replies.set(String(comment.id), await fetchCommentReplies(String(comment.id)));
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
await Promise.all(Array.from({ length: Math.min(REPLY_FETCH_CONCURRENCY, toFetch.length) }, worker));
|
|
157
|
+
return replies;
|
|
158
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/shared/config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc,oBAA2D,CAAC;AAkDvF,MAAM,MAAM,OAAO,GAAG,cAAc,GAAG,MAAM,GAAG,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/shared/config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc,oBAA2D,CAAC;AAkDvF,MAAM,MAAM,OAAO,GAAG,cAAc,GAAG,MAAM,GAAG,OAAO,CAAC;AA8CxD,eAAO,MAAM,MAAM;;;;;;;;;CAqBlB,CAAC"}
|
package/dist/shared/config.js
CHANGED
|
@@ -49,17 +49,32 @@ else if (rawMode && rawMode !== 'write') {
|
|
|
49
49
|
console.error(`Invalid CLICKUP_MCP_MODE "${rawMode}". Using default "write". Valid options: read-minimal, read, write`);
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* `
|
|
52
|
+
* Numeric settings are exposed as optional MCPB user_config fields, so their env
|
|
53
|
+
* values can arrive blank or - if the host does not substitute an unset optional
|
|
54
|
+
* field - as the literal `${user_config.x}` placeholder. Both mean "not configured"
|
|
55
|
+
* and must fall back to the default rather than fail or turn into NaN.
|
|
55
56
|
*/
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
function readOptionalEnv(name) {
|
|
58
|
+
const raw = process.env[name]?.trim();
|
|
59
|
+
if (!raw || /^\$\{.*}$/.test(raw)) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return raw;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A typo must not silently change a limit: `parseFloat("Infinity")` would disable
|
|
66
|
+
* the comment age check altogether and `parseFloat("abc")` would yield NaN, which
|
|
67
|
+
* compares false against every size and so lifts the upload limit instead of
|
|
68
|
+
* enforcing it. Fail at startup instead.
|
|
69
|
+
*/
|
|
70
|
+
function parseNumericEnv(name, fallback, { min, expectation }) {
|
|
71
|
+
const raw = readOptionalEnv(name);
|
|
72
|
+
if (raw === undefined) {
|
|
73
|
+
return fallback;
|
|
59
74
|
}
|
|
60
75
|
const value = Number(raw);
|
|
61
|
-
if (!Number.isFinite(value) || value <
|
|
62
|
-
throw new Error(`Invalid
|
|
76
|
+
if (!Number.isFinite(value) || value < min) {
|
|
77
|
+
throw new Error(`Invalid ${name} "${raw}". ${expectation}`);
|
|
63
78
|
}
|
|
64
79
|
return value;
|
|
65
80
|
}
|
|
@@ -71,11 +86,17 @@ exports.CONFIG = {
|
|
|
71
86
|
// Upper bound for a single image uploaded to ClickUp. Unlike maxResponseSizeMB this is
|
|
72
87
|
// not about context window budget - it only guards against accidentally pushing huge
|
|
73
88
|
// files into a ticket.
|
|
74
|
-
maxUploadSizeMB:
|
|
89
|
+
maxUploadSizeMB: parseNumericEnv("MAX_UPLOAD_SIZE_MB", 10, {
|
|
90
|
+
min: 1,
|
|
91
|
+
expectation: "Expected a positive number of megabytes.",
|
|
92
|
+
}),
|
|
75
93
|
// How long after creation a comment may still be edited. ClickUp has no way to tell
|
|
76
94
|
// "written by this MCP" apart from "written by the token owner in the UI", so this
|
|
77
95
|
// window is the actual guard against rewriting history. 0 disables editing entirely.
|
|
78
|
-
commentEditWindowHours:
|
|
96
|
+
commentEditWindowHours: parseNumericEnv("CLICKUP_COMMENT_EDIT_WINDOW_HOURS", 24, {
|
|
97
|
+
min: 0,
|
|
98
|
+
expectation: "Expected a non-negative number of hours (0 disables editComment).",
|
|
99
|
+
}),
|
|
79
100
|
primaryLanguageHint: detectedLanguageHint, // Store the cleaned code directly
|
|
80
101
|
mode: mcpMode,
|
|
81
102
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date parameters as tools receive them: an ISO date with a time of day
|
|
3
|
+
* ("2026-10-02T18:00:00+02:00") or a bare date ("2026-10-02").
|
|
4
|
+
*/
|
|
5
|
+
export interface ParsedDateInput {
|
|
6
|
+
/** Milliseconds since the epoch */
|
|
7
|
+
timestamp: number;
|
|
8
|
+
/** false for a bare date (YYYY-MM-DD), which stands for a whole local day */
|
|
9
|
+
hasTime: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Parse a date parameter, or throw an error that names the field.
|
|
13
|
+
*
|
|
14
|
+
* Two silent failures are turned into errors on purpose:
|
|
15
|
+
* - an unparseable string gives NaN, which JSON.stringify sends as `null` - a
|
|
16
|
+
* due date would be cleared while the tool reports success;
|
|
17
|
+
* - JavaScript rolls impossible days over instead of rejecting them, with or
|
|
18
|
+
* without a time ("2026-02-31" and "2026-02-31T10:00" both become March 3rd).
|
|
19
|
+
*
|
|
20
|
+
* A bare date is a whole LOCAL day: `new Date("2026-10-02")` would be UTC
|
|
21
|
+
* midnight, i.e. the previous evening east of Greenwich. `bareDateAt` picks the
|
|
22
|
+
* start (00:00:00.000) or the end (23:59:59.999) of that day.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseDateInput(value: string, field: string, bareDateAt?: "start" | "end"): ParsedDateInput;
|
|
25
|
+
//# sourceMappingURL=dates.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dates.d.ts","sourceRoot":"","sources":["../../src/shared/dates.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,mCAAmC;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,OAAO,EAAE,OAAO,CAAC;CAClB;AAID;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,UAAU,GAAE,OAAO,GAAG,KAAe,GACpC,eAAe,CA8BjB"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseDateInput = parseDateInput;
|
|
4
|
+
const LEADING_DATE = /^(\d{4})-(\d{2})-(\d{2})(.*)$/;
|
|
5
|
+
/**
|
|
6
|
+
* Parse a date parameter, or throw an error that names the field.
|
|
7
|
+
*
|
|
8
|
+
* Two silent failures are turned into errors on purpose:
|
|
9
|
+
* - an unparseable string gives NaN, which JSON.stringify sends as `null` - a
|
|
10
|
+
* due date would be cleared while the tool reports success;
|
|
11
|
+
* - JavaScript rolls impossible days over instead of rejecting them, with or
|
|
12
|
+
* without a time ("2026-02-31" and "2026-02-31T10:00" both become March 3rd).
|
|
13
|
+
*
|
|
14
|
+
* A bare date is a whole LOCAL day: `new Date("2026-10-02")` would be UTC
|
|
15
|
+
* midnight, i.e. the previous evening east of Greenwich. `bareDateAt` picks the
|
|
16
|
+
* start (00:00:00.000) or the end (23:59:59.999) of that day.
|
|
17
|
+
*/
|
|
18
|
+
function parseDateInput(value, field, bareDateAt = "start") {
|
|
19
|
+
const trimmed = value.trim();
|
|
20
|
+
const leading = LEADING_DATE.exec(trimmed);
|
|
21
|
+
if (leading) {
|
|
22
|
+
const [, yearText, monthText, dayText, rest] = leading;
|
|
23
|
+
const year = Number(yearText);
|
|
24
|
+
const month = Number(monthText);
|
|
25
|
+
const day = Number(dayText);
|
|
26
|
+
const daysInMonth = month >= 1 && month <= 12 ? new Date(year, month, 0).getDate() : 0;
|
|
27
|
+
if (day < 1 || day > daysInMonth) {
|
|
28
|
+
throw new Error(`Invalid ${field} "${value}": ${yearText}-${monthText}-${dayText} is not a calendar date.`);
|
|
29
|
+
}
|
|
30
|
+
if (rest === "") {
|
|
31
|
+
const timestamp = bareDateAt === "start"
|
|
32
|
+
? new Date(year, month - 1, day, 0, 0, 0, 0).getTime()
|
|
33
|
+
: new Date(year, month - 1, day, 23, 59, 59, 999).getTime();
|
|
34
|
+
return { timestamp, hasTime: false };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const timestamp = Date.parse(trimmed);
|
|
38
|
+
if (Number.isNaN(timestamp)) {
|
|
39
|
+
throw new Error(`Invalid ${field} "${value}". Expected an ISO date with a time of day (e.g. 2026-10-02T18:00:00+02:00) or a bare date (YYYY-MM-DD).`);
|
|
40
|
+
}
|
|
41
|
+
return { timestamp, hasTime: true };
|
|
42
|
+
}
|
package/dist/shared/utils.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import Fuse from 'fuse.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Checks if a string looks like a valid ClickUp task ID
|
|
4
4
|
* Valid task IDs are at least 6 characters long and contain only alphanumeric characters.
|
|
5
|
-
*
|
|
5
|
+
* 6-16 characters: ClickUp lengthened generated IDs in August 2026 (9 -> 10 -> 11 chars); 16 leaves headroom while still rejecting URLs and "CU-" prefixed strings before an API call.
|
|
6
6
|
*/
|
|
7
7
|
export declare function isTaskId(str: string): boolean;
|
|
8
8
|
/**
|
package/dist/shared/utils.js
CHANGED
|
@@ -29,11 +29,11 @@ const GLOBAL_REFRESH_INTERVAL = 60000; // 60 seconds - that is the rate limit ti
|
|
|
29
29
|
/**
|
|
30
30
|
* Checks if a string looks like a valid ClickUp task ID
|
|
31
31
|
* Valid task IDs are at least 6 characters long and contain only alphanumeric characters.
|
|
32
|
-
*
|
|
32
|
+
* 6-16 characters: ClickUp lengthened generated IDs in August 2026 (9 -> 10 -> 11 chars); 16 leaves headroom while still rejecting URLs and "CU-" prefixed strings before an API call.
|
|
33
33
|
*/
|
|
34
34
|
function isTaskId(str) {
|
|
35
|
-
// Task IDs are 6
|
|
36
|
-
return /^[a-z0-9]{6,}$/i.test(str);
|
|
35
|
+
// Task IDs are 6-16 characters long and contain only alphanumeric characters
|
|
36
|
+
return /^[a-z0-9]{6,16}$/i.test(str);
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
39
|
* Checks if a string looks like a ClickUp custom task ID (e.g. "SOI-4422", "PQP-123")
|
|
@@ -68,7 +68,7 @@ async function resolveTaskId(id) {
|
|
|
68
68
|
if (isCustomTaskId(id)) {
|
|
69
69
|
return resolveCustomTaskId(id);
|
|
70
70
|
}
|
|
71
|
-
throw new Error(`Invalid task ID format: "${id}". Expected an internal ID (6
|
|
71
|
+
throw new Error(`Invalid task ID format: "${id}". Expected an internal ID (6-16 alphanumeric characters) or a custom ID (e.g. "SOI-4422").`);
|
|
72
72
|
}
|
|
73
73
|
// Cache for current user info to avoid repeated API calls and race conditions
|
|
74
74
|
let cachedUserPromise = null;
|
package/dist/tools/list-tools.js
CHANGED
|
@@ -114,23 +114,23 @@ function registerListToolsWrite(server) {
|
|
|
114
114
|
tool("updateListInfo", [
|
|
115
115
|
"Appends to or replaces a list's description.",
|
|
116
116
|
"ALWAYS reference the list URL (https://app.clickup.com/v/l/LIST_ID) when updating or discussing lists.",
|
|
117
|
-
"`append_description` adds a dated block under the existing description and is the safe default. `
|
|
117
|
+
"`append_description` adds a dated block under the existing description and is the safe default. `description` rewrites the whole description - read it with getListInfo first and repeat everything worth keeping; the previous description is echoed back so it can be restored.",
|
|
118
118
|
"Use this to add project context, requirements, or guidelines that LLMs should consider when working with tasks in this list.",
|
|
119
119
|
"Include links to related tasks, spaces, or external resources in the appended content.",
|
|
120
120
|
"Content is appended in markdown format with timestamp for tracking changes."
|
|
121
121
|
].join("\n"), {
|
|
122
122
|
list_id: zod_1.z.string().min(1).describe("The list ID to update"),
|
|
123
|
-
append_description: zod_1.z.string().min(1).optional().describe("Markdown content to APPEND under the existing list description as a dated block (preserves existing content). Mutually exclusive with
|
|
124
|
-
|
|
123
|
+
append_description: zod_1.z.string().min(1).optional().describe("Markdown content to APPEND under the existing list description as a dated block (preserves existing content). Mutually exclusive with description"),
|
|
124
|
+
description: zod_1.z.string().optional().describe("Markdown content that REPLACES the whole list description. Anything not repeated here is lost (the previous description is echoed back). Mutually exclusive with append_description")
|
|
125
125
|
}, {
|
|
126
126
|
readOnlyHint: false,
|
|
127
127
|
destructiveHint: true,
|
|
128
128
|
idempotentHint: false,
|
|
129
|
-
}, async ({ list_id, append_description,
|
|
129
|
+
}, async ({ list_id, append_description, description }) => {
|
|
130
130
|
try {
|
|
131
|
-
if ((append_description === undefined) === (
|
|
131
|
+
if ((append_description === undefined) === (description === undefined)) {
|
|
132
132
|
return {
|
|
133
|
-
content: [{ type: "text", text: "Pass either append_description or
|
|
133
|
+
content: [{ type: "text", text: "Pass either append_description or description (exactly one). The list was NOT updated." }],
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
// Get current list info including description (try to get markdown content)
|
|
@@ -145,8 +145,8 @@ function registerListToolsWrite(server) {
|
|
|
145
145
|
const currentDescription = listData.markdown_description || listData.markdown_content || listData.content || "";
|
|
146
146
|
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
|
147
147
|
let finalDescription;
|
|
148
|
-
if (
|
|
149
|
-
finalDescription =
|
|
148
|
+
if (description !== undefined) {
|
|
149
|
+
finalDescription = description;
|
|
150
150
|
}
|
|
151
151
|
else {
|
|
152
152
|
const separator = currentDescription.trim() ? "\n\n---\n" : "";
|
|
@@ -171,7 +171,7 @@ function registerListToolsWrite(server) {
|
|
|
171
171
|
content: [
|
|
172
172
|
{
|
|
173
173
|
type: "text",
|
|
174
|
-
text:
|
|
174
|
+
text: description !== undefined
|
|
175
175
|
? [
|
|
176
176
|
`Successfully replaced the description of list "${listData.name}" (list_id: ${list_id}).`,
|
|
177
177
|
`previous_description: ${currentDescription.trim() ? currentDescription : '(empty)'}`,
|
|
@@ -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,QAgErE;AAuPD;;GAEG;AACH,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,GAAG,EAAE,WAAW,CAAC,EAAE,GAAG,EAAE,EAAE,YAAY,GAAE,OAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAuJ/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
|
// Workaround: SDK 1.27+ dual Zod v3/v4 type causes TS2589 on server.tool() generics
|
|
@@ -20,13 +21,13 @@ function registerTaskToolsRead(server, userData) {
|
|
|
20
21
|
.string()
|
|
21
22
|
.min(1)
|
|
22
23
|
.refine(val => (0, utils_1.isTaskId)(val) || (0, utils_1.isCustomTaskId)(val), {
|
|
23
|
-
message: "Must be an internal task ID (6
|
|
24
|
+
message: "Must be an internal task ID (6-16 alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
|
|
24
25
|
})
|
|
25
|
-
.describe(`The task ID: either an internal ID (6
|
|
26
|
+
.describe(`The task ID: either an internal ID (6-16 alphanumeric characters like "869c4za0g") or a custom task ID (e.g. "SOI-4422"). Do not include prefixes like "#", "CU-" or URLs.`),
|
|
26
27
|
}, {
|
|
27
28
|
readOnlyHint: true
|
|
28
29
|
}, async ({ id }) => {
|
|
29
|
-
// Resolve custom task
|
|
30
|
+
// Resolve custom task IDs (e.g. "SOI-4422") to internal IDs - no API call for internal IDs
|
|
30
31
|
const resolvedId = await (0, utils_1.resolveTaskId)(id);
|
|
31
32
|
// 1. Load base task content, comment events, and status change events in parallel
|
|
32
33
|
const [taskDetailContentBlocks, commentEvents, statusChangeEvents] = await Promise.all([
|
|
@@ -101,28 +102,63 @@ async function loadTaskContent(taskId) {
|
|
|
101
102
|
return [taskMetadata, ...content];
|
|
102
103
|
}
|
|
103
104
|
async function loadTaskComments(id) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
105
|
+
// The comment list only returns 25 top-level comments per page - page through
|
|
106
|
+
// all of them (the previous `?start_date=0` was silently ignored by ClickUp).
|
|
107
|
+
// A failing page keeps what was loaded before it; the gap is reported below.
|
|
108
|
+
const { comments, incomplete } = await (0, comments_1.fetchAllTopLevelComments)(id);
|
|
109
|
+
// Replies live behind their own endpoint and are missing from the comment
|
|
110
|
+
// list. Only threads (reply_count > 0) cost extra requests, bounded in count
|
|
111
|
+
// and concurrency to protect the API budget.
|
|
112
|
+
const repliesByComment = await (0, comments_1.fetchRepliesByComment)(comments);
|
|
113
|
+
const formatUser = (user) => `${user?.username ?? "unknown"} (user_id: ${user?.id ?? "unknown"})`;
|
|
114
|
+
const commentEvents = await Promise.all(comments.map(async (comment) => {
|
|
115
|
+
// The comment_id makes the comment addressable for editComment and for
|
|
116
|
+
// threaded replies via addComment's parent_comment_id.
|
|
116
117
|
const headerBlock = {
|
|
117
118
|
type: "text",
|
|
118
|
-
text: `Comment by ${comment.user
|
|
119
|
+
text: `Comment by ${formatUser(comment.user)} on ${timestampToIso(comment.date)} (comment_id: ${comment.id}):`,
|
|
119
120
|
};
|
|
120
|
-
const commentBodyBlocks = await (0, clickup_text_1.convertClickUpTextItemsToToolCallResult)(comment.comment);
|
|
121
|
+
const commentBodyBlocks = await (0, clickup_text_1.convertClickUpTextItemsToToolCallResult)(comment.comment ?? []);
|
|
122
|
+
const contentBlocks = [headerBlock, ...commentBodyBlocks];
|
|
123
|
+
const replyCount = comment.reply_count ?? 0;
|
|
124
|
+
if (replyCount > 0) {
|
|
125
|
+
const replies = repliesByComment.get(String(comment.id)) ?? [];
|
|
126
|
+
for (const reply of replies) {
|
|
127
|
+
contentBlocks.push({
|
|
128
|
+
type: "text",
|
|
129
|
+
text: `↳ Reply by ${formatUser(reply.user)} on ${timestampToIso(reply.date)} (comment_id: ${reply.id}):`,
|
|
130
|
+
});
|
|
131
|
+
contentBlocks.push(...await (0, clickup_text_1.convertClickUpTextItemsToToolCallResult)(reply.comment ?? []));
|
|
132
|
+
}
|
|
133
|
+
if (replies.length < replyCount) {
|
|
134
|
+
// The thread has more replies than were loaded (skipped over the budget
|
|
135
|
+
// cap, failed, or truncated) - never pretend it is empty or complete.
|
|
136
|
+
const missing = replyCount - replies.length;
|
|
137
|
+
contentBlocks.push({
|
|
138
|
+
type: "text",
|
|
139
|
+
text: replies.length === 0
|
|
140
|
+
? `↳ This comment has ${replyCount} repl${replyCount === 1 ? "y" : "ies"} that could not be loaded.`
|
|
141
|
+
: `↳ ${missing} more repl${missing === 1 ? "y" : "ies"} of this comment could not be loaded (${replies.length} of ${replyCount} shown).`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
121
145
|
return {
|
|
122
146
|
date: comment.date, // String timestamp from ClickUp for sorting
|
|
123
|
-
contentBlocks
|
|
147
|
+
contentBlocks,
|
|
124
148
|
};
|
|
125
149
|
}));
|
|
150
|
+
if (incomplete) {
|
|
151
|
+
// Missing comments are always the OLDEST ones (the list is paged newest first),
|
|
152
|
+
// so the warning is dated 0 to sort before every loaded comment. Without it the
|
|
153
|
+
// reader would take a partial (or empty) history for the whole story.
|
|
154
|
+
const loaded = comments.length;
|
|
155
|
+
const text = incomplete.reason === "capped"
|
|
156
|
+
? `WARNING: only the ${loaded} newest top-level comments are shown - ${incomplete.detail}.`
|
|
157
|
+
: loaded === 0
|
|
158
|
+
? `WARNING: the comments of this task could not be loaded - ${incomplete.detail}`
|
|
159
|
+
: `WARNING: only the ${loaded} newest top-level comments are shown - older comments could not be fetched: ${incomplete.detail}`;
|
|
160
|
+
commentEvents.push({ date: "0", contentBlocks: [{ type: "text", text }] });
|
|
161
|
+
}
|
|
126
162
|
return commentEvents;
|
|
127
163
|
}
|
|
128
164
|
async function loadTimeInStatusHistory(taskId) {
|
|
@@ -244,6 +280,8 @@ async function generateTaskMetadata(task, timeEntries, isDetailView = false) {
|
|
|
244
280
|
}
|
|
245
281
|
const metadataLines = [
|
|
246
282
|
`task_id: ${task.id}`,
|
|
283
|
+
// The custom ID (e.g. "PQPS-1234") is what people quote; every tool accepts it
|
|
284
|
+
...(task.custom_id ? [`custom_id: ${task.custom_id}`] : []),
|
|
247
285
|
`task_url: ${task.url}`,
|
|
248
286
|
`name: ${task.name}`,
|
|
249
287
|
`status: ${task.status.status}`,
|
|
@@ -301,6 +339,32 @@ async function generateTaskMetadata(task, timeEntries, isDetailView = false) {
|
|
|
301
339
|
if (task.subtasks && task.subtasks.length > 0) {
|
|
302
340
|
metadataLines.push(`child_task_ids: ${task.subtasks.map((st) => st.id).join(', ')}`);
|
|
303
341
|
}
|
|
342
|
+
// Add dependencies if they exist. The API returns a single flat `dependencies`
|
|
343
|
+
// array for both directions; which side of the pair this task sits on decides
|
|
344
|
+
// whether it is waiting on the other task or blocking it.
|
|
345
|
+
if (task.dependencies && task.dependencies.length > 0) {
|
|
346
|
+
const waitingOn = task.dependencies
|
|
347
|
+
.filter((dep) => dep.task_id === task.id)
|
|
348
|
+
.map((dep) => dep.depends_on);
|
|
349
|
+
const blocking = task.dependencies
|
|
350
|
+
.filter((dep) => dep.depends_on === task.id)
|
|
351
|
+
.map((dep) => dep.task_id);
|
|
352
|
+
if (waitingOn.length > 0) {
|
|
353
|
+
metadataLines.push(`waiting_on: ${waitingOn.join(', ')}`);
|
|
354
|
+
}
|
|
355
|
+
if (blocking.length > 0) {
|
|
356
|
+
metadataLines.push(`blocking: ${blocking.join(', ')}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// Add linked (related, non-blocking) tasks if they exist
|
|
360
|
+
if (task.linked_tasks && task.linked_tasks.length > 0) {
|
|
361
|
+
const linked = task.linked_tasks
|
|
362
|
+
.map((link) => (link.task_id === task.id ? link.link_id : link.task_id))
|
|
363
|
+
.filter((id) => id);
|
|
364
|
+
if (linked.length > 0) {
|
|
365
|
+
metadataLines.push(`linked_tasks: ${linked.join(', ')}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
304
368
|
// Add archived status if true
|
|
305
369
|
if (task.archived) {
|
|
306
370
|
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;AA6JpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QAyxBtE"}
|