@sebastienheyd/clickup-mcp 1.7.4 → 1.8.1

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
@@ -13,7 +13,7 @@ Model Context Protocol (MCP) server enabling AI assistants to interact with Clic
13
13
  | **Setup** | Local npm/npx install | Remote MCP (no install) |
14
14
  | **Authentication** | API key only | OAuth only |
15
15
  | **Task Context** | Complete with comments, status history, inline images | Requires mutiple tool calls for full contxt |
16
- | **Image Support** | Inline images with smart size budgeting | Not documented |
16
+ | **Image Support** | Read and write: inline images with smart size budgeting, and `![](local/path.png)` uploads automatically | Upload via separate tool calls; base64 capped at ~200KB |
17
17
  | **Search** | Fuzzy search on recent tasks (limited scope) | Full ClickUp search database |
18
18
  | **Documents** | CRUD operations | CRUD + document search |
19
19
  | **Time Tracking** | View and create entries | Timers and entries |
@@ -24,6 +24,7 @@ Model Context Protocol (MCP) server enabling AI assistants to interact with Clic
24
24
 
25
25
  **Choose this MCP when:**
26
26
  - You need rich task context with inline images for AI coding tools
27
+ - You want to write screenshots into tickets by local file path (running locally, it reads the file itself instead of taking base64)
27
28
  - You need API key authentication for automation or CI/CD pipelines
28
29
  - You want the `read-minimal` mode optimized for development workflows
29
30
 
@@ -189,6 +190,7 @@ The ClickUp MCP supports three operational modes to balance functionality, secur
189
190
  |------------------------|:------------:|:----:|:-----:|-----------------------------------------------------------------------------------------|
190
191
  | `getTaskById` | ✅ | ✅ | ✅ | Get complete task details including comments, images, and metadata |
191
192
  | `addComment` | ❌ | ❌ | ✅ | Add comments to tasks for collaboration |
193
+ | `editComment` | ❌ | ❌ | ✅ | Correct your own comment within 24h instead of posting a follow-up |
192
194
  | `updateTask` | ❌ | ❌ | ✅ | Update tasks (status, priority, assignees, etc.) with **SAFE APPEND-ONLY** descriptions |
193
195
  | `createTask` | ❌ | ❌ | ✅ | Create new tasks with full markdown support |
194
196
  | `searchTasks` | ✅ | ✅ | ✅ | Find tasks by content, keywords, assignees, or project context |
@@ -231,6 +233,8 @@ This MCP server can be configured using environment variables:
231
233
  - `CLICKUP_MCP_MODE`: (Optional) Controls which tools are available. Options: `read-minimal`, `read`, `write` (default).
232
234
  - `MAX_IMAGES`: (Optional) The maximum number of images to return for a task in `getTaskById`. Defaults to 4.
233
235
  - `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.
236
+ - `MAX_UPLOAD_SIZE_MB`: (Optional) The maximum size of a single image uploaded when writing comments or descriptions. Defaults to 10.
237
+ - `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
238
  - `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
239
  - `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
240
 
@@ -286,6 +290,38 @@ When updating task descriptions, content is safely appended:
286
290
 
287
291
  This ensures no existing content is ever lost while maintaining a clear audit trail.
288
292
 
293
+ ## Writing Images Into Tickets
294
+
295
+ `addComment`, `editComment`, `createTask` and `updateTask` accept images as ordinary markdown. Because
296
+ this server runs locally, it reads the file itself - so a **local path is enough**:
297
+
298
+ ```markdown
299
+ Ist umgesetzt. So sieht es aus:
300
+
301
+ **1. Login öffnen** – der Kunde gibt nur seine E-Mail-Adresse ein.
302
+
303
+ ![Die Login-Maske fragt nur nach der E-Mail](/Users/me/shots/login.png)
304
+ ```
305
+
306
+ Accepted sources: local file paths, `data:` URIs, http(s) URLs (downloaded, then
307
+ re-uploaded), and existing ClickUp attachment URLs (embedded without re-uploading).
308
+
309
+ Notes:
310
+
311
+ - **Prefer paths over base64.** A path costs a few tokens; the same screenshot as a
312
+ `data:` URI costs roughly 4/3 of its file size in the request.
313
+ - **The caption becomes the attachment filename**, and that filename is what ClickUp
314
+ displays beneath the image - so write a caption that reads well.
315
+ - **An image inside a numbered list breaks ClickUp's numbering.** Write walkthrough
316
+ steps as bold lines with the image between them, as above.
317
+ - Only real PNG/JPEG/GIF/WebP files are uploaded - the content is checked, not the
318
+ extension. A file that fails **aborts the write**: `addComment`, `editComment` and
319
+ `updateTask` report every broken reference and change nothing, so the markdown can be
320
+ fixed and the call retried without creating duplicates. `createTask` validates its
321
+ images before creating the task; only an upload failing afterwards is reported as a
322
+ warning, since the task already exists at that point.
323
+ - Attachments always belong to a task, so document pages cannot embed uploads this way.
324
+
289
325
  ## Performance & Limitations
