@sebastienheyd/clickup-mcp 1.10.1 → 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 CHANGED
@@ -313,7 +313,7 @@ This keeps existing content and a clear audit trail. To correct or restructure a
313
313
 
314
314
  ## Writing Images Into Tickets
315
315
 
316
- `addComment`, `editComment`, `createTask` and `updateTask` accept images as ordinary markdown. Because
316
+ `addComment`, `createTask` and `updateTask` accept images as ordinary markdown. Because
317
317
  this server runs locally, it reads the file itself - so a **local path is enough**:
318
318
 
319
319
  ```markdown
@@ -336,12 +336,16 @@ Notes:
336
336
  - **An image inside a numbered list breaks ClickUp's numbering.** Write walkthrough
337
337
  steps as bold lines with the image between them, as above.
338
338
  - Only real PNG/JPEG/GIF/WebP files are uploaded - the content is checked, not the
339
- extension. A file that fails **aborts the write**: `addComment`, `editComment` and
339
+ extension. A file that fails **aborts the write**: `addComment` and
340
340
  `updateTask` report every broken reference and change nothing, so the markdown can be
341
341
  fixed and the call retried without creating duplicates. `createTask` validates its
342
342
  images before creating the task; only an upload failing afterwards is reported as a
343
343
  warning, since the task already exists at that point.
344
344
  - Attachments always belong to a task, so document pages cannot embed uploads this way.
345
+ - **`editComment` can only keep the images already in the comment.** Pass the markdown
346
+ returned by `getTaskById` back and they stay; ClickUp refuses to add any other image to
347
+ an existing comment, so a new image reference is rejected before anything is uploaded.
348
+ Share a new image in a new comment with `addComment` instead.
345
349
 
346
350
  ## Performance & Limitations
347
351
 
@@ -1 +1 @@
1
- {"version":3,"file":"attachments.d.ts","sourceRoot":"","sources":["../../src/shared/attachments.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAQhC;;;;GAIG;AACH,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED,uCAAuC;AACvC,UAAU,aAAa;IACrB,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,mFAAmF;AACnF,UAAU,gBAAgB;IACxB,IAAI,EAAE,UAAU,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,MAAM,mBAAmB,GAAG,aAAa,GAAG,gBAAgB,CAAC;AAoCnE;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAM3D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,MAAsB,GAC9B,OAAO,CAAC,mBAAmB,CAAC,CA2C9B;AAcD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAS/E;AAmCD;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,yBAAyB,CAAC,CAwBpC;AAED,2FAA2F;AAC3F,MAAM,WAAW,qBAAqB;IACpC,oDAAoD;IACpD,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,mBAAmB,CAAC;CAC/B;AAED,mEAAmE;AACnE,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,EAAE,EACtC,OAAO,GAAE,MAAsB,GAC9B,OAAO,CAAC;IAAE,QAAQ,EAAE,qBAAqB,EAAE,CAAC;IAAC,QAAQ,EAAE,YAAY,EAAE,CAAA;CAAE,CAAC,CAqB1E;AAED,6EAA6E;AAC7E,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,yBAAyB,CAAC;CACvC;AAED;;;;;;;;GAQG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,qBAAqB,EAAE,GAC9B,OAAO,CAAC;IAAE,QAAQ,EAAE,qBAAqB,EAAE,CAAC;IAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAAA;CAAE,CAAC,CAwC9E;AAED,gFAAgF;AAChF,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,qBAAqB,EAAE,GAChC,GAAG,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAMxC"}
1
+ {"version":3,"file":"attachments.d.ts","sourceRoot":"","sources":["../../src/shared/attachments.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAQhC;;;;GAIG;AACH,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED,uCAAuC;AACvC,UAAU,aAAa;IACrB,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,mFAAmF;AACnF,UAAU,gBAAgB;IACxB,IAAI,EAAE,UAAU,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,MAAM,mBAAmB,GAAG,aAAa,GAAG,gBAAgB,CAAC;AAoCnE;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAM3D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,MAAsB,GAC9B,OAAO,CAAC,mBAAmB,CAAC,CA2C9B;AAcD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAS/E;AAmCD;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,yBAAyB,CAAC,CAwBpC;AAED,2FAA2F;AAC3F,MAAM,WAAW,qBAAqB;IACpC,oDAAoD;IACpD,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,mBAAmB,CAAC;CAC/B;AAED,mEAAmE;AACnE,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,EAAE,EACtC,OAAO,GAAE,MAAsB,GAC9B,OAAO,CAAC;IAAE,QAAQ,EAAE,qBAAqB,EAAE,CAAC;IAAC,QAAQ,EAAE,YAAY,EAAE,CAAA;CAAE,CAAC,CAqB1E;AAED,6EAA6E;AAC7E,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,yBAAyB,CAAC;CACvC;AAED;;;;;;;;GAQG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,qBAAqB,EAAE,GAC9B,OAAO,CAAC;IAAE,QAAQ,EAAE,qBAAqB,EAAE,CAAC;IAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAAA;CAAE,CAAC,CA8C9E;AAED,gFAAgF;AAChF,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,qBAAqB,EAAE,GAChC,GAAG,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAMxC"}
@@ -228,12 +228,18 @@ async function uploadResolvedImages(taskId, images) {
228
228
  try {
229
229
  if (resolved.kind === "existing") {
230
230
  // Already on ClickUp's CDN - synthesise the minimal attachment shape so
231
- // the fragment builder has something to work with.
232
- const name = decodeURIComponent((0, path_1.basename)(new URL(resolved.url).pathname));
231
+ // the fragment builder has something to work with. The real attachment id
232
+ // is `<uuid><ext>`, the uuid being the path segment before the filename
233
+ // (.../t123/<uuid>/<name>); ClickUp rejects a comment edit whose image
234
+ // fragment carries any other id (404 ACCESS_028, verified live).
235
+ const pathname = new URL(resolved.url).pathname;
236
+ const name = decodeURIComponent((0, path_1.basename)(pathname));
237
+ const segments = pathname.split("/").filter(Boolean);
238
+ const uuid = segments.length >= 2 ? segments[segments.length - 2] : undefined;
233
239
  uploaded.push({
234
240
  src,
235
241
  attachment: {
236
- id: name,
242
+ id: uuid ? `${uuid}${(0, path_1.extname)(name)}` : name,
237
243
  name,
238
244
  title: alt || name,
239
245
  extension: (0, path_1.extname)(name).replace(/^\./, "") || undefined,
@@ -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
+ }
@@ -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;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,CAqJ/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"}
@@ -280,6 +280,8 @@ async function generateTaskMetadata(task, timeEntries, isDetailView = false) {
280
280
  }
281
281
  const metadataLines = [
282
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}`] : []),
283
285
  `task_url: ${task.url}`,
