@hauptsache.net/clickup-mcp 1.6.2 → 1.7.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.
@@ -5,6 +5,96 @@ 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
+ /**
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");
21
+ const IMAGE_SUPPORT_HINT = [
22
+ "IMAGES: Reference images with normal markdown - `![caption](/absolute/path/to/screenshot.png)`.",
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.",
24
+ "Also accepted: `data:` URIs, http(s) URLs (downloaded and re-uploaded), and existing ClickUp attachment URLs (embedded as-is).",
25
+ "The caption becomes the attachment filename, which is what ClickUp displays beneath the image - so write a caption that reads well.",
26
+ ].join("\n");
27
+ /**
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.
34
+ */
35
+ async function resolveImagesOrAbort(markdown, abortNotice) {
36
+ if (!markdown) {
37
+ return { markdown: markdown ?? "", images: [] };
38
+ }
39
+ // Normalise first, then use the same string for collecting and converting - the
40
+ // sources must line up with what the converter later looks up.
41
+ const normalized = (0, clickup_text_1.normalizeImageDestinations)(markdown);
42
+ const sources = (0, clickup_text_1.collectMarkdownImageSources)(normalized);
43
+ if (sources.length === 0) {
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"));
77
+ }
78
+ return uploaded;
79
+ }
80
+ /**
81
+ * Echo a markdown field back without repeating inline base64 payloads.
82
+ * Without this a single data-URI screenshot would be mirrored back into the
83
+ * response, costing as many tokens again as it did going in.
84
+ */
85
+ function summarizeMarkdownForEcho(markdown) {
86
+ return markdown.replace(/(!\[[^\]]*\]\()data:([^;,)]+)[^)]*(\))/g, (_match, prefix, mimeType, suffix) => `${prefix}[inline ${mimeType} data]${suffix}`);
87
+ }
88
+ /** Report successfully attached images so the caller can verify and link to them */
89
+ function formatAttachedImages(uploaded) {
90
+ if (uploaded.length === 0) {
91
+ return [];
92
+ }
93
+ return [
94
+ `images_attached: ${uploaded.length}`,
95
+ ...uploaded.map((u) => ` - ${u.attachment.name} (${u.attachment.url})`),
96
+ ];
97
+ }
8
98
  // Shared schemas for task parameters
9
99
  const taskNameSchema = zod_1.z.string().min(1).describe("The name/title of the task");
10
100
  const taskPrioritySchema = zod_1.z.enum(["urgent", "high", "normal", "low"]).optional().describe("Optional priority level");
@@ -18,9 +108,13 @@ function registerTaskToolsWrite(server, userData) {
18
108
  "Adds a comment to a specific task.",
19
109
  "LINKING BEST PRACTICES:",
20
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",
21
112
  "- Include task links when mentioning dependencies, related work, or follow-ups",
22
113
  "- Link to relevant lists, spaces, or other ClickUp entities when applicable",
23
114
  "PROGRESS UPDATES: Include current status, progress information, and next steps.",
115
+ COMMENT_FORMATTING_HINT,
116
+ IMAGE_SUPPORT_HINT,
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**`).",
24
118
  "If external links are provided, verify they are publicly accessible and incorporate relevant information.",
25
119
  "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."
26
120
  ];
@@ -37,8 +131,15 @@ function registerTaskToolsWrite(server, userData) {
37
131
  idempotentHint: false,
38
132
  }, async ({ task_id, comment }) => {
39
133
  try {
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);
40
141
  // Convert markdown to ClickUp formatted blocks
41
- const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(comment);
142
+ const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(uploaded));
42
143
  const requestBody = {
43
144
  comment: commentBlocks,
44
145
  notify_all: true
@@ -64,9 +165,10 @@ function registerTaskToolsWrite(server, userData) {
64
165
  `Comment added successfully!`,
65
166
  `comment_id: ${commentData.id || 'N/A'}`,
66
167
  `task_id: ${task_id}`,
67
- `comment: ${comment}`,
168
+ `comment: ${summarizeMarkdownForEcho(comment)}`,
68
169
  `date: ${timestampToIso(commentData.date || Date.now())}`,
69
170
  `user: ${commentData.user?.username || 'Current user'}`,
171
+ ...formatAttachedImages(uploaded),
70
172
  ].join('\n')
71
173
  }
72
174
  ],
@@ -84,6 +186,89 @@ function registerTaskToolsWrite(server, userData) {
84
186
  };
85
187
  }
86
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(9).describe("The 6-9 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
+ });
87
272
  server.tool("updateTask", (() => {
88
273
  const descriptionBase = [
89
274
  "Updates various aspects of an existing task including dependencies and relationships.",
@@ -91,6 +276,7 @@ function registerTaskToolsWrite(server, userData) {
91
276
  "Use getListInfo first to see valid status options.",
92
277
  "SAFETY FEATURE: Description updates are APPEND-ONLY to prevent data loss - existing content is preserved.",
93
278
  "STATUS UPDATES: Use the `addComment` tool for progress reports, work logs, and status updates rather than the task description.",
279
+ IMAGE_SUPPORT_HINT,
94
280
  "Task descriptions should contain requirements, specifications, and core task information.",
95
281
  "LINKING IN DESCRIPTIONS: When appending descriptions, include links to related tasks, lists, or external resources.",
96
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.",
@@ -131,6 +317,19 @@ function registerTaskToolsWrite(server, userData) {
131
317
  throw new Error(`Error fetching task: ${taskResponse.status} ${taskResponse.statusText}`);
132
318
  }
133
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
+ }
134
333
  // Handle dependencies separately since they need individual API calls
135
334
  let dependencyUpdateResults = [];
136
335
  if (blocking !== undefined || waiting_on !== undefined || linked_tasks !== undefined) {
@@ -181,11 +380,11 @@ function registerTaskToolsWrite(server, userData) {
181
380
  }
182
381
  // Handle append-only description update with markdown support
183
382
  let finalDescription;
184
- if (append_description) {
383
+ if (appendedDescription !== undefined) {
185
384
  const currentDescription = taskData.markdown_description || "";
186
385
  const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
187
386
  const separator = currentDescription.trim() ? "\n\n---\n" : "";
188
- finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${append_description}`;
387
+ finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${appendedDescription}`;
189
388
  }
190
389
  // Build update body without tags (they're handled separately)
191
390
  const updateBody = buildTaskRequestBody({
@@ -247,6 +446,7 @@ function registerTaskToolsWrite(server, userData) {
247
446
  if (tagUpdateResults.length > 0) {
248
447
  responseLines.push('tag_warnings: ' + tagUpdateResults.join('; '));
249
448
  }
449
+ responseLines.push(...formatAttachedImages(uploadedImages));
250
450
  return {
251
451
  content: [
252
452
  {
@@ -278,6 +478,7 @@ function registerTaskToolsWrite(server, userData) {
278
478
  "- The response will include the new task's clickable URL - always share this link",
279
479
  "Use getListInfo first to understand the list context and available statuses.",
280
480
  "Task descriptions support full markdown formatting including **bold**, *italic*, lists, links, and code blocks.",
481
+ IMAGE_SUPPORT_HINT,
281
482
  "BEST PRACTICE: Every task creation should result in sharing the clickable task URL for future reference."
282
483
  ];
283
484
  if (config_1.CONFIG.primaryLanguageHint && config_1.CONFIG.primaryLanguageHint.toLowerCase() !== 'en') {
@@ -303,6 +504,11 @@ function registerTaskToolsWrite(server, userData) {
303
504
  openWorldHint: true
304
505
  }, async ({ list_id, name, description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees }) => {
305
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");
306
512
  const userData = await (0, utils_1.getCurrentUser)();
307
513
  const currentUserId = userData.user.id;
308
514
  const requestBody = buildTaskRequestBody({
@@ -325,9 +531,66 @@ function registerTaskToolsWrite(server, userData) {
325
531
  throw new Error(`Error creating task: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
326
532
  }