290
326
 
291
327
  **Optimized for AI Workflows:**
package/dist/cli.js CHANGED
@@ -116,7 +116,9 @@ async function main() {
116
116
  // Parse parameters
117
117
  for (let i = 1; i < args.length; i++) {
118
118
  const arg = args[i];
119
- const match = arg.match(/^([^=]+)=(.*)$/);
119
+ // The `s` flag matters: without it `.` stops at a newline and multi-line values
120
+ // (markdown descriptions, comments with images) are silently skipped entirely.
121
+ const match = arg.match(/^([^=]+)=(.*)$/s);
120
122
  if (match) {
121
123
  const [, key, value] = match;
122
124
  // Try to parse as JSON if it looks like a JSON value
@@ -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;
@@ -67,17 +70,94 @@ export interface ClickUpCommentBlock {
67
70
  };
68
71
  indent?: number;
69
72
  'block-id'?: string;
73
+ alt?: string;
70
74
  };
71
75
  list?: {
72
76
  list: 'bullet' | 'ordered' | 'unchecked' | 'checked';
73
77
  };
78
+ /**
79
+ * Present on task mention fragments. ClickUp renders these as live task references
80
+ * showing the current task name, status and assignee.
81
+ */
82
+ task_mention?: {
83
+ task_id: string;
84
+ };
85
+ /**
86
+ * Present on image fragments. ClickUp only renders a preview when this holds the
87
+ * complete attachment object from the upload response - a bare URL string produces
88
+ * an empty placeholder tile.
89
+ */
90
+ image?: {
91
+ id?: string;
92
+ name?: string;
93
+ title?: string;
94
+ extension?: string;
95
+ url: string;
96
+ thumbnail_small?: string;
97
+ thumbnail_medium?: string;
98
+ thumbnail_large?: string;
99
+ width?: number;
100
+ height?: number;
101
+ };
102
+ }
103
+ /**
104
+ * Minimal shape needed to embed an already-uploaded attachment as an image fragment
105
+ */
106
+ export interface EmbeddableAttachment {
107
+ id?: string;
108
+ name?: string;
109
+ title?: string;
110
+ extension?: string;
111
+ url: string;
112
+ thumbnail_small?: string;
113
+ thumbnail_medium?: string;
114
+ thumbnail_large?: string;
115
+ width?: number;
116
+ height?: number;
117
+ [key: string]: any;
74
118
  }
119
+ /**
120
+ * Build the image fragment ClickUp needs to render an inline image in a comment.
121
+ * `title`/`text` carry the caption; the rest is copied straight from the upload response.
122
+ */
123
+ export declare function buildImageFragment(attachment: EmbeddableAttachment, caption: string): ClickUpCommentBlock;
124
+ /**
125
+ * Extract the task ID from a ClickUp task URL, or null if it is not one.
126
+ */
127
+ export declare function parseClickUpTaskUrl(url: string): string | null;
128
+ /**
129
+ * Wrap image destinations that contain spaces in angle brackets.
130
+ *
131
+ * CommonMark rejects a bare destination with spaces, so `![x](/tmp/Screen Shot.png)`
132
+ * is not an image at all - it would silently stay literal text and never be uploaded.
133
+ * Screenshot filenames have spaces constantly ("Screenshot 2026-07-27 at 14.30.png"),
134
+ * so normalising to the `<...>` form is what makes the obvious thing work.
135
+ */
136
+ export declare function normalizeImageDestinations(markdown: string): string;
137
+ /**
138
+ * Collect every image reference in a markdown document, in document order.
139
+ * Callers use this to know what needs uploading before converting.
140
+ */
141
+ export declare function collectMarkdownImageSources(markdown: string): {
142
+ src: string;
143
+ alt: string;
144
+ }[];
145
+ /**
146
+ * Replace image sources in markdown with their uploaded ClickUp URLs.
147
+ *
148
+ * Used for task descriptions: `markdown_description` renders `![alt](url)` directly,
149
+ * so descriptions need no fragment handling - only the URL has to be swapped.
150
+ * Images without an upload keep their original source untouched.
151
+ */
152
+ export declare function rewriteMarkdownImageUrls(markdown: string, attachmentsBySrc: Map<string, EmbeddableAttachment>): string;
75
153
  /**
76
154
  * Convert markdown text to ClickUp comment blocks format using remark
77
- * Supports: headers, bold, italic, code, links, lists, blockquotes, code blocks
155
+ * Supports: headers, bold, italic, code, links, lists, blockquotes, code blocks, images
78
156
  *
79
157
  * @param markdown The markdown text to convert
158
+ * @param attachmentsBySrc Uploaded attachments keyed by the markdown `src` they came from.
159
+ * Images without an entry degrade to a link so their information is not lost.
80
160
  * @returns Array of ClickUp comment blocks
81
161
  */
82
- export declare function convertMarkdownToClickUpBlocks(markdown: string): ClickUpCommentBlock[];
162
+ export declare function convertMarkdownToClickUpBlocks(markdown: string, attachmentsBySrc?: Map<string, EmbeddableAttachment>): ClickUpCommentBlock[];
83
163
  //# sourceMappingURL=clickup-text.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"clickup-text.d.ts","sourceRoot":"","sources":["../src/clickup-text.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAEjE,OAAO,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAOpD;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE;QACN,EAAE,CAAC,EAAE,MAAM,CAAC;QACZ,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,GAAG,EAAE,MAAM,CAAC;QACZ,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB,CAAC;IACF,UAAU,CAAC,EAAE,GAAG,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AA4BD;;;;;;GAMG;AACH,wBAAsB,uCAAuC,CAC3D,SAAS,EAAE,eAAe,EAAE,GAC3B,OAAO,CAAC,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,GAAG,kBAAkB,CAAC,EAAE,CAAC,CAsNrE;AAED;;;;;GAKG;AACH,wBAAgB,+BAA+B,CAC7C,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,iBAAiB,EAAE,GAAG,IAAI,GAAG,SAAS,GAClD,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,GAAG,kBAAkB,CAAC,EAAE,CA0I5D;AA8BD;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE;QACX,IAAI,CAAC,EAAE,OAAO,CAAC;QACf,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,IAAI,CAAC,EAAE,OAAO,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,YAAY,CAAC,EAAE;YACb,YAAY,EAAE,MAAM,CAAC;SACtB,CAAC;QACF,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,UAAU,CAAC,EAAE,EAAE,CAAC;QAChB,iBAAiB,CAAC,EAAE,OAAO,CAAC;QAC5B,IAAI,CAAC,EAAE;YACL,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,WAAW,GAAG,SAAS,CAAC;SACtD,CAAC;QACF,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,IAAI,CAAC,EAAE;QACL,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,WAAW,GAAG,SAAS,CAAC;KACtD,CAAC;CACH;AAED;;;;;;GAMG;AACH,wBAAgB,8BAA8B,CAAC,QAAQ,EAAE,MAAM,GAAG,mBAAmB,EAAE,CAoBtF"}
1
+ {"version":3,"file":"clickup-text.d.ts","sourceRoot":"","sources":["../src/clickup-text.ts"],"names":[],"mappings":"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"}
@@ -5,6 +5,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.convertClickUpTextItemsToToolCallResult = convertClickUpTextItemsToToolCallResult;
7
7
  exports.convertMarkdownToToolCallResult = convertMarkdownToToolCallResult;
8
+ exports.buildImageFragment = buildImageFragment;
9
+ exports.parseClickUpTaskUrl = parseClickUpTaskUrl;
10
+ exports.normalizeImageDestinations = normalizeImageDestinations;
11
+ exports.collectMarkdownImageSources = collectMarkdownImageSources;
12
+ exports.rewriteMarkdownImageUrls = rewriteMarkdownImageUrls;
8
13
  exports.convertMarkdownToClickUpBlocks = convertMarkdownToClickUpBlocks;
9
14
  const data_uri_1 = require("./shared/data-uri");
10
15
  const unified_1 = require("unified");
@@ -31,6 +36,18 @@ function extractThumbnailsFromDataAttachment(attributes) {
31
36
  return {};
32
37
  }
33
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
+ }
34
51
  /**
35
52
  * Process an array of ClickUp text items into a structured content format
36
53
  * that includes both text and images in their original sequence
@@ -80,8 +97,10 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
80
97
  }
81
98
  continue;
82
99
  }
83
- // Add image URL reference inline to current text block
84
- currentTextBlock += `\nImage: ${imageFileName} - ${imageUrl}`;
100
+ // Reference the image in the same markdown syntax the write tools accept, so a
101
+ // comment read here can be handed back to editComment unchanged and keep its
102
+ // images - an existing ClickUp attachment URL is re-embedded without re-uploading.
103
+ currentTextBlock += `\n${toMarkdownImage(altText, imageUrl)}`;
85
104
  // Get working thumbnail URLs from data-attachment if available
86
105
  const extractedThumbnails = extractThumbnailsFromDataAttachment(item.attributes);
87
106
  // Determine best thumbnail URLs (prefer extracted over API thumbnails)
@@ -215,6 +234,11 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
215
234
  currentLine += formattedText;
216
235
  }
217
236
  }
237
+ // Task mentions render as the task URL so the reference survives the round trip:
238
+ // writing that URL back through addComment/editComment regenerates the mention.
239
+ else if (item.type === "task_mention" && item.task_mention?.task_id) {
240
+ currentLine += `https://app.clickup.com/t/${item.task_mention.task_id}`;
241
+ }
218
242
  // Handle other types of items like bookmarks or whatever clickup can think of
219
243
  else {
220
244
  currentTextBlock += JSON.stringify(item);
@@ -288,9 +312,9 @@ function convertMarkdownToToolCallResult(markdownText, attachments) {
288
312
  // Check if this image URL exists in our attachments
289
313
  const attachment = attachmentMap.get(imageUrl);
290
314
  if (attachment) {
291
- // Add image URL reference inline to current text block
315
+ // Keep the markdown syntax, so the reference stays usable in a write call
292
316
  const imageFileName = altText || "image";
293
- currentTextBlock += `\nImage: ${imageFileName} - ${imageUrl}`;
317
+ currentTextBlock += `\n${toMarkdownImage(imageFileName, imageUrl)}`;
294
318
  // Only create image_metadata if we have at least one thumbnail (never use original image)
295
319
  if (attachment.thumbnail_large || attachment.thumbnail_medium || attachment.thumbnail_small) {
296
320
  // Push accumulated text (including image URL) as a text block
@@ -380,14 +404,132 @@ function extractFileTypeFromUrl(url) {
380
404
  return null;
381
405
  return filename.substring(lastDot + 1);
382
406
  }
407
+ /**
408
+ * Build the image fragment ClickUp needs to render an inline image in a comment.
409
+ * `title`/`text` carry the caption; the rest is copied straight from the upload response.
410
+ */
411
+ function buildImageFragment(attachment, caption) {
412
+ const label = caption || attachment.name || 'image';
413
+ const fragment = {
414
+ type: 'image',
415
+ text: label,
416
+ image: {
417
+ id: attachment.id,
418
+ name: attachment.name,
419
+ title: label,
420
+ extension: attachment.extension,
421
+ url: attachment.url,
422
+ thumbnail_small: attachment.thumbnail_small,
423
+ thumbnail_medium: attachment.thumbnail_medium,
424
+ thumbnail_large: attachment.thumbnail_large,
425
+ width: attachment.width,
426
+ height: attachment.height,
427
+ },
428
+ };
429
+ if (caption) {
430
+ fragment.attributes = { alt: caption };
431
+ }
432
+ return fragment;
433
+ }
434
+ /**
435
+ * Matches a plain ClickUp task URL, with or without the team segment:
436
+ * https://app.clickup.com/t/86cb3t6t2 or https://app.clickup.com/t/4500611/86cb3t6t2
437
+ *
438
+ * Deliberately narrow: custom task IDs (PREFIX-123) and URLs carrying a query or
439
+ * fragment (e.g. ?comment=... deep links) do NOT match, because a mention would
440
+ * either not resolve or lose the anchor - those stay ordinary links.
441
+ */
442
+ const CLICKUP_TASK_URL_PATTERN = /^https?:\/\/app\.clickup\.com\/t\/(?:\d+\/)?([a-z0-9]{6,12})\/?$/;
443
+ /**
444
+ * Extract the task ID from a ClickUp task URL, or null if it is not one.
445
+ */
446
+ function parseClickUpTaskUrl(url) {
447
+ const match = url.match(CLICKUP_TASK_URL_PATTERN);
448
+ return match ? match[1] : null;
449
+ }
450
+ /**
451
+ * Wrap image destinations that contain spaces in angle brackets.
452
+ *
453
+ * CommonMark rejects a bare destination with spaces, so `![x](/tmp/Screen Shot.png)`
454
+ * is not an image at all - it would silently stay literal text and never be uploaded.
455
+ * Screenshot filenames have spaces constantly ("Screenshot 2026-07-27 at 14.30.png"),
456
+ * so normalising to the `<...>` form is what makes the obvious thing work.
457
+ */
458
+ function normalizeImageDestinations(markdown) {
459
+ return markdown.replace(/!\[([^\]]*)\]\(([^)\n]*)\)/g, (match, alt, inner) => {
460
+ const trimmed = inner.trim();
461
+ // Already bracketed, or nothing to fix
462
+ if (trimmed.startsWith('<') || trimmed.includes('>')) {
463
+ return match;
464
+ }
465
+ // Split off an optional markdown title: dest "title" / 'title'
466
+ const titleMatch = trimmed.match(/^(.*?)(\s+(?:"[^"]*"|'[^']*'))$/s);
467
+ const dest = titleMatch ? titleMatch[1] : trimmed;
468
+ const title = titleMatch ? titleMatch[2] : '';
469
+ if (!dest || !/\s/.test(dest)) {
470
+ return match;
471
+ }
472
+ return `![${alt}](<${dest}>${title})`;
473
+ });
474
+ }
475
+ /**
476
+ * Collect every image reference in a markdown document, in document order.
477
+ * Callers use this to know what needs uploading before converting.
478
+ */
479
+ function collectMarkdownImageSources(markdown) {
480
+ const images = [];
481
+ try {
482
+ const tree = (0, unified_1.unified)()
483
+ .use(remark_parse_1.default)
484
+ .use(remark_gfm_1.default)
485
+ .parse(markdown);
486
+ const visit = (nodes) => {
487
+ for (const node of nodes) {
488
+ if (node.type === 'image' && typeof node.url === 'string') {
489
+ images.push({ src: node.url, alt: typeof node.alt === 'string' ? node.alt : '' });
490
+ }
491
+ else if (Array.isArray(node.children)) {
492
+ visit(node.children);
493
+ }
494
+ }
495
+ };
496
+ visit(tree.children);
497
+ }
498
+ catch (error) {
499
+ console.error('Failed to collect markdown images:', error);
500
+ }
501
+ return images;
502
+ }
503
+ /**
504
+ * Replace image sources in markdown with their uploaded ClickUp URLs.
505
+ *
506
+ * Used for task descriptions: `markdown_description` renders `![alt](url)` directly,
507
+ * so descriptions need no fragment handling - only the URL has to be swapped.
508
+ * Images without an upload keep their original source untouched.
509
+ */
510
+ function rewriteMarkdownImageUrls(markdown, attachmentsBySrc) {
511
+ if (attachmentsBySrc.size === 0) {
512
+ return markdown;
513
+ }
514
+ return markdown.replace(/!\[([^\]]*)\]\(\s*(<[^>]*>|[^)\s]+)([^)]*)\)/g, (match, alt, rawSrc, trailing) => {
515
+ const src = rawSrc.startsWith('<') && rawSrc.endsWith('>') ? rawSrc.slice(1, -1) : rawSrc;
516
+ const attachment = attachmentsBySrc.get(src);
517
+ if (!attachment) {
518
+ return match;
519
+ }
520
+ return `![${alt}](${attachment.url}${trailing})`;
521
+ });
522
+ }
383
523
  /**
384
524
  * Convert markdown text to ClickUp comment blocks format using remark
385
- * Supports: headers, bold, italic, code, links, lists, blockquotes, code blocks
525
+ * Supports: headers, bold, italic, code, links, lists, blockquotes, code blocks, images
386
526
  *
387
527
  * @param markdown The markdown text to convert
528
+ * @param attachmentsBySrc Uploaded attachments keyed by the markdown `src` they came from.
529
+ * Images without an entry degrade to a link so their information is not lost.
388
530
  * @returns Array of ClickUp comment blocks
389
531
  */
