@hauptsache.net/clickup-mcp 1.7.0 → 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.
package/README.md CHANGED
@@ -188,6 +188,7 @@ The ClickUp MCP supports three operational modes to balance functionality, secur
188
188
  |------------------------|:------------:|:----:|:-----:|-----------------------------------------------------------------------------------------|
189
189
  | `getTaskById` | ✅ | ✅ | ✅ | Get complete task details including comments, images, and metadata |
190
190
  | `addComment` | ❌ | ❌ | ✅ | Add comments to tasks for collaboration |
191
+ | `editComment` | ❌ | ❌ | ✅ | Correct your own comment within 24h instead of posting a follow-up |
191
192
  | `updateTask` | ❌ | ❌ | ✅ | Update tasks (status, priority, assignees, etc.) with **SAFE APPEND-ONLY** descriptions |
192
193
  | `createTask` | ❌ | ❌ | ✅ | Create new tasks with full markdown support |
193
194
  | `searchTasks` | ✅ | ✅ | ✅ | Find tasks by content, keywords, assignees, or project context |
@@ -223,7 +224,7 @@ Add the mode to your MCP configuration:
223
224
 
224
225
  ## Configuration
225
226
 
226
- This MCP server can be configured using environment variables:
227
+ This MCP server can be configured using environment variables. In the desktop extension (`.mcpb`) installer, the API key, team ID, primary language, upload size limit and comment edit window are offered as form fields; the remaining variables have to be set on the server environment directly.
227
228
 
228
229
  - `CLICKUP_API_KEY`: (Required) Your ClickUp API key.
229
230
  - `CLICKUP_TEAM_ID`: (Required) Your ClickUp Team ID (formerly Workspace ID).
@@ -231,6 +232,7 @@ This MCP server can be configured using environment variables:
231
232
  - `MAX_IMAGES`: (Optional) The maximum number of images to return for a task in `getTaskById`. Defaults to 4.
232
233
  - `MAX_RESPONSE_SIZE_MB`: (Optional) The maximum response size in megabytes for `getTaskById`. Uses intelligent size budgeting to fit the most important images within the limit. Defaults to 1.
233
234
  - `MAX_UPLOAD_SIZE_MB`: (Optional) The maximum size of a single image uploaded when writing comments or descriptions. Defaults to 10.
235
+ - `CLICKUP_COMMENT_EDIT_WINDOW_HOURS`: (Optional) How long after creation `editComment` may still rewrite a comment. Defaults to 24. Set to `0` to disable comment editing entirely.
234
236
  - `CLICKUP_PRIMARY_LANGUAGE`: (Optional) A hint for the primary language used in your ClickUp tasks (e.g., "de" for German, "en" for English). This helps the `searchTask` tool provide more tailored guidance in its description for multilingual searches.
235
237
  - `LANG`: (Optional) If `CLICKUP_PRIMARY_LANGUAGE` is not set, the MCP will check this standard environment variable (e.g., "en_US.UTF-8", "de_DE") as a fallback to infer the primary language.
236
238
 
@@ -288,7 +290,7 @@ This ensures no existing content is ever lost while maintaining a clear audit tr
288
290
 
289
291
  ## Writing Images Into Tickets
290
292
 
291
- `addComment`, `createTask` and `updateTask` accept images as ordinary markdown. Because
293
+ `addComment`, `editComment`, `createTask` and `updateTask` accept images as ordinary markdown. Because
292
294
  this server runs locally, it reads the file itself - so a **local path is enough**:
293
295
 
