@sebastienheyd/clickup-mcp 1.7.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,88 @@ const zod_1 = require("zod");
5
5
  const config_1 = require("../shared/config");
6
6
  const utils_1 = require("../shared/utils");
7
7
  const clickup_text_1 = require("../clickup-text");
8
+ const attachments_1 = require("../shared/attachments");
9
+ /**
10
+ * Shared wording for the image support of every markdown field in this file.
11
+ * Kept in one place so the tools stay consistent about what a client may pass.
12
+ */
13
+ const IMAGE_SUPPORT_HINT = [
14
+ "IMAGES: Reference images with normal markdown - `![caption](/absolute/path/to/screenshot.png)`.",
15
+ "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.",
16
+ "Also accepted: `data:` URIs, http(s) URLs (downloaded and re-uploaded), and existing ClickUp attachment URLs (embedded as-is).",
17
+ "The caption becomes the attachment filename, which is what ClickUp displays beneath the image - so write a caption that reads well.",
18
+ ].join("\n");
19
+ /**
20
+ * Phase 1 of image handling: parse the markdown and resolve every image source
21
+ * (read files, download URLs, decode data URIs) WITHOUT writing anything.
22
+ *
23
+ * Throws when any reference is unusable, listing every broken source at once.
24
+ * Nothing has been posted to ClickUp when this throws, so the caller's generic
25
+ * error path returns the report and the client can fix the markdown and retry.
26
+ */
27
+ async function resolveImagesOrAbort(markdown, abortNotice) {
28
+ if (!markdown) {
29
+ return { markdown: markdown ?? "", images: [] };
30
+ }
31
+ // Normalise first, then use the same string for collecting and converting - the
32
+ // sources must line up with what the converter later looks up.
33
+ const normalized = (0, clickup_text_1.normalizeImageDestinations)(markdown);
34
+ const sources = (0, clickup_text_1.collectMarkdownImageSources)(normalized);
35
+ if (sources.length === 0) {
36
+ return { markdown: normalized, images: [] };
37
+ }
38
+ const { resolved, failures } = await (0, attachments_1.resolveMarkdownImages)(sources);
39
+ if (failures.length > 0) {
40
+ throw new Error([
41
+ `${failures.length} image reference(s) could not be used, so ${abortNotice}:`,
42
+ ...failures.map((failure) => ` - ${failure.src}: ${failure.error}`),
43
+ `Fix or remove these image references and retry.`,
44
+ ].join("\n"));
45
+ }
46
+ return { markdown: normalized, images: resolved };
47
+ }
48
+ /**
49
+ * Phase 2: upload the resolved images to the task.
50
+ *
51
+ * Throws on the first upload failure. Everything uploaded before the failure is
52
+ * listed with its CDN URL so a retry can reference those URLs directly (existing
53
+ * ClickUp URLs are embedded without re-uploading).
54
+ */
55
+ async function uploadImagesOrAbort(taskId, images, abortNotice) {
56
+ if (images.length === 0) {
57
+ return [];
58
+ }
59
+ const { uploaded, failure } = await (0, attachments_1.uploadResolvedImages)(taskId, images);
60
+ if (failure) {
61
+ const lines = [
62
+ `Uploading image "${failure.src}" failed, so ${abortNotice}:`,
63
+ ` ${failure.error}`,
64
+ ];
65
+ if (uploaded.length > 0) {
66
+ 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}`));
67
+ }
68
+ throw new Error(lines.join("\n"));
69
+ }
70
+ return uploaded;
71
+ }
72
+ /**
73
+ * Echo a markdown field back without repeating inline base64 payloads.
74
+ * Without this a single data-URI screenshot would be mirrored back into the
75
+ * response, costing as many tokens again as it did going in.
76
+ */
77
+ function summarizeMarkdownForEcho(markdown) {
78
+ return markdown.replace(/(!\[[^\]]*\]\()data:([^;,)]+)[^)]*(\))/g, (_match, prefix, mimeType, suffix) => `${prefix}[inline ${mimeType} data]${suffix}`);
79
+ }
80
+ /** Report successfully attached images so the caller can verify and link to them */
81
+ function formatAttachedImages(uploaded) {
82
+ if (uploaded.length === 0) {
83
+ return [];
84
+ }
85
+ return [
86
+ `images_attached: ${uploaded.length}`,
87
+ ...uploaded.map((u) => ` - ${u.attachment.name} (${u.attachment.url})`),
88
+ ];
89
+ }
8
90
  // Shared schemas for task parameters
9
91
  const taskNameSchema = zod_1.z.string().min(1).describe("The name/title of the task");
10
92
  const taskPrioritySchema = zod_1.z.enum(["urgent", "high", "normal", "low"]).optional().describe("Optional priority level");
@@ -20,9 +102,12 @@ function registerTaskToolsWrite(server, userData) {
20
102
  "Adds a comment to a specific task.",
21
103
  "LINKING BEST PRACTICES:",
22
104
  "- Always reference related tasks using ClickUp URLs (https://app.clickup.com/t/TASK_ID)",
105
+ "- 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",
23
106
  "- Include task links when mentioning dependencies, related work, or follow-ups",
24
107
  "- Link to relevant lists, spaces, or other ClickUp entities when applicable",
25
108
  "PROGRESS UPDATES: Include current status, progress information, and next steps.",
109
+ IMAGE_SUPPORT_HINT,
110
+ "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**`).",
26
111
  "If external links are provided, verify they are publicly accessible and incorporate relevant information.",
27
112
  "Check the task's current status - if it's in 'backlog' or similar inactive states, suggest moving it to an active status like 'in progress' when work is being done."
28
113
  ];