390
- function convertMarkdownToClickUpBlocks(markdown) {
532
+ function convertMarkdownToClickUpBlocks(markdown, attachmentsBySrc) {
391
533
  const blocks = [];
392
534
  try {
393
535
  // Parse the entire markdown document using remark with GFM support (for task lists)
@@ -396,7 +538,7 @@ function convertMarkdownToClickUpBlocks(markdown) {
396
538
  .use(remark_gfm_1.default)
397
539
  .parse(markdown);
398
540
  // Walk the tree recursively
399
- walkMdastNodes(tree.children, {}, blocks);
541
+ walkMdastNodes(tree.children, {}, blocks, 0, attachmentsBySrc);
400
542
  }
401
543
  catch (error) {
402
544
  console.error('Failed to parse markdown:', error);
@@ -412,20 +554,20 @@ function convertMarkdownToClickUpBlocks(markdown) {
412
554
  * @param blocks Output array to append ClickUp blocks to
413
555
  * @param depth Nesting depth for lists (0 = top level, 1 = first nest, etc.)
414
556
  */
415
- function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
557
+ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0, attachmentsBySrc) {
416
558
  for (let i = 0; i < nodes.length; i++) {
417
559
  const node = nodes[i];
418
560
  const currentAttrs = { ...inheritedAttrs };
419
561
  switch (node.type) {
420
562
  case 'heading':
421
563
  // Process heading content with inline formatting
422
- walkPhrasingContent(node.children, currentAttrs, blocks);
564
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
423
565
  // Add newline with header attribute
424
566
  blocks.push({ text: '\n', attributes: { header: node.depth } });
425
567
  break;
426
568
  case 'paragraph':
427
569
  // Process paragraph content with inline formatting
428
- walkPhrasingContent(node.children, currentAttrs, blocks);
570
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
429
571
  // Add newline unless it's the last node
430
572
  if (i < nodes.length - 1) {
431
573
  blocks.push({ text: '\n', attributes: {} });
@@ -437,7 +579,7 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
437
579
  const blockquoteChildren = node.children;
438
580
  for (const child of blockquoteChildren) {
439
581
  if (child.type === 'paragraph') {
440
- walkPhrasingContent(child.children, currentAttrs, blocks);
582
+ walkPhrasingContent(child.children, currentAttrs, blocks, attachmentsBySrc);
441
583
  blocks.push({ text: '\n', attributes: { blockquote: {} } });
442
584
  }
443
585
  // Note: Other child types (heading, list) are not supported by ClickUp blockquotes
@@ -457,7 +599,7 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
457
599
  for (const itemChild of listItem.children) {
458
600
  if (itemChild.type === 'paragraph') {
459
601
  // Process paragraph content with inline formatting
460
- walkPhrasingContent(itemChild.children, currentAttrs, blocks);
602
+ walkPhrasingContent(itemChild.children, currentAttrs, blocks, attachmentsBySrc);
461
603
  // Add newline with list formatting and optional indent
462
604
  const listAttrs = {
463
605
  list: { list: finalListType }
@@ -470,7 +612,7 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
470
612
  }
471
613
  else if (itemChild.type === 'list') {
472
614
  // Nested list - recursively process with increased depth
473
- walkMdastNodes([itemChild], currentAttrs, blocks, depth + 1);
615
+ walkMdastNodes([itemChild], currentAttrs, blocks, depth + 1, attachmentsBySrc);
474
616
  }
475
617
  }
476
618
  }
@@ -493,20 +635,57 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
493
635
  default:
494
636
  // For any other block-level nodes, try to process children
495
637
  if ('children' in node && Array.isArray(node.children)) {
496
- walkMdastNodes(node.children, currentAttrs, blocks, depth);
638
+ walkMdastNodes(node.children, currentAttrs, blocks, depth, attachmentsBySrc);
497
639
  }
498
640
  break;
499
641
  }
642
+ // Keep the blank line the source puts between two blocks: remark drops it, and
643
+ // ClickUp would otherwise glue paragraphs and lists into one dense block
644
+ if (hasBlankLineBefore(nodes[i + 1], node)) {
645
+ blocks.push({ text: '\n', attributes: {} });
646
+ }
500
647
  }
501
648
  }
649
+ /**
650
+ * Whether the source has at least one blank line between `previous` and `next`.
651
+ * Several blank lines count as one, as in rendered markdown.
652
+ */
653
+ function hasBlankLineBefore(next, previous) {
654
+ const previousEnd = previous.position?.end.line;
655
+ const nextStart = next?.position?.start.line;
656
+ if (previousEnd === undefined || nextStart === undefined) {
657
+ return false;
658
+ }
659
+ return nextStart - previousEnd > 1;
660
+ }
502
661
  /**
503
662
  * Recursively walk phrasing content (inline nodes) and build ClickUp blocks
504
663
  * Accumulates formatting attributes from parent nodes
505
664
  */
506
- function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
665
+ function walkPhrasingContent(nodes, inheritedAttrs, blocks, attachmentsBySrc) {
507
666
  for (const node of nodes) {
508
667
  const currentAttrs = { ...inheritedAttrs };
509
668
  switch (node.type) {
669
+ case 'image': {
670
+ // An image node has neither `value` nor `children`, so without this case it
671
+ // would fall through to `default` and vanish silently.
672
+ const attachment = attachmentsBySrc?.get(node.url);
673
+ const caption = node.alt || '';
674
+ if (attachment) {
675
+ blocks.push(buildImageFragment(attachment, caption));
676
+ }
677
+ else {
678
+ // Nothing was uploaded for this source - degrade to a link rather than
679
+ // dropping the reference, so the information survives.
680
+ const label = caption || node.url;
681
+ const isEmbeddable = /^https?:\/\//i.test(node.url);
682
+ blocks.push({
683
+ text: label,
684
+ attributes: isEmbeddable ? { ...currentAttrs, link: node.url } : currentAttrs,
685
+ });
686
+ }
687
+ break;
688
+ }
510
689
  case 'text':
511
690
  // Plain text node
512
691
  if (node.value) {
@@ -519,12 +698,12 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
519
698
  case 'strong':
520
699
  // Bold text - recurse with bold attribute
521
700
  currentAttrs.bold = true;
522
- walkPhrasingContent(node.children, currentAttrs, blocks);
701
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
523
702
  break;
524
703
  case 'emphasis':
525
704
  // Italic text - recurse with italic attribute
526
705
  currentAttrs.italic = true;
527
- walkPhrasingContent(node.children, currentAttrs, blocks);
706
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
528
707
  break;
529
708
  case 'inlineCode':
530
709
  // Inline code
@@ -536,11 +715,20 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
536
715
  });
537
716
  }
538
717
  break;
539
- case 'link':
540
- // Link - recurse with link attribute
718
+ case 'link': {
719
+ // A link to a ClickUp task becomes a real task mention, matching what the
720
+ // ClickUp UI does when a task URL is pasted. The mention renders the live
721
+ // task name, so any custom link text is intentionally replaced by it.
722
+ const mentionedTaskId = parseClickUpTaskUrl(node.url);
723
+ if (mentionedTaskId) {
724
+ blocks.push({ type: 'task_mention', task_mention: { task_id: mentionedTaskId } });
725
+ break;
726
+ }
727
+ // Ordinary link - recurse with link attribute
541
728
  currentAttrs.link = node.url;
542
- walkPhrasingContent(node.children, currentAttrs, blocks);
729
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
543
730
  break;
731
+ }
544
732
  case 'break':
545
733
  // Line break - add as plain text
546
734
  blocks.push({ text: '\n', attributes: {} });
@@ -555,7 +743,7 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
555
743
  }
556
744
  else if ('children' in node && Array.isArray(node.children)) {
557
745
  // Recurse into children for other container nodes
558
- walkPhrasingContent(node.children, currentAttrs, blocks);
746
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
559
747
  }
560
748
  break;
561
749
  }