327
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
+ }
556
+ // Images can only be attached once the task exists, so the description is
557
+ // written first with its original sources and then rewritten to the CDN URLs.
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);
568
+ if (description && attachmentMap.size > 0) {
569
+ const rewritten = (0, clickup_text_1.rewriteMarkdownImageUrls)(normalizedDescription, attachmentMap);
570
+ if (rewritten !== description) {
571
+ const descriptionResponse = await fetch(`https://api.clickup.com/api/v2/task/${createdTask.id}`, {
572
+ method: 'PUT',
573
+ headers: {
574
+ Authorization: config_1.CONFIG.apiKey,
575
+ 'Content-Type': 'application/json'
576
+ },
577
+ body: JSON.stringify({ markdown_description: rewritten })
578
+ });
579
+ if (!descriptionResponse.ok) {
580
+ // The task itself exists - report the problem instead of failing the call.
581
+ console.error(`Failed to write image URLs into description: ${descriptionResponse.status}`);
582
+ imageWarnings.push(`WARNING: description update failed (${descriptionResponse.status} ${descriptionResponse.statusText}) - the images are attached to the task but not embedded in the description`);
583
+ }
584
+ }
585
+ }
328
586
  const responseLines = formatTaskResponse(createdTask, 'created', {
329
587
  list_id, name, description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees
330
588
  }, userData);
589
+ responseLines.push(...formatAttachedImages(uploaded));
590
+ responseLines.push(...imageWarnings);
591
+ if (tagCreateResults.length > 0) {
592
+ responseLines.push('tag_warnings: ' + tagCreateResults.join('; '));
593
+ }
331
594
  return {
332
595
  content: [
333
596
  {
@@ -373,6 +636,93 @@ function formatTimeEstimate(hours) {
373
636
  const displayMinutes = Math.round((hours - displayHours) * 60);
374
637
  return displayHours > 0 ? `${displayHours}h ${displayMinutes}m` : `${displayMinutes}m`;
375
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
+ }
376
726
  /**
377
727
  * Formats timestamp to ISO string with local timezone (not UTC)
378
728
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hauptsache.net/clickup-mcp",
3
- "version": "1.6.2",
3
+ "version": "1.7.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",
@@ -14,11 +14,12 @@
14
14
  "start": "node dist/index.js",
15
15
  "dev": "npx tsc -w & nodemon dist/index.js",
16
16
  "cli": "npx ts-node src/cli.ts",
17
+ "smoke": "npx ts-node src/protocol-smoke.ts",
17
18
  "prettier": "prettier --write src/**/*.ts",
18
19
  "prepublishOnly": "rm -r dist && npm run build",
19
20
  "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",
20
21
  "mcpb": "npm run build && mcpb pack . ClickUp.mcpb",
21
- "test": "node --test -r ts-node/register src/**/*.test.ts"
22
+ "test": "node --test -r ts-node/register -r ./src/tests/setup.ts src/**/*.test.ts"
22
23
  },
23
24
  "keywords": [
24
25
  "clickup",