294
296
  ```markdown
@@ -311,8 +313,11 @@ Notes:
311
313
  - **An image inside a numbered list breaks ClickUp's numbering.** Write walkthrough
312
314
  steps as bold lines with the image between them, as above.
313
315
  - Only real PNG/JPEG/GIF/WebP files are uploaded - the content is checked, not the
314
- extension. A file that fails is reported in the response, and the comment or task is
315
- still written.
316
+ extension. A file that fails **aborts the write**: `addComment`, `editComment` and
317
+ `updateTask` report every broken reference and change nothing, so the markdown can be
318
+ fixed and the call retried without creating duplicates. `createTask` validates its
319
+ images before creating the task; only an upload failing afterwards is reported as a
320
+ warning, since the task already exists at that point.
316
321
  - Attachments always belong to a task, so document pages cannot embed uploads this way.
317
322
 
318
323
  ## Performance & Limitations
@@ -6,6 +6,9 @@ import { ImageMetadataBlock } from "./shared/types";
6
6
  export interface ClickUpTextItem {
7
7
  text?: string;
8
8
  type?: string;
9
+ task_mention?: {
10
+ task_id?: string;
11
+ };
9
12
  image?: {
10
13
  id?: string;
11
14
  name?: string;
@@ -54,6 +57,7 @@ export interface ClickUpCommentBlock {
54
57
  attributes?: {
55
58
  bold?: boolean;
56
59
  italic?: boolean;
60
+ strike?: boolean;
57
61
  code?: boolean;
58
62
  link?: string;
59
63
  'code-block'?: {
@@ -72,6 +76,13 @@ export interface ClickUpCommentBlock {
72
76
  list?: {
73
77
  list: 'bullet' | 'ordered' | 'unchecked' | 'checked';
74
78
  };
79
+ /**
80
+ * Present on task mention fragments. ClickUp renders these as live task references
81
+ * showing the current task name, status and assignee.
82
+ */
83
+ task_mention?: {
84
+ task_id: string;
85
+ };
75
86
  /**
76
87
  * Present on image fragments. ClickUp only renders a preview when this holds the
77
88
  * complete attachment object from the upload response - a bare URL string produces
@@ -111,6 +122,10 @@ export interface EmbeddableAttachment {
111
122
  * `title`/`text` carry the caption; the rest is copied straight from the upload response.
112
123
  */
113
124
  export declare function buildImageFragment(attachment: EmbeddableAttachment, caption: string): ClickUpCommentBlock;
125
+ /**
126
+ * Extract the task ID from a ClickUp task URL, or null if it is not one.
127
+ */
128
+ export declare function parseClickUpTaskUrl(url: string): string | null;
114
129
  /**
115
130
  * Wrap image destinations that contain spaces in angle brackets.
116
131
  *
@@ -1 +1 @@
1
- {"version":3,"file":"clickup-text.d.ts","sourceRoot":"","sources":["../src/clickup-text.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"clickup-text.d.ts","sourceRoot":"","sources":["../src/clickup-text.ts"],"names":[],"mappings":"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"}
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.convertClickUpTextItemsToToolCallResult = convertClickUpTextItemsToToolCallResult;
7
7
  exports.convertMarkdownToToolCallResult = convertMarkdownToToolCallResult;
8
8
  exports.buildImageFragment = buildImageFragment;
9
+ exports.parseClickUpTaskUrl = parseClickUpTaskUrl;
9
10
  exports.normalizeImageDestinations = normalizeImageDestinations;
10
11
  exports.collectMarkdownImageSources = collectMarkdownImageSources;
11
12
  exports.rewriteMarkdownImageUrls = rewriteMarkdownImageUrls;
@@ -35,6 +36,18 @@ function extractThumbnailsFromDataAttachment(attributes) {
35
36
  return {};
36
37
  }
37
38
  }
39
+ /**
40
+ * Render an image reference as markdown, escaping whatever would break the syntax.
41
+ *
42
+ * Reading and writing use the same markdown here on purpose: it lets an agent feed a
43
+ * comment it just read straight back into editComment without losing the images.
44
+ */
45
+ function toMarkdownImage(alt, url) {
46
+ const safeAlt = alt.replace(/[\[\]\r\n]/g, " ").trim();
47
+ // Angle brackets let a URL with spaces or parentheses survive the round trip
48
+ const safeUrl = /[\s()]/.test(url) ? `<${url}>` : url;
49
+ return `![${safeAlt}](${safeUrl})`;
50
+ }
38
51
  /**
39
52
  * Process an array of ClickUp text items into a structured content format
40
53
  * that includes both text and images in their original sequence
@@ -49,11 +62,24 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
49
62
  // Track current formatting state to avoid unnecessary close/reopen
50
63
  let activeBold = false;
51
64
  let activeItalic = false;
65
+ let activeStrike = false;
52
66
  let activeCode = false;
67
+ // ClickUp emits one '\n' fragment with a code-block attribute per code LINE;
68
+ // consecutive ones belong to the same fenced block when read back as markdown.
69
+ let inCodeBlock = false;
70
+ let currentFenceLang = '';
71
+ const closeCodeFence = () => {
72
+ if (inCodeBlock) {
73
+ currentTextBlock += '```\n';
74
+ inCodeBlock = false;
75
+ currentFenceLang = '';
76
+ }
77
+ };
53
78
  for (let i = 0; i < textItems.length; i++) {
54
79
  const item = textItems[i];
55
80
  // Handle image items
56
81
  if (item.type === "image" && item.image && item.image.url) {
82
+ closeCodeFence();
57
83
  const imageFileName = item.image.name || item.image.title || "image";
58
84
  const imageUrl = item.image.url;
59
85
  const altText = item.text || imageFileName;
@@ -84,8 +110,10 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
84
110
  }
85
111
  continue;
86
112
  }
87
- // Add image URL reference inline to current text block
88
- currentTextBlock += `\nImage: ${imageFileName} - ${imageUrl}`;
113
+ // Reference the image in the same markdown syntax the write tools accept, so a
114
+ // comment read here can be handed back to editComment unchanged and keep its
115
+ // images - an existing ClickUp attachment URL is re-embedded without re-uploading.
116
+ currentTextBlock += `\n${toMarkdownImage(altText, imageUrl)}`;
89
117
  // Get working thumbnail URLs from data-attachment if available