284
286
  `name: ${task.name}`,
285
287
  `status: ${task.status.status}`,
@@ -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;AA4JpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QA4vBtE"}
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"}
@@ -7,6 +7,7 @@ const utils_1 = require("../shared/utils");
7
7
  const clickup_text_1 = require("../clickup-text");
8
8
  const attachments_1 = require("../shared/attachments");
9
9
  const comments_1 = require("../shared/comments");
10
+ const dates_1 = require("../shared/dates");
10
11
  /**
11
12
  * Shared wording for the image support of every markdown field in this file.
12
13
  * Kept in one place so the tools stay consistent about what a client may pass.
@@ -190,7 +191,7 @@ function registerTaskToolsWrite(server, userData) {
190
191
  });
191
192
  if (!response.ok) {
192
193
  const errorData = await response.json().catch(() => ({}));
193
- throw new Error(`Error adding comment: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
194
+ throw new Error(`ClickUp API ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
194
195
  }
195
196
  const commentData = await response.json();
196
197
  return {
@@ -234,8 +235,8 @@ function registerTaskToolsWrite(server, userData) {
234
235
  "ClickUp shows no 'edited' marker, so people who already read the comment will not notice the change - for anything that changes meaning after a discussion has started, prefer a follow-up comment.",
235
236
  "Editing does not reset the creation date, so the edit window does not get extended by editing.",
236
237
  COMMENT_FORMATTING_HINT,
237
- IMAGE_SUPPORT_HINT,
238
- "IMAGES ON EDIT: reading a comment (getTaskById) returns its images as markdown, so passing that text back keeps them - an existing ClickUp attachment URL is re-embedded without uploading again. Only an image whose markdown you drop disappears.",
238
+ "IMAGES ON EDIT: an edit can only KEEP the images already in this comment. Reading the comment (getTaskById) returns them as markdown, and passing that markdown back keeps them; an image whose markdown you drop disappears.",
239
+ "ClickUp refuses to add any other image to an existing comment - a new file, a data URI, a web URL, or an image of another comment or of the task description - so such a reference is rejected before anything is uploaded. To share a new image, post a new comment with addComment.",
239
240
  "Task URLs (https://app.clickup.com/t/TASK_ID) become live task references, and existing references are read back as such URLs - passing the text back keeps them.",
240
241
  ];
241
242
  if (config_1.CONFIG.primaryLanguageHint && config_1.CONFIG.primaryLanguageHint.toLowerCase() !== 'en') {
@@ -259,14 +260,14 @@ function registerTaskToolsWrite(server, userData) {
259
260
  (0, utils_1.getCurrentUser)(),
260
261
  ]);
261
262
  assertCommentIsEditable(existing, userData.user.id);
262
- // Same pipeline as addComment - the undocumented rich `comment` array is
263
- // accepted by PUT too, so formatting and images survive an edit. Images are
264
- // resolved and uploaded before the PUT, so a broken reference leaves the
265
- // existing comment untouched.
263
+ // Same converter as addComment - the undocumented rich `comment` array is
264
+ // accepted by PUT too, so formatting survives an edit. Images are different:
265
+ // ClickUp only lets an edit keep the comment's OWN images, so they are
266
+ // matched against the stored fragments and nothing is ever uploaded here.
266
267
  const abortNotice = "the comment was NOT changed";
267
- const { markdown, images } = await resolveImagesOrAbort(comment, abortNotice);
268
- const uploaded = await uploadImagesOrAbort(task_id, images, abortNotice);
269
- const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(uploaded));
268
+ const markdown = (0, clickup_text_1.normalizeImageDestinations)(comment);
269
+ const keptImages = keepOwnCommentImages(existing, markdown, abortNotice);
270
+ const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(keptImages));
270
271
  // Only `comment` is sent: sending `comment_text` alongside it appends that
271
272
  // string to the blocks instead of being ignored.
272
273
  const response = await fetch(`https://api.clickup.com/api/v2/comment/${comment_id}`, {
@@ -279,7 +280,7 @@ function registerTaskToolsWrite(server, userData) {
279
280
  });
280
281
  if (!response.ok) {
281
282
  const errorData = await response.json().catch(() => ({}));
282
- throw new Error(`Error editing comment: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
283
+ throw new Error(`ClickUp API ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
283
284
  }
284
285
  return {
285
286
  content: [
@@ -293,7 +294,10 @@ function registerTaskToolsWrite(server, userData) {
293
294
  `created: ${timestampToIso(existing.date)} (unchanged by the edit)`,
294
295
  `previous_text: ${existing.comment_text || '(no plain text available)'}`,
295
296
  `new_comment: ${summarizeMarkdownForEcho(comment)}`,
296
- ...formatAttachedImages(uploaded),
297
+ ...(keptImages.length > 0 ? [
298
+ `images_kept: ${keptImages.length}`,
299
+ ...keptImages.map((k) => ` - ${k.attachment.name} (${k.attachment.url})`),
300
+ ] : []),
297
301
  ].join('\n')
298
302
  }
299
303
  ],
@@ -335,7 +339,7 @@ function registerTaskToolsWrite(server, userData) {
335
339
  });
336
340
  if (!response.ok) {
337
341
  const errorData = await response.json().catch(() => ({}));
338
- throw new Error(`Error deleting comment: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
342
+ throw new Error(`ClickUp API ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
339
343
  }
340
344
  return {
341
345
  content: [
@@ -395,7 +399,7 @@ function registerTaskToolsWrite(server, userData) {
395
399
  time_estimate: taskTimeEstimateSchema.nullable().describe(taskTimeEstimateSchema.description + CLEAR_HINT),
396
400
  points: taskPointsSchema.nullable().describe(taskPointsSchema.description + CLEAR_HINT),
397
401
  tags: taskTagsSchema.describe("Optional array of tag names (will replace existing tags)"),
398
- parent_task_id: zod_1.z.string().optional().describe("Optional parent task ID to change parent/child relationships"),
402
+ parent_task_id: taskIdSchema.optional().describe("Optional parent task ID to move this task under, as a subtask: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
399
403
  assignees: zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of user IDs to add as assignees - existing assignees are kept"),
400
404
  remove_assignees: zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of user IDs to unassign from the task. Combine with assignees to replace one assignee by another."),
401
405
  waiting_on: zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of task IDs that this task should wait on (will replace existing waiting_on relationships)"),
@@ -418,8 +422,22 @@ function registerTaskToolsWrite(server, userData) {
418
422
  }],
419
423
  };
420
424
  }
425
+ // A malformed date must be refused before anything is written: it would
426
+ // otherwise be sent as null and clear the date (see parseDateInput).
427
+ assertValidTaskDates({ due_date, start_date });
421
428
  // Resolve custom task IDs (e.g. "SOI-4422") to internal IDs
422
429
  task_id = await (0, utils_1.resolveTaskId)(task_id);
430
+ // Current dependencies and links come back from ClickUp with internal IDs,
431
+ // so the requested lists must use them too: an unresolved "SOI-4422" would
432
+ // not match its own internal ID, and the existing relation would be removed
433
+ // as "no longer requested" while a duplicate is added. Resolved up front, so
434
+ // an unknown custom ID aborts the update before anything is written.
435
+ [blocking, waiting_on, linked_tasks] = await Promise.all([blocking, waiting_on, linked_tasks].map(resolveTaskIdList));
436
+ // ClickUp's `parent` field only takes an internal ID: a custom ID gets a bare
437
+ // 500 Internal Server Error (verified live), with nothing to tell what is wrong.
438
+ if (parent_task_id !== undefined) {
439
+ parent_task_id = await (0, utils_1.resolveTaskId)(parent_task_id);
440
+ }
423
441
  const userData = await (0, utils_1.getCurrentUser)();
424
442
  // Get task details including current markdown description
425
443
  const taskResponse = await fetch(`https://api.clickup.com/api/v2/task/${task_id}?include_markdown_description=true`, {
@@ -543,7 +561,7 @@ function registerTaskToolsWrite(server, userData) {
543
561
  });
544
562
  if (!updateResponse.ok) {
545
563
  const errorData = await updateResponse.json().catch(() => ({}));
546
- throw new Error(`Error updating task: ${updateResponse.status} ${updateResponse.statusText} - ${JSON.stringify(errorData)}`);
564
+ throw new Error(`ClickUp API ${updateResponse.status} ${updateResponse.statusText} - ${JSON.stringify(errorData)}`);
547
565
  }
548
566
  updatedTask = await updateResponse.json();
549
567
  }
@@ -626,7 +644,7 @@ function registerTaskToolsWrite(server, userData) {
626
644
  time_estimate: taskTimeEstimateSchema,
627
645
  points: taskPointsSchema,
628
646
  tags: taskTagsSchema,
629
- parent_task_id: zod_1.z.string().optional().describe("Optional parent task ID to create this as a subtask"),
647
+ parent_task_id: taskIdSchema.optional().describe("Optional parent task ID to create this as a subtask: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
630
648
  assignees: zod_1.z.array(zod_1.z.string()).optional().describe(createAssigneeDescription(userData))
631
649
  }, {
632
650
  readOnlyHint: false,
@@ -640,6 +658,12 @@ function registerTaskToolsWrite(server, userData) {
640
658
  // behind. Uploading has to wait until the task exists, though - ClickUp
641
659
  // attachments always belong to a task.
642
660
  const { markdown: normalizedDescription, images } = await resolveImagesOrAbort(description, "the task was NOT created");
661
+ // ClickUp's `parent` field only takes an internal ID: a custom ID gets a bare
662
+ // 500 Internal Server Error (verified live). Resolved before the task exists,
663
+ // so an unknown parent leaves nothing behind.
664
+ if (parent_task_id !== undefined) {
665
+ parent_task_id = await (0, utils_1.resolveTaskId)(parent_task_id);
666
+ }
643
667
  const userData = await (0, utils_1.getCurrentUser)();
644
668
  const currentUserId = userData.user.id;
645
669
  const requestBody = buildTaskRequestBody({
@@ -659,7 +683,7 @@ function registerTaskToolsWrite(server, userData) {
659
683
  });
660
684
  if (!response.ok) {
661
685
  const errorData = await response.json().catch(() => ({}));
662
- throw new Error(`Error creating task: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
686
+ throw new Error(`ClickUp API ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
663
687
  }
664
688
  const createdTask = await response.json();
665
689
  // Tags are omitted from the create body by buildTaskRequestBody because they
@@ -758,15 +782,85 @@ function convertPriorityToNumber(priority) {
758
782
  default: return 3;
759
783
  }
760
784
  }
785
+ /**
786
+ * The task payload carries the priority NAME (`priority: { id: "4", priority: "low" }`),
787
+ * so a numeric-only lookup turned every real priority into "unknown". Accept both.
788
+ */
761
789
  function convertPriorityToString(priority) {
790
+ if (typeof priority === 'string' && ['urgent', 'high', 'normal', 'low'].includes(priority.toLowerCase())) {
791
+ return priority.toLowerCase();
792
+ }
762
793
  const priorityMap = { 1: 'urgent', 2: 'high', 3: 'normal', 4: 'low' };
763
- return priorityMap[priority] || 'unknown';
794
+ return priorityMap[Number(priority)] || 'unknown';
764
795
  }
