@hauptsache.net/clickup-mcp 1.7.0 → 1.7.3

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.
@@ -10,6 +10,14 @@ const attachments_1 = require("../shared/attachments");
10
10
  * Shared wording for the image support of every markdown field in this file.
11
11
  * Kept in one place so the tools stay consistent about what a client may pass.
12
12
  */
13
+ /**
14
+ * Shared wording for what markdown ClickUp comments can actually render.
15
+ * Kept in one place so addComment and editComment stay consistent.
16
+ */
17
+ const COMMENT_FORMATTING_HINT = [
18
+ "FORMATTING: Headings, **bold**, *italic*, ~~strikethrough~~, `inline code`, code blocks, links, blockquotes, bullet/numbered/nested lists and checkboxes (- [ ] / - [x]) all render natively.",
19
+ "TABLES ARE NOT SUPPORTED by ClickUp comments - a markdown table is automatically converted to a monospace code block, which is readable but plain. Prefer bold labels or lists over tables when writing comments.",
20
+ ].join("\n");
13
21
  const IMAGE_SUPPORT_HINT = [
14
22
  "IMAGES: Reference images with normal markdown - `![caption](/absolute/path/to/screenshot.png)`.",
15
23
  "This server runs locally, so a local file path is read and uploaded automatically - never inline a screenshot as base64 when a path exists, it costs orders of magnitude more tokens.",
@@ -17,25 +25,57 @@ const IMAGE_SUPPORT_HINT = [
17
25
  "The caption becomes the attachment filename, which is what ClickUp displays beneath the image - so write a caption that reads well.",
18
26
  ].join("\n");
19
27
  /**
20
- * Upload every image referenced in a markdown field and report what failed.
21
- * Never throws: a broken image must not cost the user their comment or task.
28
+ * Phase 1 of image handling: parse the markdown and resolve every image source
29
+ * (read files, download URLs, decode data URIs) WITHOUT writing anything.
30
+ *
31
+ * Throws when any reference is unusable, listing every broken source at once.
32
+ * Nothing has been posted to ClickUp when this throws, so the caller's generic
33
+ * error path returns the report and the client can fix the markdown and retry.
22
34
  */
23
- async function prepareMarkdownImages(taskId, markdown) {
35
+ async function resolveImagesOrAbort(markdown, abortNotice) {
24
36
  if (!markdown) {
25
- return { results: [], failures: [], markdown: markdown ?? "" };
37
+ return { markdown: markdown ?? "", images: [] };
26
38
  }
27
39
  // Normalise first, then use the same string for collecting and converting - the
28
40
  // sources must line up with what the converter later looks up.
29
41
  const normalized = (0, clickup_text_1.normalizeImageDestinations)(markdown);
30
42
  const sources = (0, clickup_text_1.collectMarkdownImageSources)(normalized);
31
43
  if (sources.length === 0) {
32
- return { results: [], failures: [], markdown: normalized };
44
+ return { markdown: normalized, images: [] };
45
+ }
46
+ const { resolved, failures } = await (0, attachments_1.resolveMarkdownImages)(sources);
47
+ if (failures.length > 0) {
48
+ throw new Error([
49
+ `${failures.length} image reference(s) could not be used, so ${abortNotice}:`,
50
+ ...failures.map((failure) => ` - ${failure.src}: ${failure.error}`),
51
+ `Fix or remove these image references and retry.`,
52
+ ].join("\n"));
53
+ }
54
+ return { markdown: normalized, images: resolved };
55
+ }
56
+ /**
57
+ * Phase 2: upload the resolved images to the task.
58
+ *
59
+ * Throws on the first upload failure. Everything uploaded before the failure is
60
+ * listed with its CDN URL so a retry can reference those URLs directly (existing
61
+ * ClickUp URLs are embedded without re-uploading).
62
+ */
63
+ async function uploadImagesOrAbort(taskId, images, abortNotice) {
64
+ if (images.length === 0) {
65
+ return [];
66
+ }
67
+ const { uploaded, failure } = await (0, attachments_1.uploadResolvedImages)(taskId, images);
68
+ if (failure) {
69
+ const lines = [
70
+ `Uploading image "${failure.src}" failed, so ${abortNotice}:`,
71
+ ` ${failure.error}`,
72
+ ];
73
+ if (uploaded.length > 0) {
74
+ lines.push(`${uploaded.length} image(s) were already uploaded to task ${taskId} before the failure - on retry, reference these URLs directly to avoid duplicate uploads:`, ...uploaded.map((u) => ` - ${u.attachment.name}: ${u.attachment.url}`));
75
+ }
76
+ throw new Error(lines.join("\n"));
33
77
  }
34
- const results = await (0, attachments_1.uploadMarkdownImages)(taskId, sources);
35
- const failures = results
36
- .filter((result) => !result.attachment)
37
- .map((result) => `${result.src}: ${result.error || "unknown error"}`);
38
- return { results, failures, markdown: normalized };
78
+ return uploaded;
39
79
  }
40
80
  /**
41
81
  * Echo a markdown field back without repeating inline base64 payloads.
@@ -45,25 +85,14 @@ async function prepareMarkdownImages(taskId, markdown) {
45
85
  function summarizeMarkdownForEcho(markdown) {
46
86
  return markdown.replace(/(!\[[^\]]*\]\()data:([^;,)]+)[^)]*(\))/g, (_match, prefix, mimeType, suffix) => `${prefix}[inline ${mimeType} data]${suffix}`);
47
87
  }
48
- /** Report successfully attached images so the caller can link to them later */
49
- function formatAttachedImages(results) {
50
- const attached = results.filter((result) => result.attachment);
51
- if (attached.length === 0) {
88
+ /** Report successfully attached images so the caller can verify and link to them */
89
+ function formatAttachedImages(uploaded) {
90
+ if (uploaded.length === 0) {
52
91
  return [];
53
92
  }
54
93
  return [
55
- `images_attached: ${attached.length}`,
56
- ...attached.map((result) => ` - ${result.attachment.name} (${result.attachment.url})`),
57
- ];
58
- }
59
- /** Render upload failures as response lines so they are never silently swallowed */
60
- function formatImageFailures(failures) {
61
- if (failures.length === 0) {
62
- return [];
63
- }
64
- return [
65
- `WARNING: ${failures.length} image(s) could not be attached and were replaced by their caption:`,
66
- ...failures.map((failure) => ` - ${failure}`),
94
+ `images_attached: ${uploaded.length}`,
95
+ ...uploaded.map((u) => ` - ${u.attachment.name} (${u.attachment.url})`),
67
96
  ];
68
97
  }
69
98
  // Shared schemas for task parameters
@@ -79,9 +108,11 @@ function registerTaskToolsWrite(server, userData) {
79
108
  "Adds a comment to a specific task.",
80
109
  "LINKING BEST PRACTICES:",
81
110
  "- Always reference related tasks using ClickUp URLs (https://app.clickup.com/t/TASK_ID)",
111
+ "- Task URLs become live task references (chip with task name and status), so write them bare - any custom link text on a task URL is replaced by the live task name",
82
112
  "- Include task links when mentioning dependencies, related work, or follow-ups",
83
113
  "- Link to relevant lists, spaces, or other ClickUp entities when applicable",
84
114
  "PROGRESS UPDATES: Include current status, progress information, and next steps.",
115
+ COMMENT_FORMATTING_HINT,
85
116
  IMAGE_SUPPORT_HINT,
86
117
  "IMAGE LAYOUT: An image inside a numbered list breaks ClickUp's numbering. Write walkthrough steps as bold lines with a blank line before and after the image instead (`**1. Open the login page**`).",
87
118
  "If external links are provided, verify they are publicly accessible and incorporate relevant information.",
@@ -92,7 +123,7 @@ function registerTaskToolsWrite(server, userData) {
92
123
  }
93
124
  return descriptionBase.join("\n");
94
125
  })(), {
95
- task_id: zod_1.z.string().min(6).max(9).describe("The 6-9 character task ID to comment on"),
126
+ task_id: zod_1.z.string().min(6).max(16).describe("The 6-16 character task ID to comment on"),
96
127
  comment: zod_1.z.string().min(1).describe("The comment text to add to the task"),
97
128
  }, {
98
129
  readOnlyHint: false,
@@ -100,11 +131,15 @@ function registerTaskToolsWrite(server, userData) {
100
131
  idempotentHint: false,
101
132
  }, async ({ task_id, comment }) => {
102
133
  try {
103
- // Upload referenced images first - the fragments need the attachment objects
104
- // from the upload response, a bare URL renders as an empty tile.
105
- const { results, failures, markdown } = await prepareMarkdownImages(task_id, comment);
134
+ // Resolve and upload referenced images first - the fragments need the
135
+ // attachment objects from the upload response, a bare URL renders as an
136
+ // empty tile. Any image problem aborts BEFORE the comment is posted, so
137
+ // the caller can fix the markdown and retry without creating duplicates.
138
+ const abortNotice = "the comment was NOT posted";
139
+ const { markdown, images } = await resolveImagesOrAbort(comment, abortNotice);
140
+ const uploaded = await uploadImagesOrAbort(task_id, images, abortNotice);
106
141
  // Convert markdown to ClickUp formatted blocks
107
- const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(results));
142
+ const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(uploaded));
108
143
  const requestBody = {
109
144
  comment: commentBlocks,
110
145
  notify_all: true
@@ -133,8 +168,7 @@ function registerTaskToolsWrite(server, userData) {
133
168
  `comment: ${summarizeMarkdownForEcho(comment)}`,
134
169
  `date: ${timestampToIso(commentData.date || Date.now())}`,
135
170
  `user: ${commentData.user?.username || 'Current user'}`,
136
- ...formatAttachedImages(results),
137
- ...formatImageFailures(failures),
171
+ ...formatAttachedImages(uploaded),
138
172
  ].join('\n')
139
173
  }
140
174
  ],
@@ -152,6 +186,89 @@ function registerTaskToolsWrite(server, userData) {
152
186
  };
153
187
  }
154
188
  });