90
118
  const extractedThumbnails = extractThumbnailsFromDataAttachment(item.attributes);
91
119
  // Determine best thumbnail URLs (prefer extracted over API thumbnails)
@@ -118,6 +146,27 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
118
146
  else if (typeof item.text === "string") {
119
147
  // Check if this is a newline with block formatting (header, blockquote, list)
120
148
  if (item.text === '\n' && item.attributes) {
149
+ // Code block formatting: append this line to the open fence (or open one).
150
+ // The attribute value carries the language, either directly or nested as
151
+ // {'code-block': lang}; 'plain' means no language.
152
+ if (item.attributes['code-block']) {
153
+ const rawLang = item.attributes['code-block'];
154
+ const lang = typeof rawLang === 'string' ? rawLang : rawLang?.['code-block'] ?? '';
155
+ const fenceLang = lang === 'plain' ? '' : lang;
156
+ if (inCodeBlock && fenceLang !== currentFenceLang) {
157
+ closeCodeFence();
158
+ }
159
+ if (!inCodeBlock) {
160
+ currentTextBlock += '```' + fenceLang + '\n';
161
+ inCodeBlock = true;
162
+ currentFenceLang = fenceLang;
163
+ }
164
+ currentTextBlock += currentLine + '\n';
165
+ currentLine = "";
166
+ continue;
167
+ }
168
+ // Any other line terminator ends a code block
169
+ closeCodeFence();
121
170
  // Header formatting
122
171
  if (item.attributes.header) {
123
172
  const level = item.attributes.header;
@@ -148,17 +197,8 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
148
197
  break;
149
198
  }
150
199
  }
151
- // Code block formatting
152
- else if (item.attributes['code-block']) {
153
- // Wrap the current line in code block markers
154
- currentLine = '```\n' + currentLine + '\n```';
155
- }
156
200
  // Add formatted line to text block
157
- currentTextBlock += currentLine;
158
- // Add newline unless it's code block (already has newlines)
159
- if (!item.attributes['code-block']) {
160
- currentTextBlock += '\n';
161
- }
201
+ currentTextBlock += currentLine + '\n';
162
202
  currentLine = ""; // Reset for next line
163
203
  continue;
164
204
  }
@@ -167,15 +207,18 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
167
207
  // Determine current and next formatting state
168
208
  const hasBold = item.attributes?.bold === true;
169
209
  const hasItalic = item.attributes?.italic === true;
210
+ const hasStrike = item.attributes?.strike === true;
170
211
  const hasLink = item.attributes?.link;
171
212
  // Look ahead to next non-newline block
172
213
  let nextHasBold = false;
173
214
  let nextHasItalic = false;
215
+ let nextHasStrike = false;
174
216
  for (let j = i + 1; j < textItems.length; j++) {
175
217
  const nextItem = textItems[j];
176
218
  if (nextItem.text !== '\n' || !nextItem.attributes) {
177
219
  nextHasBold = nextItem.attributes?.bold === true;
178
220
  nextHasItalic = nextItem.attributes?.italic === true;
221
+ nextHasStrike = nextItem.attributes?.strike === true;
179
222
  break;
180
223
  }
181
224
  }
@@ -185,14 +228,20 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
185
228
  prefix += "**";
186
229
  if (hasItalic && !activeItalic)
187
230
  prefix += "*";
231
+ if (hasStrike && !activeStrike)
232
+ prefix += "~~";
188
233
  // Build suffix (close formatting that won't continue)
189
234
  let suffix = "";
235
+ if (hasStrike && !nextHasStrike)
236
+ suffix += "~~";
190
237
  if (hasItalic && !nextHasItalic)
191
238
  suffix += "*";
192
239
  if (hasBold && !nextHasBold)
193
240
  suffix += "**";
194
241
  // Close formatting that's active but not in this block
195
242
  let closingPrefix = "";
243
+ if (activeStrike && !hasStrike)
244
+ closingPrefix += "~~";
196
245
  if (activeBold && !hasBold)
197
246
  closingPrefix += "**";
198
247
  if (activeItalic && !hasItalic)
@@ -201,6 +250,7 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
201
250
  // Update state
202
251
  activeBold = hasBold && nextHasBold;
203
252
  activeItalic = hasItalic && nextHasItalic;
253
+ activeStrike = hasStrike && nextHasStrike;
204
254
  // Link formatting (wraps everything)
205
255
  if (hasLink) {
206
256
  formattedText = `[${formattedText}](${hasLink})`;
@@ -212,6 +262,7 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
212
262
  // Add to current line (not text block yet)
213
263
  if (item.text === '\n') {
214
264
  // Plain newline without formatting
265
+ closeCodeFence();
215
266
  currentTextBlock += currentLine + '\n';
216
267
  currentLine = "";
217
268
  }
@@ -219,12 +270,18 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
219
270
  currentLine += formattedText;
220
271
  }