765
796
  function formatTimeEstimate(hours) {
766
797
  const displayHours = Math.floor(hours);
767
798
  const displayMinutes = Math.round((hours - displayHours) * 60);
768
799
  return displayHours > 0 ? `${displayHours}h ${displayMinutes}m` : `${displayMinutes}m`;
769
800
  }
801
+ /**
802
+ * Match the image references of an edited comment against the images the comment
803
+ * already contains, and return the stored attachment object for each of them.
804
+ *
805
+ * Verified live on `PUT /comment/{id}`: an image fragment is only accepted when it
806
+ * carries the real attachment of an image that is ALREADY in that comment. Any
807
+ * other attachment - a fresh upload, the task description's image, an image of
808
+ * another comment - is refused with 401 ACCESS_610, and a fragment whose id is not
809
+ * a real attachment id fails with 404 ACCESS_028. Reusing the stored object (real
810
+ * id, dimensions included) is therefore the only working way to keep an image, and
811
+ * anything else is rejected here, before a request is made - uploading first would
812
+ * only leave an orphan attachment on the task behind the failed edit.
813
+ */
814
+ function keepOwnCommentImages(existing, markdown, abortNotice) {
815
+ const sources = (0, clickup_text_1.collectMarkdownImageSources)(markdown);
816
+ if (sources.length === 0) {
817
+ return [];
818
+ }
819
+ const ownImages = new Map();
820
+ for (const fragment of existing.comment ?? []) {
821
+ if (fragment?.type === "image" && fragment.image?.url) {
822
+ ownImages.set(normalizeUrl(fragment.image.url), fragment.image);
823
+ }
824
+ }
825
+ const kept = [];
826
+ const refused = [];
827
+ const seen = new Set();
828
+ for (const { src } of sources) {
829
+ if (seen.has(src)) {
830
+ continue;
831
+ }
832
+ seen.add(src);
833
+ const stored = ownImages.get(normalizeUrl(src));
834
+ if (stored) {
835
+ kept.push({ src, attachment: stored });
836
+ }
837
+ else {
838
+ refused.push(src);
839
+ }
840
+ }
841
+ if (refused.length > 0) {
842
+ throw new Error([
843
+ `${refused.length} image reference(s) are not images of this comment, so ${abortNotice}:`,
844
+ ...refused.map((src) => ` - ${summarizeImageSource(src)}`),
845
+ `ClickUp only lets an edit keep the images already in the comment - it refuses to add any other image.`,
846
+ `Remove these references, or post the new image in a new comment with addComment.`,
847
+ ].join("\n"));
848
+ }
849
+ return kept;
850
+ }
851
+ /** Compare attachment URLs regardless of percent-encoding of the filename */
852
+ function normalizeUrl(url) {
853
+ try {
854
+ return decodeURI(url.trim());
855
+ }
856
+ catch {
857
+ return url.trim();
858
+ }
859
+ }
860
+ /** Shorten a data URI so an error message never repeats a base64 payload */
861
+ function summarizeImageSource(src) {
862
+ return src.startsWith("data:") ? `${src.slice(0, 40)}... (data URI)` : src;
863
+ }
770
864
  /**
771
865
  * Load a single comment of a task.
772
866
  *
@@ -800,6 +894,10 @@ async function findTaskComment(taskId, commentId) {
800
894
  }
801
895
  checked += page.length;
802
896
  sawThreadedReplies || (sawThreadedReplies = page.some((entry) => (entry.reply_count ?? 0) > 0));
897
+ // A short page is the last one - requesting the next would only return [].
898
+ if (page.length < comments_1.COMMENTS_PER_PAGE) {
899
+ break;
900
+ }
803
901
  // Comments come back newest first, so once a page runs past the edit window
804
902
  // there is nothing editable further back.
805
903
  const oldest = page[page.length - 1];
@@ -853,6 +951,35 @@ function timestampToIso(timestamp) {
853
951
  const timezoneOffset = sign + String(offsetHours).padStart(2, '0') + ':' + String(offsetMinutes).padStart(2, '0');
854
952
  return `${year}-${month}-${day}T${hours}:${minutes}${timezoneOffset}`;
855
953
  }
954
+ /**
955
+ * Convert a due/start date parameter for the task API.
956
+ *
957
+ * ClickUp drops the time of day unless `due_date_time`/`start_date_time` is true
958
+ * (verified live: 18:00 came back as 04:00, its all-day placeholder), so report
959
+ * whether the caller gave a time. A bare date (YYYY-MM-DD) is an all-day value at
960
+ * local midnight. Invalid or impossible dates throw instead of being sent as null.
961
+ */
962
+ function parseTaskDate(value, field) {
963
+ return (0, dates_1.parseDateInput)(value, field, "start");
964
+ }
965
+ /** Validate due/start dates up front so a bad value never reaches a write */
966
+ function assertValidTaskDates(dates) {
967
+ for (const field of ["due_date", "start_date"]) {
968
+ const value = dates[field];
969
+ if (typeof value === "string") {
970
+ parseTaskDate(value, field);
971
+ }
972
+ }
973
+ }
974
+ /** Resolve every ID of a relation list to an internal ID, without duplicates */
975
+ async function resolveTaskIdList(ids) {
976
+ if (ids === undefined) {
977
+ return undefined;
978
+ }
979
+ // Deduplicate before resolving too: each custom ID costs one API call
980
+ const resolved = await Promise.all([...new Set(ids)].map((id) => (0, utils_1.resolveTaskId)(id)));
981
+ return [...new Set(resolved)];
982
+ }
856
983
  function buildTaskRequestBody(params, currentUserId) {
857
984
  const requestBody = {};
858
985
  if (params.name !== undefined) {
@@ -867,10 +994,24 @@ function buildTaskRequestBody(params, currentUserId) {
867
994
  // null clears the value - it must be sent as is, new Date(null) would set 1970-01-01.
868
995
  // time_estimate is the exception: ClickUp accepts null but silently keeps the estimate, only 0 clears it.
869
996
  if (params.due_date !== undefined) {
870
- requestBody.due_date = params.due_date === null ? null : new Date(params.due_date).getTime();
997
+ if (params.due_date === null) {
998
+ requestBody.due_date = null;
999
+ }
1000
+ else {
1001
+ const due = parseTaskDate(params.due_date, 'due_date');
1002
+ requestBody.due_date = due.timestamp;
1003
+ requestBody.due_date_time = due.hasTime;
1004
+ }
871
1005
  }
872
1006
  if (params.start_date !== undefined) {
873
- requestBody.start_date = params.start_date === null ? null : new Date(params.start_date).getTime();
1007
+ if (params.start_date === null) {
1008
+ requestBody.start_date = null;
1009
+ }
1010
+ else {
1011
+ const start = parseTaskDate(params.start_date, 'start_date');
1012
+ requestBody.start_date = start.timestamp;
1013
+ requestBody.start_date_time = start.hasTime;
1014
+ }
874
1015
  }
875
1016
  if (params.time_estimate !== undefined) {
876
1017
  requestBody.time_estimate = params.time_estimate === null ? 0 : Math.round(params.time_estimate * 60 * 60 * 1000);
@@ -894,9 +1035,18 @@ function buildTaskRequestBody(params, currentUserId) {
894
1035
  // Helper function to manage task dependencies
895
1036
  async function updateTaskDependencies(taskId, taskData, dependencies) {
896
1037
  const errors = [];
897
- // Get current dependencies
898
- const currentBlocking = taskData.blocking?.map((dep) => dep.id) || [];
899
- const currentWaitingOn = taskData.waiting_on?.map((dep) => dep.id) || [];
1038
+ // Get current dependencies. The task payload has no `blocking`/`waiting_on`
1039
+ // arrays (verified live): both directions live in one flat `dependencies` array
1040
+ // of `{ task_id, depends_on }` records, and this task can sit on either side.
1041
+ // Reading the missing fields made every current dependency look absent, so
1042
+ // `waiting_on: []`/`blocking: []` never removed anything and reported no error.
1043
+ const dependencyRecords = Array.isArray(taskData.dependencies) ? taskData.dependencies : [];
1044
+ const currentWaitingOn = dependencyRecords
1045
+ .filter((dep) => dep.task_id === taskId && dep.depends_on)
1046
+ .map((dep) => dep.depends_on);
1047
+ const currentBlocking = dependencyRecords
1048
+ .filter((dep) => dep.depends_on === taskId && dep.task_id)
1049
+ .map((dep) => dep.task_id);
900
1050
  // `linked_tasks` entries are link records, not tasks: each has `task_id` and
901
1051
  // `link_id` (the two ends of the link) and no `id` at all. Mapping `.id` here
902
1052
  // produced `[undefined, ...]`, so every existing link looked like it was no
@@ -1 +1 @@
1
- {"version":3,"file":"time-tools.d.ts","sourceRoot":"","sources":["../../src/tools/time-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAyDpE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,QAqFtD;AA6OD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,QAmPvD"}
1
+ {"version":3,"file":"time-tools.d.ts","sourceRoot":"","sources":["../../src/tools/time-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAoEpE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,QAqFtD;AA6OD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,QAmPvD"}
@@ -5,12 +5,22 @@ exports.registerTimeToolsWrite = registerTimeToolsWrite;
5
5
  const zod_1 = require("zod");
6
6
  const config_1 = require("../shared/config");
7
7
  const utils_1 = require("../shared/utils");
8
+ const dates_1 = require("../shared/dates");
8
9
  /**
9
10
  * Converts ISO date string to Unix timestamp in milliseconds
10
11
  */
11
12
  function isoToTimestamp(isoString) {
12
13
  return new Date(isoString).getTime();
13
14
  }
15
+ /**
16
+ * Convert a date range filter bound. A bare date (YYYY-MM-DD) means the whole
17
+ * local day: `new Date("2026-10-01")` is UTC midnight, so as an end bound it used
18
+ * to exclude the very day the summary header announced ("2026-10-01 to 2026-10-01").
19
+ * Invalid or impossible dates throw instead of sending `NaN` to the API.
20
+ */
21
+ function dateFilterToTimestamp(value, bound) {
22
+ return (0, dates_1.parseDateInput)(value, bound === "start" ? "start_date" : "end_date", bound).timestamp;
23
+ }
14
24
  /**
15
25
  * Formats timestamp to ISO string with local timezone (not UTC)
16
26
  */
@@ -57,8 +67,8 @@ function registerTimeToolsRead(server) {
57
67
  task_id: zod_1.z.string().min(1).refine(val => (0, utils_1.isTaskId)(val) || (0, utils_1.isCustomTaskId)(val), {
58
68
  message: "Must be an internal task ID (6-16 alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
59
69
  }).optional().describe("Optional task ID to filter entries: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\"). If not provided, returns all user's time entries."),
60
- start_date: zod_1.z.string().optional().describe("Optional start date filter as ISO date string (e.g., '2024-10-06T00:00:00+02:00'). Defaults to 30 days ago."),
61
- end_date: zod_1.z.string().optional().describe("Optional end date filter as ISO date string (e.g., '2024-10-06T23:59:59+02:00'). Defaults to current date."),
70
+ start_date: zod_1.z.string().optional().describe("Optional start date filter as ISO date string (e.g., '2024-10-06T00:00:00+02:00') or a bare date (e.g., '2024-10-06', from the start of that local day). Defaults to 30 days ago."),
71
+ end_date: zod_1.z.string().optional().describe("Optional end date filter as ISO date string (e.g., '2024-10-06T23:59:59+02:00') or a bare date (e.g., '2024-10-06', up to the end of that local day). Defaults to current date."),
62
72
  list_id: zod_1.z.string().optional().describe("Optional single list ID to filter time entries by a specific list"),
63
73
  space_id: zod_1.z.string().optional().describe("Optional single space ID to filter time entries by a specific space"),
64
74
  include_all_users: zod_1.z.boolean().optional().describe("Optional flag to include time entries from all team members (default: false, only current user)")
@@ -72,10 +82,10 @@ function registerTimeToolsRead(server) {
72
82
  params.append('task_id', await (0, utils_1.resolveTaskId)(task_id));
73
83
  }
74
84
  if (start_date) {
75
- params.append('start_date', isoToTimestamp(start_date).toString());
85
+ params.append('start_date', dateFilterToTimestamp(start_date, "start").toString());
76
86
  }
77
87
  if (end_date) {
78
- params.append('end_date', isoToTimestamp(end_date).toString());
88
+ params.append('end_date', dateFilterToTimestamp(end_date, "end").toString());
79
89
  }
80
90
  // Add single list_id or space_id filter (not both)
81
91
  if (list_id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sebastienheyd/clickup-mcp",
3
- "version": "1.10.1",
3
+ "version": "1.10.2",
4
4
  "description": "Search, create, and retrieve tasks, add comments, and track time through natural language commands.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",