@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.
@@ -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;AA4BxD,eAAO,MAAM,MAAM;;;;;;;;;CAelB,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"}
@@ -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
- * The edit window is a safety limit, so a typo must not silently widen it:
53
- * `parseFloat("Infinity")` would disable the age check altogether, and
54
- * `parseFloat("abc")` would quietly turn editing off.
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 parseCommentEditWindowHours(raw) {
57
- if (!raw?.trim()) {
58
- return 24;
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 < 0) {
62
- throw new Error(`Invalid CLICKUP_COMMENT_EDIT_WINDOW_HOURS "${raw}". Expected a non-negative number of hours (0 disables editComment).`);
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: process.env.MAX_UPLOAD_SIZE_MB ? parseFloat(process.env.MAX_UPLOAD_SIZE_MB) : 10,
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: parseCommentEditWindowHours(process.env.CLICKUP_COMMENT_EDIT_WINDOW_HOURS),
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
+ }
@@ -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
- * No upper bound: ClickUp now issues IDs of 10+ characters (e.g. "wdrv93ebwx").
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
  /**
@@ -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
- * No upper bound: ClickUp now issues IDs of 10+ characters (e.g. "wdrv93ebwx").
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+ characters long and contain only alphanumeric characters
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+ alphanumeric characters) or a custom ID (e.g. "SOI-4422").`);
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;
@@ -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. `replace_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.",
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 replace_description"),
124
- replace_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")
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, replace_description }) => {
129
+ }, async ({ list_id, append_description, description }) => {
130
130
  try {
131
- if ((append_description === undefined) === (replace_description === undefined)) {
131
+ if ((append_description === undefined) === (description === undefined)) {
132
132
  return {
133
- content: [{ type: "text", text: "Pass either append_description or replace_description (exactly one). The list was NOT updated." }],
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 (replace_description !== undefined) {
149
- finalDescription = replace_description;
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: replace_description !== undefined
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;AAOtF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QAgErE;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,CAwH/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,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"}
@@ -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+ alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
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+ alphanumeric characters like "869c4za0g") or a custom task ID (e.g. "SOI-4422"). Do not include prefixes like "#", "CU-" or URLs.`),
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 ID to internal ID if needed
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
- const response = await fetch(`https://api.clickup.com/api/v2/task/${id}/comment?start_date=0`, // Ensure all comments are fetched
105
- { headers: { Authorization: config_1.CONFIG.apiKey } });
106
- if (!response.ok) {
107
- console.error(`Error fetching comments for task ${id}: ${response.status} ${response.statusText}`);
108
- return [];
109
- }
110
- const commentsData = await response.json();
111
- if (!commentsData.comments || !Array.isArray(commentsData.comments)) {
112
- console.error(`Unexpected comment data structure for task ${id}`);
113
- return [];
114
- }
115
- const commentEvents = await Promise.all(commentsData.comments.map(async (comment) => {
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.username} on ${timestampToIso(comment.date)}:`,
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: [headerBlock, ...commentBodyBlocks],
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;AAwIpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QA4sBtE"}
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"}