221
272
  }
273
+ // Task mentions render as the task URL so the reference survives the round trip:
274
+ // writing that URL back through addComment/editComment regenerates the mention.
275
+ else if (item.type === "task_mention" && item.task_mention?.task_id) {
276
+ currentLine += `https://app.clickup.com/t/${item.task_mention.task_id}`;
277
+ }
222
278
  // Handle other types of items like bookmarks or whatever clickup can think of
223
279
  else {
224
280
  currentTextBlock += JSON.stringify(item);
225
281
  }
226
282
  }
227
283
  // Add any remaining text
284
+ closeCodeFence();
228
285
  if (currentLine) {
229
286
  currentTextBlock += currentLine;
230
287
  }
@@ -292,9 +349,9 @@ function convertMarkdownToToolCallResult(markdownText, attachments) {
292
349
  // Check if this image URL exists in our attachments
293
350
  const attachment = attachmentMap.get(imageUrl);
294
351
  if (attachment) {
295
- // Add image URL reference inline to current text block
352
+ // Keep the markdown syntax, so the reference stays usable in a write call
296
353
  const imageFileName = altText || "image";
297
- currentTextBlock += `\nImage: ${imageFileName} - ${imageUrl}`;
354
+ currentTextBlock += `\n${toMarkdownImage(imageFileName, imageUrl)}`;
298
355
  // Only create image_metadata if we have at least one thumbnail (never use original image)
299
356
  if (attachment.thumbnail_large || attachment.thumbnail_medium || attachment.thumbnail_small) {
300
357
  // Push accumulated text (including image URL) as a text block
@@ -411,6 +468,22 @@ function buildImageFragment(attachment, caption) {
411
468
  }
412
469
  return fragment;
413
470
  }
471
+ /**
472
+ * Matches a plain ClickUp task URL, with or without the team segment:
473
+ * https://app.clickup.com/t/86cb3t6t2 or https://app.clickup.com/t/4500611/86cb3t6t2
474
+ *
475
+ * Deliberately narrow: custom task IDs (PREFIX-123) and URLs carrying a query or
476
+ * fragment (e.g. ?comment=... deep links) do NOT match, because a mention would
477
+ * either not resolve or lose the anchor - those stay ordinary links.
478
+ */
479
+ const CLICKUP_TASK_URL_PATTERN = /^https?:\/\/app\.clickup\.com\/t\/(?:\d+\/)?([a-z0-9]{6,12})\/?$/;
480
+ /**
481
+ * Extract the task ID from a ClickUp task URL, or null if it is not one.
482
+ */
483
+ function parseClickUpTaskUrl(url) {
484
+ const match = url.match(CLICKUP_TASK_URL_PATTERN);
485
+ return match ? match[1] : null;
486
+ }
414
487
  /**
415
488
  * Wrap image destinations that contain spaces in angle brackets.
416
489
  *
@@ -585,25 +658,119 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0, attachmentsByS
585
658
  // Code block
586
659
  const codeNode = node;
587
660
  if (codeNode.value) {
588
- blocks.push({ text: codeNode.value, attributes: {} });
589
- blocks.push({
590
- text: '\n',
591
- attributes: { 'code-block': { 'code-block': codeNode.lang || 'plain' } }
592
- });
661
+ pushCodeBlockLines(blocks, codeNode.value, codeNode.lang || 'plain');
593
662
  }
594
663
  break;
595
664
  case 'thematicBreak':
596
665
  // Horizontal rule - just add a line break
597
666
  blocks.push({ text: '\n', attributes: {} });
598
667
  break;
668
+ case 'table': {
669
+ // ClickUp comments cannot render tables (there is no table fragment in the
670
+ // comment format - unknown attributes are stored but render as plain text).
671
+ // Re-render the table as an aligned pipe table inside a code block so the
672
+ // information survives and stays readable in monospace.
673
+ const tableText = serializeTableAsAlignedPipes(node);
674
+ if (tableText) {
675
+ pushCodeBlockLines(blocks, tableText, 'plain');
676
+ }
677
+ break;
678
+ }
599
679
  default:
600
680
  // For any other block-level nodes, try to process children
601
681
  if ('children' in node && Array.isArray(node.children)) {
602
682
  walkMdastNodes(node.children, currentAttrs, blocks, depth, attachmentsBySrc);
603
683
  }
684
+ else if ('value' in node && typeof node.value === 'string') {
685
+ // Last-resort safety net: never drop text content silently
686
+ blocks.push({ text: node.value, attributes: {} });
687
+ }
688
+ break;
689
+ }
690
+ }
691
+ }
692
+ /**
693
+ * Emit a (possibly multi-line) code block the way ClickUp's Quill-based format
694
+ * expects it: block attributes apply per line, so EVERY line needs its own '\n'
695
+ * fragment carrying the code-block attribute. A single text fragment with embedded
696
+ * newlines renders only its last line as code - the rest degrades to plain text.
697
+ */
698
+ function pushCodeBlockLines(blocks, code, lang) {
699
+ for (const line of code.split('\n')) {
700
+ if (line) {
701
+ blocks.push({ text: line, attributes: {} });
702
+ }
703
+ blocks.push({
704
+ text: '\n',
705
+ attributes: { 'code-block': { 'code-block': lang } }
706
+ });
707
+ }
708
+ }
709
+ /**
710
+ * Serialize phrasing content back to compact markdown for use inside a code block.
711
+ * Inline formatting markers are kept so nothing is lost, even though a code block
712
+ * renders them literally.
713
+ */
714
+ function serializePhrasingToMarkdown(nodes) {
715
+ let out = '';
716
+ for (const node of nodes) {
717
+ switch (node.type) {
718
+ case 'text':
719
+ out += node.value;
720
+ break;
721
+ case 'strong':
722
+ out += `**${serializePhrasingToMarkdown(node.children)}**`;
723
+ break;
724
+ case 'emphasis':
725
+ out += `*${serializePhrasingToMarkdown(node.children)}*`;
726
+ break;
727
+ case 'delete':
728
+ out += `~~${serializePhrasingToMarkdown(node.children)}~~`;
729
+ break;
730
+ case 'inlineCode':
731
+ out += `\`${node.value}\``;
732
+ break;
733
+ case 'link':
734
+ out += `[${serializePhrasingToMarkdown(node.children)}](${node.url})`;
735
+ break;
736
+ case 'image':
737
+ out += node.alt || node.url;
738
+ break;
739
+ case 'break':
740
+ out += ' ';
741
+ break;
742
+ default:
743
+ if ('value' in node && typeof node.value === 'string') {
744
+ out += node.value;
745
+ }
746
+ else if ('children' in node && Array.isArray(node.children)) {
747
+ out += serializePhrasingToMarkdown(node.children);
748
+ }
604
749
  break;
605
750
  }
606
751
  }
752
+ return out;
753
+ }
754
+ /**
755
+ * Render an mdast table as a column-aligned pipe table string.
756
+ */
757
+ function serializeTableAsAlignedPipes(table) {
758
+ const rows = table.children.map((row) => row.children.map((cell) => serializePhrasingToMarkdown(cell.children).replace(/\|/g, '\\|').trim()));
759
+ if (rows.length === 0) {
760
+ return '';
761
+ }
762
+ const colCount = Math.max(...rows.map((r) => r.length));
763
+ const widths = [];
764
+ for (let col = 0; col < colCount; col++) {
765
+ widths[col] = Math.max(3, ...rows.map((r) => (r[col] ?? '').length));
766
+ }
767
+ const renderRow = (cells) => '| ' + widths.map((w, col) => (cells[col] ?? '').padEnd(w)).join(' | ') + ' |';
768
+ const lines = [renderRow(rows[0])];
769
+ lines.push('| ' + widths.map((w) => '-'.repeat(w)).join(' | ') + ' |');
770
+ for (const row of rows.slice(1)) {
771
+ lines.push(renderRow(row));
772
+ }
773
+ return lines.join('\n');
607
774
  }
608
775
  /**
609
776
  * Recursively walk phrasing content (inline nodes) and build ClickUp blocks
@@ -652,6 +819,11 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks, attachmentsBySrc) {
652
819
  currentAttrs.italic = true;
653
820
  walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
654
821
  break;
822
+ case 'delete':
823
+ // GFM strikethrough (~~text~~) - ClickUp renders this via the strike attribute
824
+ currentAttrs.strike = true;
825
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
826
+ break;
655
827
  case 'inlineCode':
656
828
  // Inline code
657
829
  if (node.value) {
@@ -662,11 +834,20 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks, attachmentsBySrc) {
662
834
  });
663
835
  }
664
836
  break;
665
- case 'link':
666
- // Link - recurse with link attribute
837
+ case 'link': {
838
+ // A link to a ClickUp task becomes a real task mention, matching what the
839
+ // ClickUp UI does when a task URL is pasted. The mention renders the live
840
+ // task name, so any custom link text is intentionally replaced by it.
841
+ const mentionedTaskId = parseClickUpTaskUrl(node.url);
842
+ if (mentionedTaskId) {
843
+ blocks.push({ type: 'task_mention', task_mention: { task_id: mentionedTaskId } });
844
+ break;
845
+ }
846
+ // Ordinary link - recurse with link attribute
667
847
  currentAttrs.link = node.url;
668
848
  walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
669
849
  break;
850
+ }
670
851
  case 'break':
671
852
  // Line break - add as plain text
672
853
  blocks.push({ text: '\n', attributes: {} });
@@ -61,31 +61,52 @@ export declare function captionToFilename(caption: string, fallbackName: string)
61
61
  * Upload a single image to a task and return the full attachment object.
62
62
  */
63
63
  export declare function uploadTaskAttachment(taskId: string, filename: string, bytes: Buffer, mimeType: string): Promise<ClickUpUploadedAttachment>;
64
- /** One markdown image reference and what became of it */
65
- export interface ImageUploadResult {
64
+ /** A markdown image whose source resolved to uploadable bytes or an existing attachment */
65
+ export interface ResolvedMarkdownImage {
66
66
  /** The original `src` as written in the markdown */
67
67
  src: string;
68
- /** Attachment to embed, or null when the upload failed */
69
- attachment: ClickUpUploadedAttachment | null;
70
- /** Reason the upload failed, for reporting back to the caller */
71
- error?: string;
68
+ alt: string;
69
+ resolved: ResolvedImageSource;
70
+ }
71
+ /** One markdown image reference that could not be used, and why */
72
+ export interface ImageFailure {
73
+ src: string;
74
+ error: string;
72
75
  }
73
76
  /**
74
- * Upload every image referenced in the markdown to the given task.
77
+ * Phase 1 of attaching images: resolve every source without writing anything.
75
78
  *
76
- * Uploads run sequentially: a typical comment has a handful of screenshots, and
77
- * N uploads plus one write call stays well inside ClickUp's 100 calls/minute.
78
- * A failing image never fails the whole batch - the caller writes the comment
79
- * anyway and reports which images did not make it.
79
+ * Reads local files, downloads http(s) URLs, decodes data URIs and validates
80
+ * magic bytes and size. Failures are collected instead of thrown so the caller
81
+ * can report every broken reference at once - and abort before anything is
82
+ * posted to ClickUp. Identical sources are resolved once.
80
83
  */
81
- export declare function uploadMarkdownImages(taskId: string, images: {
84
+ export declare function resolveMarkdownImages(images: {
82
85
  src: string;
83
86
  alt: string;
84
- }[], baseDir?: string): Promise<ImageUploadResult[]>;
87
+ }[], baseDir?: string): Promise<{
88
+ resolved: ResolvedMarkdownImage[];
89
+ failures: ImageFailure[];
90
+ }>;
91
+ /** A successfully uploaded (or reused) attachment for one markdown source */
92
+ export interface UploadedMarkdownImage {
93
+ src: string;
94
+ attachment: ClickUpUploadedAttachment;
95
+ }
85
96
  /**
86
- * Map from markdown `src` to the attachment that should be embedded for it.
87
- * Sources whose upload failed are absent, so the converters fall back to text.
97
+ * Phase 2: upload the resolved images to the task.
98
+ *
99
+ * Uploads run sequentially: a typical comment has a handful of screenshots, and
100
+ * N uploads plus one write call stays well inside ClickUp's 100 calls/minute.
101
+ * Stops at the first upload error - an API failure is unlikely to heal mid-batch,
102
+ * and everything uploaded so far is returned so the caller can tell a retry to
103
+ * reference those CDN URLs directly instead of uploading again.
88
104
  */
89
- export declare function toAttachmentMap(results: ImageUploadResult[]): Map<string, ClickUpUploadedAttachment>;
105
+ export declare function uploadResolvedImages(taskId: string, images: ResolvedMarkdownImage[]): Promise<{
106
+ uploaded: UploadedMarkdownImage[];
107
+ failure: ImageFailure | null;
108
+ }>;
109
+ /** Map from markdown `src` to the attachment that should be embedded for it. */
110
+ export declare function toAttachmentMap(uploaded: UploadedMarkdownImage[]): Map<string, ClickUpUploadedAttachment>;
90
111
  export {};
91
112
  //# sourceMappingURL=attachments.d.ts.map
@@ -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,yDAAyD;AACzD,MAAM,WAAW,iBAAiB;IAChC,oDAAoD;IACpD,GAAG,EAAE,MAAM,CAAC;IACZ,0DAA0D;IAC1D,UAAU,EAAE,yBAAyB,GAAG,IAAI,CAAC;IAC7C,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,EAAE,EACtC,OAAO,GAAE,MAAsB,GAC9B,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAqD9B;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,iBAAiB,EAAE,GAC3B,GAAG,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAQxC"}
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"}
@@ -4,7 +4,8 @@ exports.isClickUpAttachmentUrl = isClickUpAttachmentUrl;
4
4
  exports.resolveImageSource = resolveImageSource;
5
5
  exports.captionToFilename = captionToFilename;
6
6
  exports.uploadTaskAttachment = uploadTaskAttachment;
7
- exports.uploadMarkdownImages = uploadMarkdownImages;
7
+ exports.resolveMarkdownImages = resolveMarkdownImages;
8
+ exports.uploadResolvedImages = uploadResolvedImages;
8
9
  exports.toAttachmentMap = toAttachmentMap;
9
10
  const buffer_1 = require("buffer");
10
11
  const crypto_1 = require("crypto");
@@ -185,31 +186,51 @@ async function uploadTaskAttachment(taskId, filename, bytes, mimeType) {
185
186
  return attachment;
186
187
  }
187
188
  /**
188
- * Upload every image referenced in the markdown to the given task.
189
+ * Phase 1 of attaching images: resolve every source without writing anything.
189
190
  *
190
- * Uploads run sequentially: a typical comment has a handful of screenshots, and
191
- * N uploads plus one write call stays well inside ClickUp's 100 calls/minute.
192
- * A failing image never fails the whole batch - the caller writes the comment
193
- * anyway and reports which images did not make it.
191
+ * Reads local files, downloads http(s) URLs, decodes data URIs and validates
192
+ * magic bytes and size. Failures are collected instead of thrown so the caller
193
+ * can report every broken reference at once - and abort before anything is
194
+ * posted to ClickUp. Identical sources are resolved once.
194
195
  */
195
- async function uploadMarkdownImages(taskId, images, baseDir = process.cwd()) {
196
- const results = [];
197
- // Identical sources are uploaded once and reused.
198
- const seen = new Map();
196
+ async function resolveMarkdownImages(images, baseDir = process.cwd()) {
197
+ const resolved = [];
198
+ const failures = [];
199
+ const seen = new Set();
199
200
  for (const { src, alt } of images) {
200
- const cached = seen.get(src);
201
- if (cached) {
202
- results.push(cached);
201
+ if (seen.has(src)) {
203
202
  continue;
204
203
  }
205
- let result;
204
+ seen.add(src);
205
+ try {
206
+ resolved.push({ src, alt, resolved: await resolveImageSource(src, baseDir) });
207
+ }
208
+ catch (error) {
209
+ const message = error instanceof Error ? error.message : "unknown error";
210
+ console.error(`Cannot use image "${src}": ${message}`);
211
+ failures.push({ src, error: message });
212
+ }
213
+ }
214
+ return { resolved, failures };
215
+ }
216
+ /**
217
+ * Phase 2: upload the resolved images to the task.
218
+ *
219
+ * Uploads run sequentially: a typical comment has a handful of screenshots, and
220
+ * N uploads plus one write call stays well inside ClickUp's 100 calls/minute.
221
+ * Stops at the first upload error - an API failure is unlikely to heal mid-batch,
222
+ * and everything uploaded so far is returned so the caller can tell a retry to
223
+ * reference those CDN URLs directly instead of uploading again.
224
+ */
225
+ async function uploadResolvedImages(taskId, images) {
226
+ const uploaded = [];
227
+ for (const { src, alt, resolved } of images) {
206
228
  try {
207
- const resolved = await resolveImageSource(src, baseDir);
208
229
  if (resolved.kind === "existing") {
209
230
  // Already on ClickUp's CDN - synthesise the minimal attachment shape so
210
231
  // the fragment builder has something to work with.
211
232
  const name = decodeURIComponent((0, path_1.basename)(new URL(resolved.url).pathname));
212
- result = {
233
+ uploaded.push({
213
234
  src,
214
235
  attachment: {
215
236
  id: name,
@@ -221,34 +242,27 @@ async function uploadMarkdownImages(taskId, images, baseDir = process.cwd()) {
221
242
  thumbnail_medium: resolved.url,
222
243
  thumbnail_large: resolved.url,
223
244
  },
224
- };
245
+ });
225
246
  }
226
247
  else {
227
248
  const filename = captionToFilename(alt, resolved.suggestedName);
228
249
  const attachment = await uploadTaskAttachment(taskId, filename, resolved.bytes, resolved.mimeType);
229
- result = { src, attachment };
250
+ uploaded.push({ src, attachment });
230
251
  }
231
252
  }
232
253
  catch (error) {
233
254
  const message = error instanceof Error ? error.message : "unknown error";
234
255
  console.error(`Failed to attach image "${src}": ${message}`);
235
- result = { src, attachment: null, error: message };
256
+ return { uploaded, failure: { src, error: message } };
236
257
  }
237
- seen.set(src, result);
238
- results.push(result);
239
258
  }
240
- return results;
259
+ return { uploaded, failure: null };
241
260
  }
242
- /**
243
- * Map from markdown `src` to the attachment that should be embedded for it.
244
- * Sources whose upload failed are absent, so the converters fall back to text.
245
- */
246
- function toAttachmentMap(results) {
261
+ /** Map from markdown `src` to the attachment that should be embedded for it. */
262
+ function toAttachmentMap(uploaded) {
247
263
  const map = new Map();
248
- for (const result of results) {
249
- if (result.attachment) {
250
- map.set(result.src, result.attachment);
251
- }
264
+ for (const { src, attachment } of uploaded) {
265
+ map.set(src, attachment);
252
266
  }
253
267
  return map;
254
268
  }
@@ -6,6 +6,7 @@ export declare const CONFIG: {
6
6
  maxImages: number;
7
7
  maxResponseSizeMB: number;
8
8
  maxUploadSizeMB: number;
9
+ commentEditWindowHours: number;
9
10
  primaryLanguageHint: string | undefined;
10
11
  mode: McpMode;
11
12
  };
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/shared/config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc,oBAA2D,CAAC;AAkDvF,MAAM,MAAM,OAAO,GAAG,cAAc,GAAG,MAAM,GAAG,OAAO,CAAC;AAUxD,eAAO,MAAM,MAAM;;;;;;;;CAWlB,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/shared/config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc,oBAA2D,CAAC;AAkDvF,MAAM,MAAM,OAAO,GAAG,cAAc,GAAG,MAAM,GAAG,OAAO,CAAC;AA8CxD,eAAO,MAAM,MAAM;;;;;;;;;CAqBlB,CAAC"}
@@ -48,6 +48,36 @@ if (rawMode === 'read-minimal' || rawMode === 'read') {
48
48
  else if (rawMode && rawMode !== 'write') {
49
49
  console.error(`Invalid CLICKUP_MCP_MODE "${rawMode}". Using default "write". Valid options: read-minimal, read, write`);
50
50
  }
51
+ /**
52
+ * Numeric settings are exposed as optional MCPB user_config fields, so their env
53
+ * values can arrive blank or - if the host does not substitute an unset optional
54
+ * field - as the literal `${user_config.x}` placeholder. Both mean "not configured"
55
+ * and must fall back to the default rather than fail or turn into NaN.
56
+ */
57
+ function readOptionalEnv(name) {
58
+ const raw = process.env[name]?.trim();
59
+ if (!raw || /^\$\{.*}$/.test(raw)) {
60
+ return undefined;
61
+ }
62
+ return raw;
63
+ }
64
+ /**
65
+ * A typo must not silently change a limit: `parseFloat("Infinity")` would disable
66
+ * the comment age check altogether and `parseFloat("abc")` would yield NaN, which
67
+ * compares false against every size and so lifts the upload limit instead of
68
+ * enforcing it. Fail at startup instead.
69
+ */
70
+ function parseNumericEnv(name, fallback, { min, expectation }) {
71
+ const raw = readOptionalEnv(name);
72
+ if (raw === undefined) {
73
+ return fallback;
74
+ }
75
+ const value = Number(raw);
76
+ if (!Number.isFinite(value) || value < min) {
77
+ throw new Error(`Invalid ${name} "${raw}". ${expectation}`);
78
+ }
79
+ return value;
80
+ }
51
81
  exports.CONFIG = {
52
82
  apiKey: process.env.CLICKUP_API_KEY,
53
83
  teamId: process.env.CLICKUP_TEAM_ID,
@@ -56,7 +86,17 @@ exports.CONFIG = {
56
86
  // Upper bound for a single image uploaded to ClickUp. Unlike maxResponseSizeMB this is
57
87
  // not about context window budget - it only guards against accidentally pushing huge
58
88
  // files into a ticket.
59
- maxUploadSizeMB: process.env.MAX_UPLOAD_SIZE_MB ? parseFloat(process.env.MAX_UPLOAD_SIZE_MB) : 10,
89
+ maxUploadSizeMB: parseNumericEnv("MAX_UPLOAD_SIZE_MB", 10, {
90
+ min: 1,
91
+ expectation: "Expected a positive number of megabytes.",
92
+ }),
93
+ // How long after creation a comment may still be edited. ClickUp has no way to tell
94
+ // "written by this MCP" apart from "written by the token owner in the UI", so this
95
+ // window is the actual guard against rewriting history. 0 disables editing entirely.
96
+ commentEditWindowHours: parseNumericEnv("CLICKUP_COMMENT_EDIT_WINDOW_HOURS", 24, {
97
+ min: 0,
98
+ expectation: "Expected a non-negative number of hours (0 disables editComment).",
99
+ }),
60
100
  primaryLanguageHint: detectedLanguageHint, // Store the cleaned code directly
61
101
  mode: mcpMode,
62
102
  };
@@ -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;AAgGpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QA+ctE"}
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;AA+IpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QAumBtE"}
@@ -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.",
@@ -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(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
+ });
155
272
  server.tool("updateTask", (() => {
156
273
  const descriptionBase = [
157
274
  "Updates various aspects of an existing task including dependencies and relationships.",
@@ -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
  */
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.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",