@@ -43,8 +128,15 @@ function registerTaskToolsWrite(server, userData) {
43
128
  try {
44
129
  // Resolve custom task ID to internal ID if needed
45
130
  const resolved_task_id = await (0, utils_1.resolveTaskId)(task_id);
131
+ // Resolve and upload referenced images first - the fragments need the
132
+ // attachment objects from the upload response, a bare URL renders as an
133
+ // empty tile. Any image problem aborts BEFORE the comment is posted, so
134
+ // the caller can fix the markdown and retry without creating duplicates.
135
+ const abortNotice = "the comment was NOT posted";
136
+ const { markdown, images } = await resolveImagesOrAbort(comment, abortNotice);
137
+ const uploaded = await uploadImagesOrAbort(resolved_task_id, images, abortNotice);
46
138
  // Convert markdown to ClickUp formatted blocks
47
- const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(comment);
139
+ const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(uploaded));
48
140
  const requestBody = {
49
141
  comment: commentBlocks,
50
142
  notify_all: true
@@ -70,9 +162,10 @@ function registerTaskToolsWrite(server, userData) {
70
162
  `Comment added successfully!`,
71
163
  `comment_id: ${commentData.id || 'N/A'}`,
72
164
  `task_id: ${resolved_task_id}`,
73
- `comment: ${comment}`,
165
+ `comment: ${summarizeMarkdownForEcho(comment)}`,
74
166
  `date: ${timestampToIso(commentData.date || Date.now())}`,
75
167
  `user: ${commentData.user?.username || 'Current user'}`,
168
+ ...formatAttachedImages(uploaded),
76
169
  ].join('\n')
77
170
  }
78
171
  ],
@@ -90,6 +183,92 @@ function registerTaskToolsWrite(server, userData) {
90
183
  };
91
184
  }
92
185
  });