189
+ server.tool("editComment", (() => {
190
+ const descriptionBase = [
191
+ "Replaces the full text of an existing task comment - use this to correct a comment you just posted instead of adding a follow-up comment.",
192
+ "The new text REPLACES the old one completely, it is not appended. Anything worth keeping must be repeated in `comment`.",
193
+ `GUARDRAILS: only comments written by the API token's own user can be edited, and only within ${config_1.CONFIG.commentEditWindowHours} hours of their creation. Older comments and other people's comments must be answered with a new comment via addComment.`,
194
+ "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.",
195
+ "Editing does not reset the creation date, so the edit window does not get extended by editing.",
196
+ COMMENT_FORMATTING_HINT,
197
+ IMAGE_SUPPORT_HINT,
198
+ "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.",
199
+ "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.",
200
+ ];
201
+ if (config_1.CONFIG.primaryLanguageHint && config_1.CONFIG.primaryLanguageHint.toLowerCase() !== 'en') {
202
+ descriptionBase.splice(1, 0, `For optimal results, consider writing comments in '${config_1.CONFIG.primaryLanguageHint}' unless the task is already in another language.`);
203
+ }
204
+ return descriptionBase.join("\n");
205
+ })(), {
206
+ task_id: zod_1.z.string().min(6).max(16).describe("The 6-16 character ID of the task the comment belongs to - needed to locate the comment and to upload images"),
207
+ comment_id: zod_1.z.string().min(1).describe("The ID of the comment to edit, as returned by addComment or getTaskById"),
208
+ comment: zod_1.z.string().min(1).describe("The new comment text, replacing the previous text completely"),
209
+ }, {
210
+ readOnlyHint: false,
211
+ destructiveHint: true,
212
+ idempotentHint: true,
213
+ }, async ({ task_id, comment_id, comment }) => {
214
+ try {
215
+ const [existing, userData] = await Promise.all([
216
+ findTaskComment(task_id, comment_id),
217
+ (0, utils_1.getCurrentUser)(),
218
+ ]);
219
+ assertCommentIsEditable(existing, userData.user.id);
220
+ // Same pipeline as addComment - the undocumented rich `comment` array is
221
+ // accepted by PUT too, so formatting and images survive an edit. Images are
222
+ // resolved and uploaded before the PUT, so a broken reference leaves the
223
+ // existing comment untouched.
224
+ const abortNotice = "the comment was NOT changed";
225
+ const { markdown, images } = await resolveImagesOrAbort(comment, abortNotice);
226
+ const uploaded = await uploadImagesOrAbort(task_id, images, abortNotice);
227
+ const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(uploaded));
228
+ // Only `comment` is sent: sending `comment_text` alongside it appends that
229
+ // string to the blocks instead of being ignored.
230
+ const response = await fetch(`https://api.clickup.com/api/v2/comment/${comment_id}`, {
231
+ method: 'PUT',
232
+ headers: {
233
+ Authorization: config_1.CONFIG.apiKey,
234
+ 'Content-Type': 'application/json'
235
+ },
236
+ body: JSON.stringify({ comment: commentBlocks })
237
+ });
238
+ if (!response.ok) {
239
+ const errorData = await response.json().catch(() => ({}));
240
+ throw new Error(`Error editing comment: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
241
+ }
242
+ return {
243
+ content: [
244
+ {
245
+ type: "text",
246
+ text: [
247
+ `Comment edited successfully!`,
248
+ `comment_id: ${comment_id}`,
249
+ `task_id: ${task_id}`,
250
+ `task_url: https://app.clickup.com/t/${task_id}`,
251
+ `created: ${timestampToIso(existing.date)} (unchanged by the edit)`,
252
+ `previous_text: ${existing.comment_text || '(no plain text available)'}`,
253
+ `new_comment: ${summarizeMarkdownForEcho(comment)}`,
254
+ ...formatAttachedImages(uploaded),
255
+ ].join('\n')
256
+ }
257
+ ],
258
+ };
259
+ }
260
+ catch (error) {
261
+ console.error('Error editing comment:', error);
262
+ return {
263
+ content: [
264
+ {
265
+ type: "text",
266
+ text: `Error editing comment: ${error instanceof Error ? error.message : 'Unknown error'}`,
267
+ },
268
+ ],
269
+ };
270
+ }
271
+ });
155
272
  server.tool("updateTask", (() => {
156
273
  const descriptionBase = [
157
274
  "Updates various aspects of an existing task including dependencies and relationships.",
@@ -170,7 +287,7 @@ function registerTaskToolsWrite(server, userData) {
170
287
  }
171
288
  return descriptionBase.join("\n");
172
289
  })(), {
173
- task_id: zod_1.z.string().min(6).max(9).describe("The 6-9 character task ID to update"),
290
+ task_id: zod_1.z.string().min(6).max(16).describe("The 6-16 character task ID to update"),
174
291
  name: taskNameSchema.optional(),
175
292
  append_description: zod_1.z.string().optional().describe("Optional markdown content to APPEND to existing task description (preserves existing content for safety)"),
176
293
  status: zod_1.z.string().optional().describe("Optional new status name - use getListInfo to see valid options"),
@@ -200,6 +317,19 @@ function registerTaskToolsWrite(server, userData) {
200
317
  throw new Error(`Error fetching task: ${taskResponse.status} ${taskResponse.statusText}`);
201
318
  }
202
319
  const taskData = await taskResponse.json();
320
+ // Resolve and upload description images FIRST - an image problem must
321
+ // abort before dependencies, tags or the task itself are touched, so the
322
+ // caller can fix the markdown and retry the whole call cleanly.
323
+ let appendedDescription;
324
+ let uploadedImages = [];
325
+ if (append_description) {
326
+ const abortNotice = "the task was NOT updated";
327
+ const prepared = await resolveImagesOrAbort(append_description, abortNotice);
328
+ uploadedImages = await uploadImagesOrAbort(task_id, prepared.images, abortNotice);
329
+ // Descriptions render plain markdown, so no image fragments are involved
330
+ // here - the local paths are simply swapped for the CDN URLs.
331
+ appendedDescription = (0, clickup_text_1.rewriteMarkdownImageUrls)(prepared.markdown, (0, attachments_1.toAttachmentMap)(uploadedImages));
332
+ }
203
333
  // Handle dependencies separately since they need individual API calls
204
334
  let dependencyUpdateResults = [];
205
335
  if (blocking !== undefined || waiting_on !== undefined || linked_tasks !== undefined) {
@@ -250,19 +380,11 @@ function registerTaskToolsWrite(server, userData) {
250
380
  }
251
381
  // Handle append-only description update with markdown support
252
382
  let finalDescription;
253
- let imageResults = [];
254
- let imageFailures = [];
255
- if (append_description) {
256
- // Upload first, then swap the local paths for CDN URLs. Descriptions render
257
- // plain markdown, so no image fragments are involved here.
258
- const prepared = await prepareMarkdownImages(task_id, append_description);
259
- imageResults = prepared.results;
260
- imageFailures = prepared.failures;
261
- const appended = (0, clickup_text_1.rewriteMarkdownImageUrls)(prepared.markdown, (0, attachments_1.toAttachmentMap)(imageResults));
383
+ if (appendedDescription !== undefined) {
262
384
  const currentDescription = taskData.markdown_description || "";
263
385
  const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
264
386
  const separator = currentDescription.trim() ? "\n\n---\n" : "";
265
- finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${appended}`;
387
+ finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${appendedDescription}`;
266
388
  }
267
389
  // Build update body without tags (they're handled separately)
268
390
  const updateBody = buildTaskRequestBody({
@@ -324,8 +446,7 @@ function registerTaskToolsWrite(server, userData) {
324
446
  if (tagUpdateResults.length > 0) {
325
447
  responseLines.push('tag_warnings: ' + tagUpdateResults.join('; '));
326
448
  }
327
- responseLines.push(...formatAttachedImages(imageResults));
328
- responseLines.push(...formatImageFailures(imageFailures));
449
+ responseLines.push(...formatAttachedImages(uploadedImages));
329
450
  return {
330
451
  content: [
331
452
  {
@@ -383,6 +504,11 @@ function registerTaskToolsWrite(server, userData) {
383
504
  openWorldHint: true
384
505
  }, async ({ list_id, name, description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees }) => {
385
506
  try {
507
+ // Resolve description images BEFORE creating the task: a broken reference
508
+ // (missing file, dead URL, non-image) must not leave a half-finished task
509
+ // behind. Uploading has to wait until the task exists, though - ClickUp
510
+ // attachments always belong to a task.
511
+ const { markdown: normalizedDescription, images } = await resolveImagesOrAbort(description, "the task was NOT created");
386
512
  const userData = await (0, utils_1.getCurrentUser)();
387
513
  const currentUserId = userData.user.id;
388
514
  const requestBody = buildTaskRequestBody({
@@ -405,10 +531,40 @@ function registerTaskToolsWrite(server, userData) {
405
531
  throw new Error(`Error creating task: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
406
532
  }
407
533
  const createdTask = await response.json();
534
+ // Tags are omitted from the create body by buildTaskRequestBody because they
535
+ // need the dedicated tag endpoints, so apply them here - the same way
536
+ // updateTask does - otherwise the requested tags are silently dropped.
537
+ const tagCreateResults = [];
538
+ if (tags !== undefined && tags.length > 0) {
539
+ for (const tagName of tags) {
540
+ try {
541
+ const addTagResponse = await fetch(`https://api.clickup.com/api/v2/task/${createdTask.id}/tag/${encodeURIComponent(tagName)}`, {
542
+ method: 'POST',
543
+ headers: { Authorization: config_1.CONFIG.apiKey }
544
+ });
545
+ if (!addTagResponse.ok) {
546
+ console.error(`Failed to add tag "${tagName}": ${addTagResponse.status}`);
547
+ tagCreateResults.push(`Failed to add tag: ${tagName}`);
548
+ }
549
+ }
550
+ catch (error) {
551
+ console.error(`Error adding tag "${tagName}":`, error);
552
+ tagCreateResults.push(`Error adding tag: ${tagName}`);
553
+ }
554
+ }
555
+ }
408
556
  // Images can only be attached once the task exists, so the description is
409
557
  // written first with its original sources and then rewritten to the CDN URLs.
410
- const { results: imageResults, failures: imageFailures, markdown: normalizedDescription, } = await prepareMarkdownImages(createdTask.id, description);
411
- const attachmentMap = (0, attachments_1.toAttachmentMap)(imageResults);
558
+ // At this point every source resolved successfully - only the upload API
559
+ // itself can still fail, and then the task already exists, so that is
560
+ // reported as a warning instead of pretending the task was not created.
561
+ const imageWarnings = [];
562
+ const { uploaded, failure: uploadFailure } = await (0, attachments_1.uploadResolvedImages)(createdTask.id, images);
563
+ if (uploadFailure) {
564
+ console.error(`Failed to attach image "${uploadFailure.src}": ${uploadFailure.error}`);
565
+ imageWarnings.push(`WARNING: the task was created, but uploading image "${uploadFailure.src}" failed: ${uploadFailure.error}`, `The description still references the original image source. Fix the problem and add the image via updateTask.`);
566
+ }
567
+ const attachmentMap = (0, attachments_1.toAttachmentMap)(uploaded);
412
568
  if (description && attachmentMap.size > 0) {
413
569
  const rewritten = (0, clickup_text_1.rewriteMarkdownImageUrls)(normalizedDescription, attachmentMap);
414
570
  if (rewritten !== description) {
@@ -423,15 +579,18 @@ function registerTaskToolsWrite(server, userData) {
423
579
  if (!descriptionResponse.ok) {
424
580
  // The task itself exists - report the problem instead of failing the call.
425
581
  console.error(`Failed to write image URLs into description: ${descriptionResponse.status}`);
426
- imageFailures.push(`description update failed (${descriptionResponse.status} ${descriptionResponse.statusText}) - images are attached but not embedded`);
582
+ imageWarnings.push(`WARNING: description update failed (${descriptionResponse.status} ${descriptionResponse.statusText}) - the images are attached to the task but not embedded in the description`);
427
583
  }
428
584
  }
429
585
  }
430
586
  const responseLines = formatTaskResponse(createdTask, 'created', {
431
587
  list_id, name, description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees
432
588
  }, userData);
433
- responseLines.push(...formatAttachedImages(imageResults));
434
- responseLines.push(...formatImageFailures(imageFailures));
589
+ responseLines.push(...formatAttachedImages(uploaded));
590
+ responseLines.push(...imageWarnings);
591
+ if (tagCreateResults.length > 0) {
592
+ responseLines.push('tag_warnings: ' + tagCreateResults.join('; '));
593
+ }
435
594
  return {
436
595
  content: [
437
596
  {
@@ -477,6 +636,93 @@ function formatTimeEstimate(hours) {
477
636
  const displayMinutes = Math.round((hours - displayHours) * 60);
478
637
  return displayHours > 0 ? `${displayHours}h ${displayMinutes}m` : `${displayMinutes}m`;
479
638
  }
639
+ /**
640
+ * Never page further back than this. A generous edit window would otherwise walk
641
+ * the entire comment history of a busy ticket and eat the 100 calls/minute budget.
642
+ */
643
+ const MAX_COMMENT_PAGES = 10;
644
+ /** One page of task comments, newest first, 25 per page */
645
+ async function fetchCommentPage(taskId, cursor) {
646
+ // Note there is no `start_date` parameter - passing one is silently ignored.
647
+ // Older pages are reached with `start` + `start_id` of the previous page's last entry.
648
+ const query = cursor
649
+ ? `?${new URLSearchParams({ start: cursor.start, start_id: cursor.startId })}`
650
+ : "";
651
+ const response = await fetch(`https://api.clickup.com/api/v2/task/${taskId}/comment${query}`, { headers: { Authorization: config_1.CONFIG.apiKey } });
652
+ if (!response.ok) {
653
+ const errorData = await response.json().catch(() => ({}));
654
+ throw new Error(`Error loading comments of task ${taskId}: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
655
+ }
656
+ const data = await response.json();
657
+ return Array.isArray(data.comments) ? data.comments : [];
658
+ }
659
+ /**
660
+ * Load a single comment of a task.
661
+ *
662
+ * ClickUp has no `GET /comment/{id}`, so the task's comment list is the only way
663
+ * to learn a comment's author and age - both of which editComment has to check
664
+ * before touching anything.
665
+ *
666
+ * The list returns the 25 newest comments per page, so a busy ticket needs paging
667
+ * to reach the wanted comment. Paging stops as soon as a page ends outside the edit
668
+ * window: everything older would be refused anyway, which keeps this to a single
669
+ * request in the normal case.
670
+ *
671
+ * Note the list only contains top-level comments; replies inside a thread live
672
+ * behind `/comment/{parent_id}/reply` and are therefore not editable here.
673
+ */
674
+ async function findTaskComment(taskId, commentId) {
675
+ const oldestEditableDate = Date.now() - config_1.CONFIG.commentEditWindowHours * 60 * 60 * 1000;
676
+ let cursor;
677
+ let checked = 0;
678
+ let pages = 0;
679
+ let sawThreadedReplies = false;
680
+ while (pages < MAX_COMMENT_PAGES) {
681
+ const page = await fetchCommentPage(taskId, cursor);
682
+ pages++;
683
+ if (page.length === 0) {
684
+ break;
685
+ }
686
+ const match = page.find((entry) => String(entry.id) === String(commentId));
687
+ if (match) {
688
+ return match;
689
+ }
690
+ checked += page.length;
691
+ sawThreadedReplies || (sawThreadedReplies = page.some((entry) => (entry.reply_count ?? 0) > 0));
692
+ // Comments come back newest first, so once a page runs past the edit window
693
+ // there is nothing editable further back.
694
+ const oldest = page[page.length - 1];
695
+ if (Number(oldest.date) < oldestEditableDate) {
696
+ break;
697
+ }
698
+ cursor = { start: String(oldest.date), startId: String(oldest.id) };
699
+ }
700
+ const threadedHint = sawThreadedReplies
701
+ ? " This task has threaded replies, and replies inside a thread cannot be edited - answer them with a new comment instead."
702
+ : "";
703
+ throw new Error(`Comment ${commentId} was not found on task ${taskId} (${checked} top-level comment(s) checked across ${pages} page(s), newest first).${threadedHint}`);
704
+ }
705
+ /**
706
+ * The whole safety model of editComment.
707
+ *
708
+ * ClickUp cannot tell "written through this MCP" from "written by the token owner
709
+ * in the web UI" - both carry the same user id - so the author check only keeps
710
+ * other people's comments safe, and the time window is what keeps the tool from
711
+ * rewriting history.
712
+ */
713
+ function assertCommentIsEditable(comment, currentUserId) {
714
+ const windowHours = config_1.CONFIG.commentEditWindowHours;
715
+ if (!(windowHours > 0)) {
716
+ throw new Error(`Editing comments is disabled (CLICKUP_COMMENT_EDIT_WINDOW_HOURS=${windowHours}). Add a new comment instead.`);
717
+ }
718
+ if (String(comment.user?.id ?? '') !== String(currentUserId)) {
719
+ throw new Error(`Comment ${comment.id} was written by ${comment.user?.username || 'someone else'} (user_id: ${comment.user?.id ?? 'unknown'}), not by the current user (user_id: ${currentUserId}). Only your own comments can be edited - reply with a new comment instead.`);
720
+ }
721
+ const ageHours = (Date.now() - Number(comment.date)) / (1000 * 60 * 60);
722
+ if (ageHours > windowHours) {
723
+ throw new Error(`Comment ${comment.id} was created ${ageHours.toFixed(1)} hours ago (${timestampToIso(comment.date)}), which is outside the ${windowHours} hour edit window. Add a new comment instead of rewriting an old one.`);
724
+ }
725
+ }
480
726
  /**
481
727
  * Formats timestamp to ISO string with local timezone (not UTC)
482
728
  */
@@ -52,7 +52,7 @@ function formatEntryTime(timestamp) {
52
52
  }
53
53
  function registerTimeToolsRead(server) {
54
54
  server.tool("getTimeEntries", "Gets time entries for a specific task or all user's time entries. Returns last 30 days by default if no dates specified.", {
55
- task_id: zod_1.z.string().min(6).max(9).optional().describe("Optional 6-9 character task ID to filter entries. If not provided, returns all user's time entries."),
55
+ task_id: zod_1.z.string().min(6).max(16).optional().describe("Optional 6-16 character task ID to filter entries. If not provided, returns all user's time entries."),
56
56
  start_date: zod_1.z.string().optional().describe("Optional start date filter as ISO date string (e.g., '2024-10-06T00:00:00+02:00'). Defaults to 30 days ago."),
57
57
  end_date: zod_1.z.string().optional().describe("Optional end date filter as ISO date string (e.g., '2024-10-06T23:59:59+02:00'). Defaults to current date."),
58
58
  list_id: zod_1.z.string().optional().describe("Optional single list ID to filter time entries by a specific list"),
@@ -267,7 +267,7 @@ function registerTimeToolsWrite(server) {
267
267
  "IMPORTANT: Before booking time, check the task's status - booking time on tasks in 'backlog', 'closed', or similar inactive states usually doesn't make sense.",
268
268
  "Suggest moving the task to an active status like 'in progress' first."
269
269
  ].join("\n"), {
270
- task_id: zod_1.z.string().min(6).max(9).describe("The 6-9 character task ID to book time against"),
270
+ task_id: zod_1.z.string().min(6).max(16).describe("The 6-16 character task ID to book time against"),
271
271
  hours: zod_1.z.number().min(0.01).max(24).describe("Hours to book (decimal format, e.g., 0.25 = 15min, 1.5 = 1h 30min)"),
272
272
  description: zod_1.z.string().optional().describe("Optional description for the time entry"),
273
273
  start_time: zod_1.z.string().optional().describe("Optional start time as ISO date string (e.g., '2024-10-06T09:00:00+02:00', defaults to current time)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hauptsache.net/clickup-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.7.3",
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",