186
+ tool("editComment", (() => {
187
+ const descriptionBase = [
188
+ "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.",
189
+ "The new text REPLACES the old one completely, it is not appended. Anything worth keeping must be repeated in `comment`.",
190
+ `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.`,
191
+ "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.",
192
+ "Editing does not reset the creation date, so the edit window does not get extended by editing.",
193
+ IMAGE_SUPPORT_HINT,
194
+ "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.",
195
+ "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.",
196
+ ];
197
+ if (config_1.CONFIG.primaryLanguageHint && config_1.CONFIG.primaryLanguageHint.toLowerCase() !== 'en') {
198
+ descriptionBase.splice(1, 0, `For optimal results, consider writing comments in '${config_1.CONFIG.primaryLanguageHint}' unless the task is already in another language.`);
199
+ }
200
+ return descriptionBase.join("\n");
201
+ })(), {
202
+ task_id: zod_1.z.string().min(1).refine(val => (0, utils_1.isTaskId)(val) || (0, utils_1.isCustomTaskId)(val), {
203
+ message: "Must be an internal task ID (6+ alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
204
+ }).describe("The ID of the task the comment belongs to - needed to locate the comment and to upload images. Internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
205
+ comment_id: zod_1.z.string().min(1).describe("The ID of the comment to edit, as returned by addComment or getTaskById"),
206
+ comment: zod_1.z.string().min(1).describe("The new comment text, replacing the previous text completely"),
207
+ }, {
208
+ readOnlyHint: false,
209
+ destructiveHint: true,
210
+ idempotentHint: true,
211
+ }, async ({ task_id, comment_id, comment }) => {
212
+ try {
213
+ // Resolve custom task ID to internal ID if needed
214
+ const resolved_task_id = await (0, utils_1.resolveTaskId)(task_id);
215
+ const [existing, userData] = await Promise.all([
216
+ findTaskComment(resolved_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(resolved_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: ${resolved_task_id}`,
250
+ `task_url: https://app.clickup.com/t/${resolved_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
+ });
93
272
  tool("updateTask", (() => {
94
273
  const descriptionBase = [
95
274
  "Updates various aspects of an existing task including dependencies and relationships.",
@@ -97,6 +276,7 @@ function registerTaskToolsWrite(server, userData) {
97
276
  "Use getListInfo first to see valid status options.",
98
277
  "SAFETY FEATURE: Description updates are APPEND-ONLY to prevent data loss - existing content is preserved.",
99
278
  "STATUS UPDATES: Use the `addComment` tool for progress reports, work logs, and status updates rather than the task description.",
279
+ IMAGE_SUPPORT_HINT,
100
280
  "Task descriptions should contain requirements, specifications, and core task information.",
101
281
  "LINKING IN DESCRIPTIONS: When appending descriptions, include links to related tasks, lists, or external resources.",
102
282
  "IMPORTANT: When updating tasks (especially when booking time or adding progress), ensure the status makes sense for the work being done - tasks in 'backlog' or 'closed' states usually shouldn't have active work.",
@@ -141,6 +321,19 @@ function registerTaskToolsWrite(server, userData) {
141
321
  throw new Error(`Error fetching task: ${taskResponse.status} ${taskResponse.statusText}`);
142
322
  }
143
323
  const taskData = await taskResponse.json();
324
+ // Resolve and upload description images FIRST - an image problem must
325
+ // abort before dependencies, tags or the task itself are touched, so the
326
+ // caller can fix the markdown and retry the whole call cleanly.
327
+ let appendedDescription;
328
+ let uploadedImages = [];
329
+ if (append_description) {
330
+ const abortNotice = "the task was NOT updated";
331
+ const prepared = await resolveImagesOrAbort(append_description, abortNotice);
332
+ uploadedImages = await uploadImagesOrAbort(resolved_task_id, prepared.images, abortNotice);
333
+ // Descriptions render plain markdown, so no image fragments are involved
334
+ // here - the local paths are simply swapped for the CDN URLs.
335
+ appendedDescription = (0, clickup_text_1.rewriteMarkdownImageUrls)(prepared.markdown, (0, attachments_1.toAttachmentMap)(uploadedImages));
336
+ }
144
337
  // Handle dependencies separately since they need individual API calls
145
338
  let dependencyUpdateResults = [];
146
339
  if (blocking !== undefined || waiting_on !== undefined || linked_tasks !== undefined) {
@@ -191,11 +384,11 @@ function registerTaskToolsWrite(server, userData) {
191
384
  }
192
385
  // Handle append-only description update with markdown support
193
386
  let finalDescription;
194
- if (append_description) {
387
+ if (appendedDescription !== undefined) {
195
388
  const currentDescription = taskData.markdown_description || "";
196
389
  const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
197
390
  const separator = currentDescription.trim() ? "\n\n---\n" : "";
198
- finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${append_description}`;
391
+ finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${appendedDescription}`;
199
392
  }
200
393
  // Build update body without tags (they're handled separately)
201
394
  const updateBody = buildTaskRequestBody({
@@ -257,6 +450,7 @@ function registerTaskToolsWrite(server, userData) {
257
450
  if (tagUpdateResults.length > 0) {
258
451
  responseLines.push('tag_warnings: ' + tagUpdateResults.join('; '));
259
452
  }
453
+ responseLines.push(...formatAttachedImages(uploadedImages));
260
454
  return {
261
455
  content: [
262
456
  {
@@ -288,6 +482,7 @@ function registerTaskToolsWrite(server, userData) {
288
482
  "- The response will include the new task's clickable URL - always share this link",
289
483
  "Use getListInfo first to understand the list context and available statuses.",
290
484
  "Task descriptions support full markdown formatting including **bold**, *italic*, lists, links, and code blocks.",
485
+ IMAGE_SUPPORT_HINT,
291
486
  "BEST PRACTICE: Every task creation should result in sharing the clickable task URL for future reference."
292
487
  ];
293
488
  if (config_1.CONFIG.primaryLanguageHint && config_1.CONFIG.primaryLanguageHint.toLowerCase() !== 'en') {
@@ -313,6 +508,11 @@ function registerTaskToolsWrite(server, userData) {
313
508
  openWorldHint: true
314
509
  }, async ({ list_id, name, description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees }) => {
315
510
  try {
511
+ // Resolve description images BEFORE creating the task: a broken reference
512
+ // (missing file, dead URL, non-image) must not leave a half-finished task
513
+ // behind. Uploading has to wait until the task exists, though - ClickUp
514
+ // attachments always belong to a task.
515
+ const { markdown: normalizedDescription, images } = await resolveImagesOrAbort(description, "the task was NOT created");
316
516
  const userData = await (0, utils_1.getCurrentUser)();
317
517
  const currentUserId = userData.user.id;
318
518
  const requestBody = buildTaskRequestBody({
@@ -358,9 +558,41 @@ function registerTaskToolsWrite(server, userData) {
358
558
  createdTask = await refreshResponse.json();
359
559
  }
360
560
  }
561
+ // Images can only be attached once the task exists, so the description is
562
+ // written first with its original sources and then rewritten to the CDN URLs.
563
+ // At this point every source resolved successfully - only the upload API
564
+ // itself can still fail, and then the task already exists, so that is
565
+ // reported as a warning instead of pretending the task was not created.
566
+ const imageWarnings = [];
567
+ const { uploaded, failure: uploadFailure } = await (0, attachments_1.uploadResolvedImages)(createdTask.id, images);
568
+ if (uploadFailure) {
569
+ console.error(`Failed to attach image "${uploadFailure.src}": ${uploadFailure.error}`);
570
+ 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.`);
571
+ }
572
+ const attachmentMap = (0, attachments_1.toAttachmentMap)(uploaded);
573
+ if (description && attachmentMap.size > 0) {
574
+ const rewritten = (0, clickup_text_1.rewriteMarkdownImageUrls)(normalizedDescription, attachmentMap);
575
+ if (rewritten !== description) {
576
+ const descriptionResponse = await fetch(`https://api.clickup.com/api/v2/task/${createdTask.id}`, {
577
+ method: 'PUT',
578
+ headers: {
579
+ Authorization: config_1.CONFIG.apiKey,
580
+ 'Content-Type': 'application/json'
581
+ },
582
+ body: JSON.stringify({ markdown_description: rewritten })
583
+ });
584
+ if (!descriptionResponse.ok) {
585
+ // The task itself exists - report the problem instead of failing the call.
586
+ console.error(`Failed to write image URLs into description: ${descriptionResponse.status}`);
587
+ imageWarnings.push(`WARNING: description update failed (${descriptionResponse.status} ${descriptionResponse.statusText}) - the images are attached to the task but not embedded in the description`);
588
+ }
589
+ }
590
+ }
361
591
  const responseLines = formatTaskResponse(createdTask, 'created', {
362
592
  list_id, name, description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees
363
593
  }, userData);
594
+ responseLines.push(...formatAttachedImages(uploaded));
595
+ responseLines.push(...imageWarnings);
364
596
  return {
365
597
  content: [
366
598
  {
@@ -406,6 +638,93 @@ function formatTimeEstimate(hours) {
406
638
  const displayMinutes = Math.round((hours - displayHours) * 60);
407
639
  return displayHours > 0 ? `${displayHours}h ${displayMinutes}m` : `${displayMinutes}m`;
408
640
  }
641
+ /**
642
+ * Never page further back than this. A generous edit window would otherwise walk
643
+ * the entire comment history of a busy ticket and eat the 100 calls/minute budget.
644
+ */
645
+ const MAX_COMMENT_PAGES = 10;
646
+ /** One page of task comments, newest first, 25 per page */
647
+ async function fetchCommentPage(taskId, cursor) {
648
+ // Note there is no `start_date` parameter - passing one is silently ignored.
649
+ // Older pages are reached with `start` + `start_id` of the previous page's last entry.
650
+ const query = cursor
651
+ ? `?${new URLSearchParams({ start: cursor.start, start_id: cursor.startId })}`
652
+ : "";
653
+ const response = await fetch(`https://api.clickup.com/api/v2/task/${taskId}/comment${query}`, { headers: { Authorization: config_1.CONFIG.apiKey } });
654
+ if (!response.ok) {
655
+ const errorData = await response.json().catch(() => ({}));
656
+ throw new Error(`Error loading comments of task ${taskId}: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
657
+ }
658
+ const data = await response.json();
659
+ return Array.isArray(data.comments) ? data.comments : [];
660
+ }
661
+ /**
662
+ * Load a single comment of a task.
663
+ *
664
+ * ClickUp has no `GET /comment/{id}`, so the task's comment list is the only way
665
+ * to learn a comment's author and age - both of which editComment has to check
666
+ * before touching anything.
667
+ *
668
+ * The list returns the 25 newest comments per page, so a busy ticket needs paging
669
+ * to reach the wanted comment. Paging stops as soon as a page ends outside the edit
670
+ * window: everything older would be refused anyway, which keeps this to a single
671
+ * request in the normal case.
672
+ *
673
+ * Note the list only contains top-level comments; replies inside a thread live
674
+ * behind `/comment/{parent_id}/reply` and are therefore not editable here.
675
+ */
676
+ async function findTaskComment(taskId, commentId) {
677
+ const oldestEditableDate = Date.now() - config_1.CONFIG.commentEditWindowHours * 60 * 60 * 1000;
678
+ let cursor;
679
+ let checked = 0;
680
+ let pages = 0;
681
+ let sawThreadedReplies = false;
682
+ while (pages < MAX_COMMENT_PAGES) {
683
+ const page = await fetchCommentPage(taskId, cursor);
684
+ pages++;
685
+ if (page.length === 0) {
686
+ break;
687
+ }
688
+ const match = page.find((entry) => String(entry.id) === String(commentId));
689
+ if (match) {
690
+ return match;
691
+ }
692
+ checked += page.length;
693
+ sawThreadedReplies || (sawThreadedReplies = page.some((entry) => (entry.reply_count ?? 0) > 0));
694
+ // Comments come back newest first, so once a page runs past the edit window
695
+ // there is nothing editable further back.
696
+ const oldest = page[page.length - 1];
697
+ if (Number(oldest.date) < oldestEditableDate) {
698
+ break;
699
+ }
700
+ cursor = { start: String(oldest.date), startId: String(oldest.id) };
701
+ }
702
+ const threadedHint = sawThreadedReplies
703
+ ? " This task has threaded replies, and replies inside a thread cannot be edited - answer them with a new comment instead."
704
+ : "";
705
+ throw new Error(`Comment ${commentId} was not found on task ${taskId} (${checked} top-level comment(s) checked across ${pages} page(s), newest first).${threadedHint}`);
706
+ }
707
+ /**
708
+ * The whole safety model of editComment.
709
+ *
710
+ * ClickUp cannot tell "written through this MCP" from "written by the token owner
711
+ * in the web UI" - both carry the same user id - so the author check only keeps
712
+ * other people's comments safe, and the time window is what keeps the tool from
713
+ * rewriting history.
714
+ */
715
+ function assertCommentIsEditable(comment, currentUserId) {
716
+ const windowHours = config_1.CONFIG.commentEditWindowHours;
717
+ if (!(windowHours > 0)) {
718
+ throw new Error(`Editing comments is disabled (CLICKUP_COMMENT_EDIT_WINDOW_HOURS=${windowHours}). Add a new comment instead.`);
719
+ }
720
+ if (String(comment.user?.id ?? '') !== String(currentUserId)) {
721
+ 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.`);
722
+ }
723
+ const ageHours = (Date.now() - Number(comment.date)) / (1000 * 60 * 60);
724
+ if (ageHours > windowHours) {
725
+ 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.`);
726
+ }
727
+ }
409
728
  /**
410
729
  * Formats timestamp to ISO string with local timezone (not UTC)
411
730
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sebastienheyd/clickup-mcp",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
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",
@@ -16,11 +16,12 @@
16
16
  "start": "node dist/index.js",
17
17
  "dev": "npx tsc -w & nodemon dist/index.js",
18
18
  "cli": "npx ts-node src/cli.ts",
19
+ "smoke": "npx ts-node src/protocol-smoke.ts",
19
20
  "prettier": "prettier --write src/**/*.ts",
20
- "prepublishOnly": "rm -r dist && npm run build",
21
+ "prepublishOnly": "rm -r dist && npm run build && chmod +x dist/index.js",
21
22
  "release": "npm run build && npm publish --access public && git add . && git commit -m \"Release v$(node -p 'require(\"./package.json\").version')\" && git tag -a v$(node -p 'require(\"./package.json\").version') -m \"Release v$(node -p 'require(\"./package.json\").version')\" && git push && git push --tags",
22
23
  "mcpb": "npm run build && mcpb pack . ClickUp.mcpb",
23
- "test": "node --test -r ts-node/register src/**/*.test.ts"
24
+ "test": "node --test -r ts-node/register -r ./src/tests/setup.ts src/**/*.test.ts"
24
25
  },
25
26
  "keywords": [
26
27
  "clickup",