@hauptsache.net/clickup-mcp 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@ Model Context Protocol (MCP) server enabling AI assistants to interact with Clic
11
11
  | **Setup** | Local npm/npx install | Remote MCP (no install) |
12
12
  | **Authentication** | API key only | OAuth only |
13
13
  | **Task Context** | Complete with comments, status history, inline images | Requires mutiple tool calls for full contxt |
14
- | **Image Support** | Inline images with smart size budgeting | Not documented |
14
+ | **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 |
15
15
  | **Search** | Fuzzy search on recent tasks (limited scope) | Full ClickUp search database |
16
16
  | **Documents** | CRUD operations | CRUD + document search |
17
17
  | **Time Tracking** | View and create entries | Timers and entries |
@@ -22,6 +22,7 @@ Model Context Protocol (MCP) server enabling AI assistants to interact with Clic
22
22
 
23
23
  **Choose this MCP when:**
24
24
  - You need rich task context with inline images for AI coding tools
25
+ - You want to write screenshots into tickets by local file path (running locally, it reads the file itself instead of taking base64)
25
26
  - You need API key authentication for automation or CI/CD pipelines
26
27
  - You want the `read-minimal` mode optimized for development workflows
27
28
 
@@ -229,6 +230,7 @@ This MCP server can be configured using environment variables:
229
230
  - `CLICKUP_MCP_MODE`: (Optional) Controls which tools are available. Options: `read-minimal`, `read`, `write` (default).
230
231
  - `MAX_IMAGES`: (Optional) The maximum number of images to return for a task in `getTaskById`. Defaults to 4.
231
232
  - `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
+ - `MAX_UPLOAD_SIZE_MB`: (Optional) The maximum size of a single image uploaded when writing comments or descriptions. Defaults to 10.
232
234
  - `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.
233
235
  - `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.
234
236
 
@@ -284,6 +286,35 @@ When updating task descriptions, content is safely appended:
284
286
 
285
287
  This ensures no existing content is ever lost while maintaining a clear audit trail.
286
288
 
289
+ ## Writing Images Into Tickets
290
+
291
+ `addComment`, `createTask` and `updateTask` accept images as ordinary markdown. Because
292
+ this server runs locally, it reads the file itself - so a **local path is enough**:
293
+
294
+ ```markdown
295
+ Ist umgesetzt. So sieht es aus:
296
+
297
+ **1. Login öffnen** – der Kunde gibt nur seine E-Mail-Adresse ein.
298
+
299
+ ![Die Login-Maske fragt nur nach der E-Mail](/Users/me/shots/login.png)
300
+ ```
301
+
302
+ Accepted sources: local file paths, `data:` URIs, http(s) URLs (downloaded, then
303
+ re-uploaded), and existing ClickUp attachment URLs (embedded without re-uploading).
304
+
305
+ Notes:
306
+
307
+ - **Prefer paths over base64.** A path costs a few tokens; the same screenshot as a
308
+ `data:` URI costs roughly 4/3 of its file size in the request.
309
+ - **The caption becomes the attachment filename**, and that filename is what ClickUp
310
+ displays beneath the image - so write a caption that reads well.
311
+ - **An image inside a numbered list breaks ClickUp's numbering.** Write walkthrough
312
+ steps as bold lines with the image between them, as above.
313
+ - 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
+ - Attachments always belong to a task, so document pages cannot embed uploads this way.
317
+
287
318
  ## Performance & Limitations
288
319
 
289
320
  **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
@@ -67,17 +67,83 @@ export interface ClickUpCommentBlock {
67
67
  };
68
68
  indent?: number;
69
69
  'block-id'?: string;
70
+ alt?: string;
70
71
  };
71
72
  list?: {
72
73
  list: 'bullet' | 'ordered' | 'unchecked' | 'checked';
73
74
  };
75
+ /**
76
+ * Present on image fragments. ClickUp only renders a preview when this holds the
77
+ * complete attachment object from the upload response - a bare URL string produces
78
+ * an empty placeholder tile.
79
+ */
80
+ image?: {
81
+ id?: string;
82
+ name?: string;
83
+ title?: string;
84
+ extension?: string;
85
+ url: string;
86
+ thumbnail_small?: string;
87
+ thumbnail_medium?: string;
88
+ thumbnail_large?: string;
89
+ width?: number;
90
+ height?: number;
91
+ };
92
+ }
93
+ /**
94
+ * Minimal shape needed to embed an already-uploaded attachment as an image fragment
95
+ */
96
+ export interface EmbeddableAttachment {
97
+ id?: string;
98
+ name?: string;
99
+ title?: string;
100
+ extension?: string;
101
+ url: string;
102
+ thumbnail_small?: string;
103
+ thumbnail_medium?: string;
104
+ thumbnail_large?: string;
105
+ width?: number;
106
+ height?: number;
107
+ [key: string]: any;
74
108
  }
109
+ /**
110
+ * Build the image fragment ClickUp needs to render an inline image in a comment.
111
+ * `title`/`text` carry the caption; the rest is copied straight from the upload response.
112
+ */
113
+ export declare function buildImageFragment(attachment: EmbeddableAttachment, caption: string): ClickUpCommentBlock;
114
+ /**
115
+ * Wrap image destinations that contain spaces in angle brackets.
116
+ *
117
+ * CommonMark rejects a bare destination with spaces, so `![x](/tmp/Screen Shot.png)`
118
+ * is not an image at all - it would silently stay literal text and never be uploaded.
119
+ * Screenshot filenames have spaces constantly ("Screenshot 2026-07-27 at 14.30.png"),
120
+ * so normalising to the `<...>` form is what makes the obvious thing work.
121
+ */
122
+ export declare function normalizeImageDestinations(markdown: string): string;
123
+ /**
124
+ * Collect every image reference in a markdown document, in document order.
125
+ * Callers use this to know what needs uploading before converting.
126
+ */
127
+ export declare function collectMarkdownImageSources(markdown: string): {
128
+ src: string;
129
+ alt: string;
130
+ }[];
131
+ /**
132
+ * Replace image sources in markdown with their uploaded ClickUp URLs.
133
+ *
134
+ * Used for task descriptions: `markdown_description` renders `![alt](url)` directly,
135
+ * so descriptions need no fragment handling - only the URL has to be swapped.
136
+ * Images without an upload keep their original source untouched.
137
+ */
138
+ export declare function rewriteMarkdownImageUrls(markdown: string, attachmentsBySrc: Map<string, EmbeddableAttachment>): string;
75
139
  /**
76
140
  * Convert markdown text to ClickUp comment blocks format using remark
77
- * Supports: headers, bold, italic, code, links, lists, blockquotes, code blocks
141
+ * Supports: headers, bold, italic, code, links, lists, blockquotes, code blocks, images
78
142
  *
79
143
  * @param markdown The markdown text to convert
144
+ * @param attachmentsBySrc Uploaded attachments keyed by the markdown `src` they came from.
145
+ * Images without an entry degrade to a link so their information is not lost.
80
146
  * @returns Array of ClickUp comment blocks
81
147
  */
82
- export declare function convertMarkdownToClickUpBlocks(markdown: string): ClickUpCommentBlock[];
148
+ export declare function convertMarkdownToClickUpBlocks(markdown: string, attachmentsBySrc?: Map<string, EmbeddableAttachment>): ClickUpCommentBlock[];
83
149
  //# 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,10 @@ 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.normalizeImageDestinations = normalizeImageDestinations;
10
+ exports.collectMarkdownImageSources = collectMarkdownImageSources;
11
+ exports.rewriteMarkdownImageUrls = rewriteMarkdownImageUrls;
8
12
  exports.convertMarkdownToClickUpBlocks = convertMarkdownToClickUpBlocks;
9
13
  const data_uri_1 = require("./shared/data-uri");
10
14
  const unified_1 = require("unified");
@@ -380,14 +384,116 @@ function extractFileTypeFromUrl(url) {
380
384
  return null;
381
385
  return filename.substring(lastDot + 1);
382
386
  }
387
+ /**
388
+ * Build the image fragment ClickUp needs to render an inline image in a comment.
389
+ * `title`/`text` carry the caption; the rest is copied straight from the upload response.
390
+ */
391
+ function buildImageFragment(attachment, caption) {
392
+ const label = caption || attachment.name || 'image';
393
+ const fragment = {
394
+ type: 'image',
395
+ text: label,
396
+ image: {
397
+ id: attachment.id,
398
+ name: attachment.name,
399
+ title: label,
400
+ extension: attachment.extension,
401
+ url: attachment.url,
402
+ thumbnail_small: attachment.thumbnail_small,
403
+ thumbnail_medium: attachment.thumbnail_medium,
404
+ thumbnail_large: attachment.thumbnail_large,
405
+ width: attachment.width,
406
+ height: attachment.height,
407
+ },
408
+ };
409
+ if (caption) {
410
+ fragment.attributes = { alt: caption };
411
+ }
412
+ return fragment;
413
+ }
414
+ /**
415
+ * Wrap image destinations that contain spaces in angle brackets.
416
+ *
417
+ * CommonMark rejects a bare destination with spaces, so `![x](/tmp/Screen Shot.png)`
418
+ * is not an image at all - it would silently stay literal text and never be uploaded.
419
+ * Screenshot filenames have spaces constantly ("Screenshot 2026-07-27 at 14.30.png"),
420
+ * so normalising to the `<...>` form is what makes the obvious thing work.
421
+ */
422
+ function normalizeImageDestinations(markdown) {
423
+ return markdown.replace(/!\[([^\]]*)\]\(([^)\n]*)\)/g, (match, alt, inner) => {
424
+ const trimmed = inner.trim();
425
+ // Already bracketed, or nothing to fix
426
+ if (trimmed.startsWith('<') || trimmed.includes('>')) {
427
+ return match;
428
+ }
429
+ // Split off an optional markdown title: dest "title" / 'title'
430
+ const titleMatch = trimmed.match(/^(.*?)(\s+(?:"[^"]*"|'[^']*'))$/s);
431
+ const dest = titleMatch ? titleMatch[1] : trimmed;
432
+ const title = titleMatch ? titleMatch[2] : '';
433
+ if (!dest || !/\s/.test(dest)) {
434
+ return match;
435
+ }
436
+ return `![${alt}](<${dest}>${title})`;
437
+ });
438
+ }
439
+ /**
440
+ * Collect every image reference in a markdown document, in document order.
441
+ * Callers use this to know what needs uploading before converting.
442
+ */
443
+ function collectMarkdownImageSources(markdown) {
444
+ const images = [];
445
+ try {
446
+ const tree = (0, unified_1.unified)()
447
+ .use(remark_parse_1.default)
448
+ .use(remark_gfm_1.default)
449
+ .parse(markdown);
450
+ const visit = (nodes) => {
451
+ for (const node of nodes) {
452
+ if (node.type === 'image' && typeof node.url === 'string') {
453
+ images.push({ src: node.url, alt: typeof node.alt === 'string' ? node.alt : '' });
454
+ }
455
+ else if (Array.isArray(node.children)) {
456
+ visit(node.children);
457
+ }
458
+ }
459
+ };
460
+ visit(tree.children);
461
+ }
462
+ catch (error) {
463
+ console.error('Failed to collect markdown images:', error);
464
+ }
465
+ return images;
466
+ }
467
+ /**
468
+ * Replace image sources in markdown with their uploaded ClickUp URLs.
469
+ *
470
+ * Used for task descriptions: `markdown_description` renders `![alt](url)` directly,
471
+ * so descriptions need no fragment handling - only the URL has to be swapped.
472
+ * Images without an upload keep their original source untouched.
473
+ */
474
+ function rewriteMarkdownImageUrls(markdown, attachmentsBySrc) {
475
+ if (attachmentsBySrc.size === 0) {
476
+ return markdown;
477
+ }
478
+ return markdown.replace(/!\[([^\]]*)\]\(\s*(<[^>]*>|[^)\s]+)([^)]*)\)/g, (match, alt, rawSrc, trailing) => {
479
+ const src = rawSrc.startsWith('<') && rawSrc.endsWith('>') ? rawSrc.slice(1, -1) : rawSrc;
480
+ const attachment = attachmentsBySrc.get(src);
481
+ if (!attachment) {
482
+ return match;
483
+ }
484
+ return `![${alt}](${attachment.url}${trailing})`;
485
+ });
486
+ }
383
487
  /**
384
488
  * Convert markdown text to ClickUp comment blocks format using remark
385
- * Supports: headers, bold, italic, code, links, lists, blockquotes, code blocks
489
+ * Supports: headers, bold, italic, code, links, lists, blockquotes, code blocks, images
386
490
  *
387
491
  * @param markdown The markdown text to convert
492
+ * @param attachmentsBySrc Uploaded attachments keyed by the markdown `src` they came from.
493
+ * Images without an entry degrade to a link so their information is not lost.
388
494
  * @returns Array of ClickUp comment blocks
389
495
  */
390
- function convertMarkdownToClickUpBlocks(markdown) {
496
+ function convertMarkdownToClickUpBlocks(markdown, attachmentsBySrc) {
391
497
  const blocks = [];
392
498
  try {
393
499
  // Parse the entire markdown document using remark with GFM support (for task lists)
@@ -396,7 +502,7 @@ function convertMarkdownToClickUpBlocks(markdown) {
396
502
  .use(remark_gfm_1.default)
397
503
  .parse(markdown);
398
504
  // Walk the tree recursively
399
- walkMdastNodes(tree.children, {}, blocks);
505
+ walkMdastNodes(tree.children, {}, blocks, 0, attachmentsBySrc);
400
506
  }
401
507
  catch (error) {
402
508
  console.error('Failed to parse markdown:', error);
@@ -412,20 +518,20 @@ function convertMarkdownToClickUpBlocks(markdown) {
412
518
  * @param blocks Output array to append ClickUp blocks to
413
519
  * @param depth Nesting depth for lists (0 = top level, 1 = first nest, etc.)
414
520
  */
415
- function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
521
+ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0, attachmentsBySrc) {
416
522
  for (let i = 0; i < nodes.length; i++) {
417
523
  const node = nodes[i];
418
524
  const currentAttrs = { ...inheritedAttrs };
419
525
  switch (node.type) {
420
526
  case 'heading':
421
527
  // Process heading content with inline formatting
422
- walkPhrasingContent(node.children, currentAttrs, blocks);
528
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
423
529
  // Add newline with header attribute
424
530
  blocks.push({ text: '\n', attributes: { header: node.depth } });
425
531
  break;
426
532
  case 'paragraph':
427
533
  // Process paragraph content with inline formatting
428
- walkPhrasingContent(node.children, currentAttrs, blocks);
534
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
429
535
  // Add newline unless it's the last node
430
536
  if (i < nodes.length - 1) {
431
537
  blocks.push({ text: '\n', attributes: {} });
@@ -437,7 +543,7 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
437
543
  const blockquoteChildren = node.children;
438
544
  for (const child of blockquoteChildren) {
439
545
  if (child.type === 'paragraph') {
440
- walkPhrasingContent(child.children, currentAttrs, blocks);
546
+ walkPhrasingContent(child.children, currentAttrs, blocks, attachmentsBySrc);
441
547
  blocks.push({ text: '\n', attributes: { blockquote: {} } });
442
548
  }
443
549
  // Note: Other child types (heading, list) are not supported by ClickUp blockquotes
@@ -457,7 +563,7 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
457
563
  for (const itemChild of listItem.children) {
458
564
  if (itemChild.type === 'paragraph') {
459
565
  // Process paragraph content with inline formatting
460
- walkPhrasingContent(itemChild.children, currentAttrs, blocks);
566
+ walkPhrasingContent(itemChild.children, currentAttrs, blocks, attachmentsBySrc);
461
567
  // Add newline with list formatting and optional indent
462
568
  const listAttrs = {
463
569
  list: { list: finalListType }
@@ -470,7 +576,7 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
470
576
  }
471
577
  else if (itemChild.type === 'list') {
472
578
  // Nested list - recursively process with increased depth
473
- walkMdastNodes([itemChild], currentAttrs, blocks, depth + 1);
579
+ walkMdastNodes([itemChild], currentAttrs, blocks, depth + 1, attachmentsBySrc);
474
580
  }
475
581
  }
476
582
  }
@@ -493,7 +599,7 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
493
599
  default:
494
600
  // For any other block-level nodes, try to process children
495
601
  if ('children' in node && Array.isArray(node.children)) {
496
- walkMdastNodes(node.children, currentAttrs, blocks, depth);
602
+ walkMdastNodes(node.children, currentAttrs, blocks, depth, attachmentsBySrc);
497
603
  }
498
604
  break;
499
605
  }
@@ -503,10 +609,30 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
503
609
  * Recursively walk phrasing content (inline nodes) and build ClickUp blocks
504
610
  * Accumulates formatting attributes from parent nodes
505
611
  */
506
- function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
612
+ function walkPhrasingContent(nodes, inheritedAttrs, blocks, attachmentsBySrc) {
507
613
  for (const node of nodes) {
508
614
  const currentAttrs = { ...inheritedAttrs };
509
615
  switch (node.type) {
616
+ case 'image': {
617
+ // An image node has neither `value` nor `children`, so without this case it
618
+ // would fall through to `default` and vanish silently.
619
+ const attachment = attachmentsBySrc?.get(node.url);
620
+ const caption = node.alt || '';
621
+ if (attachment) {
622
+ blocks.push(buildImageFragment(attachment, caption));
623
+ }
624
+ else {
625
+ // Nothing was uploaded for this source - degrade to a link rather than
626
+ // dropping the reference, so the information survives.
627
+ const label = caption || node.url;
628
+ const isEmbeddable = /^https?:\/\//i.test(node.url);
629
+ blocks.push({
630
+ text: label,
631
+ attributes: isEmbeddable ? { ...currentAttrs, link: node.url } : currentAttrs,
632
+ });
633
+ }
634
+ break;
635
+ }
510
636
  case 'text':
511
637
  // Plain text node
512
638
  if (node.value) {
@@ -519,12 +645,12 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
519
645
  case 'strong':
520
646
  // Bold text - recurse with bold attribute
521
647
  currentAttrs.bold = true;
522
- walkPhrasingContent(node.children, currentAttrs, blocks);
648
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
523
649
  break;
524
650
  case 'emphasis':
525
651
  // Italic text - recurse with italic attribute
526
652
  currentAttrs.italic = true;
527
- walkPhrasingContent(node.children, currentAttrs, blocks);
653
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
528
654
  break;
529
655
  case 'inlineCode':
530
656
  // Inline code
@@ -539,7 +665,7 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
539
665
  case 'link':
540
666
  // Link - recurse with link attribute
541
667
  currentAttrs.link = node.url;
542
- walkPhrasingContent(node.children, currentAttrs, blocks);
668
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
543
669
  break;
544
670
  case 'break':
545
671
  // Line break - add as plain text
@@ -555,7 +681,7 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
555
681
  }
556
682
  else if ('children' in node && Array.isArray(node.children)) {
557
683
  // Recurse into children for other container nodes
558
- walkPhrasingContent(node.children, currentAttrs, blocks);
684
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
559
685
  }
560
686
  break;
561
687
  }
@@ -0,0 +1,91 @@
1
+ import { Buffer } from "buffer";
2
+ /**
3
+ * Attachment object as returned by POST /api/v2/task/{task_id}/attachment.
4
+ * ClickUp only renders an image inside a comment when the fragment carries this
5
+ * whole object - a bare URL string renders as an empty placeholder tile.
6
+ */
7
+ export interface ClickUpUploadedAttachment {
8
+ id: string;
9
+ name: string;
10
+ title?: string;
11
+ extension?: string;
12
+ url: string;
13
+ thumbnail_small?: string;
14
+ thumbnail_medium?: string;
15
+ thumbnail_large?: string;
16
+ width?: number;
17
+ height?: number;
18
+ [key: string]: any;
19
+ }
20
+ /** Image bytes ready to be uploaded */
21
+ interface ResolvedBytes {
22
+ kind: "bytes";
23
+ bytes: Buffer;
24
+ mimeType: string;
25
+ suggestedName: string;
26
+ }
27
+ /** Already an attachment on ClickUp's CDN - reuse it instead of uploading again */
28
+ interface ResolvedExisting {
29
+ kind: "existing";
30
+ url: string;
31
+ }
32
+ export type ResolvedImageSource = ResolvedBytes | ResolvedExisting;
33
+ /**
34
+ * ClickUp serves attachments from *.clickup-attachments.com. Such a URL is
35
+ * already uploaded, so it can be embedded directly.
36
+ */
37
+ export declare function isClickUpAttachmentUrl(url: string): boolean;
38
+ /**
39
+ * Turn the `src` of a markdown image into something uploadable.
40
+ *
41
+ * Supported sources, in this order:
42
+ * - a ClickUp attachment URL -> reused as-is, no upload
43
+ * - a base64 data URI -> decoded
44
+ * - any other http(s) URL -> downloaded
45
+ * - anything else -> read from the local filesystem
46
+ *
47
+ * The local path case is the interesting one: this server runs next to the agent,
48
+ * so a screenshot can be referenced by path instead of being inlined as base64,
49
+ * which would otherwise cost a multiple of the file size in tokens.
50
+ */
51
+ export declare function resolveImageSource(src: string, baseDir?: string): Promise<ResolvedImageSource>;
52
+ /**
53
+ * Derive the upload filename from the markdown alt text.
54
+ *
55
+ * ClickUp shows the *attachment filename* underneath an image, not the fragment
56
+ * text - so naming the upload after the caption is what makes a readable caption
57
+ * appear in the ticket.
58
+ */
59
+ export declare function captionToFilename(caption: string, fallbackName: string): string;
60
+ /**
61
+ * Upload a single image to a task and return the full attachment object.
62
+ */
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 {
66
+ /** The original `src` as written in the markdown */
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;
72
+ }
73
+ /**
74
+ * Upload every image referenced in the markdown to the given task.
75
+ *
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.
80
+ */
81
+ export declare function uploadMarkdownImages(taskId: string, images: {
82
+ src: string;
83
+ alt: string;
84
+ }[], baseDir?: string): Promise<ImageUploadResult[]>;
85
+ /**
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.
88
+ */
89
+ export declare function toAttachmentMap(results: ImageUploadResult[]): Map<string, ClickUpUploadedAttachment>;
90
+ export {};
91
+ //# sourceMappingURL=attachments.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,254 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isClickUpAttachmentUrl = isClickUpAttachmentUrl;
4
+ exports.resolveImageSource = resolveImageSource;
5
+ exports.captionToFilename = captionToFilename;
6
+ exports.uploadTaskAttachment = uploadTaskAttachment;
7
+ exports.uploadMarkdownImages = uploadMarkdownImages;
8
+ exports.toAttachmentMap = toAttachmentMap;
9
+ const buffer_1 = require("buffer");
10
+ const crypto_1 = require("crypto");
11
+ const promises_1 = require("fs/promises");
12
+ const path_1 = require("path");
13
+ const config_1 = require("./config");
14
+ const data_uri_1 = require("./data-uri");
15
+ const image_processing_1 = require("./image-processing");
16
+ const MIME_EXTENSIONS = {
17
+ "image/png": ".png",
18
+ "image/jpeg": ".jpg",
19
+ "image/gif": ".gif",
20
+ "image/webp": ".webp",
21
+ };
22
+ /**
23
+ * Detect the mime type from magic bytes and reject anything that is not an image
24
+ * we know how to display. This is both a correctness guard (ClickUp needs a real
25
+ * image to render a preview) and a safety guard: it stops arbitrary local files
26
+ * from being pushed into a ticket just because a path was mentioned in markdown.
27
+ */
28
+ function assertSupportedImage(bytes, source) {
29
+ // Copy into a fresh view - Buffer instances share a pooled ArrayBuffer, so
30
+ // passing bytes.buffer directly would hand over unrelated neighbouring data.
31
+ const detected = (0, image_processing_1.detectMimeTypeFromBuffer)(new Uint8Array(bytes).buffer);
32
+ if (!detected) {
33
+ throw new Error(`${source} is not a supported image (expected PNG, JPEG, GIF or WebP based on its content)`);
34
+ }
35
+ return detected;
36
+ }
37
+ function assertWithinSizeLimit(byteLength, source) {
38
+ const limit = config_1.CONFIG.maxUploadSizeMB * 1024 * 1024;
39
+ if (byteLength > limit) {
40
+ throw new Error(`${source} is ${(byteLength / 1024 / 1024).toFixed(1)} MB which exceeds the ${config_1.CONFIG.maxUploadSizeMB} MB upload limit (raise MAX_UPLOAD_SIZE_MB to allow it)`);
41
+ }
42
+ }
43
+ /**
44
+ * ClickUp serves attachments from *.clickup-attachments.com. Such a URL is
45
+ * already uploaded, so it can be embedded directly.
46
+ */
47
+ function isClickUpAttachmentUrl(url) {
48
+ try {
49
+ return new URL(url).hostname.endsWith(".clickup-attachments.com");
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ /**
56
+ * Turn the `src` of a markdown image into something uploadable.
57
+ *
58
+ * Supported sources, in this order:
59
+ * - a ClickUp attachment URL -> reused as-is, no upload
60
+ * - a base64 data URI -> decoded
61
+ * - any other http(s) URL -> downloaded
62
+ * - anything else -> read from the local filesystem
63
+ *
64
+ * The local path case is the interesting one: this server runs next to the agent,
65
+ * so a screenshot can be referenced by path instead of being inlined as base64,
66
+ * which would otherwise cost a multiple of the file size in tokens.
67
+ */
68
+ async function resolveImageSource(src, baseDir = process.cwd()) {
69
+ if (isClickUpAttachmentUrl(src)) {
70
+ return { kind: "existing", url: src };
71
+ }
72
+ const dataUri = (0, data_uri_1.parseDataUri)(src);
73
+ if (dataUri) {
74
+ const bytes = buffer_1.Buffer.from(dataUri.base64Data, "base64");
75
+ assertWithinSizeLimit(bytes.byteLength, "The inline image");
76
+ const mimeType = assertSupportedImage(bytes, "The inline image");
77
+ return {
78
+ kind: "bytes",
79
+ bytes,
80
+ mimeType,
81
+ suggestedName: `image${MIME_EXTENSIONS[mimeType] ?? ".png"}`,
82
+ };
83
+ }
84
+ if (/^https?:\/\//i.test(src)) {
85
+ const response = await fetch(src);
86
+ if (!response.ok) {
87
+ throw new Error(`Could not download ${src}: ${response.status} ${response.statusText}`);
88
+ }
89
+ const bytes = buffer_1.Buffer.from(await response.arrayBuffer());
90
+ assertWithinSizeLimit(bytes.byteLength, src);
91
+ const mimeType = assertSupportedImage(bytes, src);
92
+ const urlName = (0, path_1.basename)(new URL(src).pathname) || `image${MIME_EXTENSIONS[mimeType] ?? ".png"}`;
93
+ return { kind: "bytes", bytes, mimeType, suggestedName: decodeURIComponent(urlName) };
94
+ }
95
+ const filePath = (0, path_1.isAbsolute)(src) ? src : (0, path_1.resolve)(baseDir, decodeFilePath(src));
96
+ let bytes;
97
+ try {
98
+ bytes = await (0, promises_1.readFile)(filePath);
99
+ }
100
+ catch (error) {
101
+ if (error?.code === "ENOENT") {
102
+ throw new Error(`No such file: ${filePath}`);
103
+ }
104
+ throw new Error(`Could not read ${filePath}: ${error?.message || "unknown error"}`);
105
+ }
106
+ assertWithinSizeLimit(bytes.byteLength, filePath);
107
+ const mimeType = assertSupportedImage(bytes, filePath);
108
+ return { kind: "bytes", bytes, mimeType, suggestedName: (0, path_1.basename)(filePath) };
109
+ }
110
+ /**
111
+ * Markdown writers tend to percent-encode spaces in paths (`my%20shot.png`).
112
+ * Decode them, but leave the path alone if it is not valid encoding.
113
+ */
114
+ function decodeFilePath(src) {
115
+ try {
116
+ return decodeURIComponent(src);
117
+ }
118
+ catch {
119
+ return src;
120
+ }
121
+ }
122
+ /**
123
+ * Derive the upload filename from the markdown alt text.
124
+ *
125
+ * ClickUp shows the *attachment filename* underneath an image, not the fragment
126
+ * text - so naming the upload after the caption is what makes a readable caption
127
+ * appear in the ticket.
128
+ */
129
+ function captionToFilename(caption, fallbackName) {
130
+ const extension = (0, path_1.extname)(fallbackName) || ".png";
131
+ const cleaned = caption
132
+ .replace(/[/\\:*?"<>|]/g, " ")
133
+ .replace(/\s+/g, " ")
134
+ .trim()
135
+ .slice(0, 120)
136
+ .trim();
137
+ return cleaned ? `${cleaned}${extension}` : fallbackName;
138
+ }
139
+ /**
140
+ * Build the multipart/form-data body by hand.
141
+ *
142
+ * Deliberately not using `FormData`: the global FormData is only understood by the
143
+ * fetch implementation it ships with, so swapping fetch (as the tests do) silently
144
+ * turns the body into the string "[object FormData]". A hand-built buffer behaves
145
+ * identically everywhere and keeps the wire format assertable.
146
+ */
147
+ function buildMultipartBody(filename, bytes, mimeType) {
148
+ const boundary = `----clickupmcp${(0, crypto_1.randomUUID)().replace(/-/g, "")}`;
149
+ // Quotes and newlines would break out of the header - strip them.
150
+ const safeName = filename.replace(/["\r\n]/g, "");
151
+ const head = buffer_1.Buffer.from(`--${boundary}\r\n` +
152
+ `Content-Disposition: form-data; name="attachment"; filename="${safeName}"\r\n` +
153
+ `Content-Type: ${mimeType}\r\n\r\n`, "utf8");
154
+ const tail = buffer_1.Buffer.from(`\r\n--${boundary}--\r\n`, "utf8");
155
+ // Hand over a bare ArrayBuffer: Buffer and Uint8Array both trip up the BodyInit
156
+ // typing here. Copying into a fresh Uint8Array also detaches from Buffer's shared
157
+ // pool, so the ArrayBuffer contains exactly our bytes and nothing else.
158
+ const combined = new Uint8Array(buffer_1.Buffer.concat([head, bytes, tail]));
159
+ return {
160
+ body: combined.buffer,
161
+ contentType: `multipart/form-data; boundary=${boundary}`,
162
+ };
163
+ }
164
+ /**
165
+ * Upload a single image to a task and return the full attachment object.
166
+ */
167
+ async function uploadTaskAttachment(taskId, filename, bytes, mimeType) {
168
+ const { body, contentType } = buildMultipartBody(filename, bytes, mimeType);
169
+ const response = await fetch(`https://api.clickup.com/api/v2/task/${taskId}/attachment`, {
170
+ method: "POST",
171
+ headers: {
172
+ Authorization: config_1.CONFIG.apiKey,
173
+ "Content-Type": contentType,
174
+ },
175
+ body,
176
+ });
177
+ if (!response.ok) {
178
+ const errorText = await response.text().catch(() => "");
179
+ throw new Error(`Upload of "${filename}" failed: ${response.status} ${response.statusText}${errorText ? ` - ${errorText.slice(0, 300)}` : ""}`);
180
+ }
181
+ const attachment = (await response.json());
182
+ if (!attachment?.url) {
183
+ throw new Error(`Upload of "${filename}" returned no URL: ${JSON.stringify(attachment)}`);
184
+ }
185
+ return attachment;
186
+ }
187
+ /**
188
+ * Upload every image referenced in the markdown to the given task.
189
+ *
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.
194
+ */
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();
199
+ for (const { src, alt } of images) {
200
+ const cached = seen.get(src);
201
+ if (cached) {
202
+ results.push(cached);
203
+ continue;
204
+ }
205
+ let result;
206
+ try {
207
+ const resolved = await resolveImageSource(src, baseDir);
208
+ if (resolved.kind === "existing") {
209
+ // Already on ClickUp's CDN - synthesise the minimal attachment shape so
210
+ // the fragment builder has something to work with.
211
+ const name = decodeURIComponent((0, path_1.basename)(new URL(resolved.url).pathname));
212
+ result = {
213
+ src,
214
+ attachment: {
215
+ id: name,
216
+ name,
217
+ title: alt || name,
218
+ extension: (0, path_1.extname)(name).replace(/^\./, "") || undefined,
219
+ url: resolved.url,
220
+ thumbnail_small: resolved.url,
221
+ thumbnail_medium: resolved.url,
222
+ thumbnail_large: resolved.url,
223
+ },
224
+ };
225
+ }
226
+ else {
227
+ const filename = captionToFilename(alt, resolved.suggestedName);
228
+ const attachment = await uploadTaskAttachment(taskId, filename, resolved.bytes, resolved.mimeType);
229
+ result = { src, attachment };
230
+ }
231
+ }
232
+ catch (error) {
233
+ const message = error instanceof Error ? error.message : "unknown error";
234
+ console.error(`Failed to attach image "${src}": ${message}`);
235
+ result = { src, attachment: null, error: message };
236
+ }
237
+ seen.set(src, result);
238
+ results.push(result);
239
+ }
240
+ return results;
241
+ }
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) {
247
+ const map = new Map();
248
+ for (const result of results) {
249
+ if (result.attachment) {
250
+ map.set(result.src, result.attachment);
251
+ }
252
+ }
253
+ return map;
254
+ }
@@ -5,6 +5,7 @@ export declare const CONFIG: {
5
5
  teamId: string;
6
6
  maxImages: number;
7
7
  maxResponseSizeMB: number;
8
+ maxUploadSizeMB: number;
8
9
  primaryLanguageHint: string | undefined;
9
10
  mode: McpMode;
10
11
  };
@@ -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;;;;;;;CAOlB,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;AAUxD,eAAO,MAAM,MAAM;;;;;;;;CAWlB,CAAC"}
@@ -53,6 +53,10 @@ exports.CONFIG = {
53
53
  teamId: process.env.CLICKUP_TEAM_ID,
54
54
  maxImages: process.env.MAX_IMAGES ? parseInt(process.env.MAX_IMAGES) : 4,
55
55
  maxResponseSizeMB: process.env.MAX_RESPONSE_SIZE_MB ? parseFloat(process.env.MAX_RESPONSE_SIZE_MB) : 1,
56
+ // Upper bound for a single image uploaded to ClickUp. Unlike maxResponseSizeMB this is
57
+ // not about context window budget - it only guards against accidentally pushing huge
58
+ // files into a ticket.
59
+ maxUploadSizeMB: process.env.MAX_UPLOAD_SIZE_MB ? parseFloat(process.env.MAX_UPLOAD_SIZE_MB) : 10,
56
60
  primaryLanguageHint: detectedLanguageHint, // Store the cleaned code directly
57
61
  mode: mcpMode,
58
62
  };
@@ -1,4 +1,9 @@
1
1
  import { ContentBlock, ImageMetadataBlock } from "./types";
2
+ /**
3
+ * Detect MIME type from image binary data using magic bytes (file signatures)
4
+ * Returns null if the format is not recognized
5
+ */
6
+ export declare function detectMimeTypeFromBuffer(buffer: ArrayBuffer): string | null;
2
7
  /**
3
8
  * Downloads images from image_metadata blocks and applies smart size/count limiting
4
9
  * Prioritizes keeping the most recent images (assumes content is ordered with newest items last)
@@ -1 +1 @@
1
- {"version":3,"file":"image-processing.d.ts","sourceRoot":"","sources":["../../src/shared/image-processing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAE,kBAAkB,EAAC,MAAM,SAAS,CAAC;AAmDzD;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,CAAC,YAAY,GAAG,kBAAkB,CAAC,EAAE,EAAE,SAAS,GAAE,MAAyB,EAAE,SAAS,GAAE,MAAiC,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CA0BhM"}
1
+ {"version":3,"file":"image-processing.d.ts","sourceRoot":"","sources":["../../src/shared/image-processing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAE,kBAAkB,EAAC,MAAM,SAAS,CAAC;AAKzD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CA0B3E;AAgBD;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,CAAC,YAAY,GAAG,kBAAkB,CAAC,EAAE,EAAE,SAAS,GAAE,MAAyB,EAAE,SAAS,GAAE,MAAiC,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CA0BhM"}
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectMimeTypeFromBuffer = detectMimeTypeFromBuffer;
3
4
  exports.downloadImages = downloadImages;
4
5
  const config_1 = require("./config");
5
6
  const data_uri_1 = require("./data-uri");
@@ -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;AAcpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QAsZtE"}
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"}
@@ -5,6 +5,67 @@ const zod_1 = require("zod");
5
5
  const config_1 = require("../shared/config");
6
6
  const utils_1 = require("../shared/utils");
7
7
  const clickup_text_1 = require("../clickup-text");
8
+ const attachments_1 = require("../shared/attachments");
9
+ /**
10
+ * Shared wording for the image support of every markdown field in this file.
11
+ * Kept in one place so the tools stay consistent about what a client may pass.
12
+ */
13
+ const IMAGE_SUPPORT_HINT = [
14
+ "IMAGES: Reference images with normal markdown - `![caption](/absolute/path/to/screenshot.png)`.",
15
+ "This server runs locally, so a local file path is read and uploaded automatically - never inline a screenshot as base64 when a path exists, it costs orders of magnitude more tokens.",
16
+ "Also accepted: `data:` URIs, http(s) URLs (downloaded and re-uploaded), and existing ClickUp attachment URLs (embedded as-is).",
17
+ "The caption becomes the attachment filename, which is what ClickUp displays beneath the image - so write a caption that reads well.",
18
+ ].join("\n");
19
+ /**
20
+ * 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.
22
+ */
23
+ async function prepareMarkdownImages(taskId, markdown) {
24
+ if (!markdown) {
25
+ return { results: [], failures: [], markdown: markdown ?? "" };
26
+ }
27
+ // Normalise first, then use the same string for collecting and converting - the
28
+ // sources must line up with what the converter later looks up.
29
+ const normalized = (0, clickup_text_1.normalizeImageDestinations)(markdown);
30
+ const sources = (0, clickup_text_1.collectMarkdownImageSources)(normalized);
31
+ if (sources.length === 0) {
32
+ return { results: [], failures: [], markdown: normalized };
33
+ }
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 };
39
+ }
40
+ /**
41
+ * Echo a markdown field back without repeating inline base64 payloads.
42
+ * Without this a single data-URI screenshot would be mirrored back into the
43
+ * response, costing as many tokens again as it did going in.
44
+ */
45
+ function summarizeMarkdownForEcho(markdown) {
46
+ return markdown.replace(/(!\[[^\]]*\]\()data:([^;,)]+)[^)]*(\))/g, (_match, prefix, mimeType, suffix) => `${prefix}[inline ${mimeType} data]${suffix}`);
47
+ }
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) {
52
+ return [];
53
+ }
54
+ 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}`),
67
+ ];
68
+ }
8
69
  // Shared schemas for task parameters
9
70
  const taskNameSchema = zod_1.z.string().min(1).describe("The name/title of the task");
10
71
  const taskPrioritySchema = zod_1.z.enum(["urgent", "high", "normal", "low"]).optional().describe("Optional priority level");
@@ -21,6 +82,8 @@ function registerTaskToolsWrite(server, userData) {
21
82
  "- Include task links when mentioning dependencies, related work, or follow-ups",
22
83
  "- Link to relevant lists, spaces, or other ClickUp entities when applicable",
23
84
  "PROGRESS UPDATES: Include current status, progress information, and next steps.",
85
+ IMAGE_SUPPORT_HINT,
86
+ "IMAGE LAYOUT: An image inside a numbered list breaks ClickUp's numbering. Write walkthrough steps as bold lines with a blank line before and after the image instead (`**1. Open the login page**`).",
24
87
  "If external links are provided, verify they are publicly accessible and incorporate relevant information.",
25
88
  "Check the task's current status - if it's in 'backlog' or similar inactive states, suggest moving it to an active status like 'in progress' when work is being done."
26
89
  ];
@@ -37,8 +100,11 @@ function registerTaskToolsWrite(server, userData) {
37
100
  idempotentHint: false,
38
101
  }, async ({ task_id, comment }) => {
39
102
  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);
40
106
  // Convert markdown to ClickUp formatted blocks
41
- const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(comment);
107
+ const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(results));
42
108
  const requestBody = {
43
109
  comment: commentBlocks,
44
110
  notify_all: true
@@ -64,9 +130,11 @@ function registerTaskToolsWrite(server, userData) {
64
130
  `Comment added successfully!`,
65
131
  `comment_id: ${commentData.id || 'N/A'}`,
66
132
  `task_id: ${task_id}`,
67
- `comment: ${comment}`,
133
+ `comment: ${summarizeMarkdownForEcho(comment)}`,
68
134
  `date: ${timestampToIso(commentData.date || Date.now())}`,
69
135
  `user: ${commentData.user?.username || 'Current user'}`,
136
+ ...formatAttachedImages(results),
137
+ ...formatImageFailures(failures),
70
138
  ].join('\n')
71
139
  }
72
140
  ],
@@ -91,6 +159,7 @@ function registerTaskToolsWrite(server, userData) {
91
159
  "Use getListInfo first to see valid status options.",
92
160
  "SAFETY FEATURE: Description updates are APPEND-ONLY to prevent data loss - existing content is preserved.",
93
161
  "STATUS UPDATES: Use the `addComment` tool for progress reports, work logs, and status updates rather than the task description.",
162
+ IMAGE_SUPPORT_HINT,
94
163
  "Task descriptions should contain requirements, specifications, and core task information.",
95
164
  "LINKING IN DESCRIPTIONS: When appending descriptions, include links to related tasks, lists, or external resources.",
96
165
  "IMPORTANT: When updating tasks (especially when booking time or adding progress), ensure the status makes sense for the work being done - tasks in 'backlog' or 'closed' states usually shouldn't have active work.",
@@ -181,11 +250,19 @@ function registerTaskToolsWrite(server, userData) {
181
250
  }
182
251
  // Handle append-only description update with markdown support
183
252
  let finalDescription;
253
+ let imageResults = [];
254
+ let imageFailures = [];
184
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));
185
262
  const currentDescription = taskData.markdown_description || "";
186
263
  const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
187
264
  const separator = currentDescription.trim() ? "\n\n---\n" : "";
188
- finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${append_description}`;
265
+ finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${appended}`;
189
266
  }
190
267
  // Build update body without tags (they're handled separately)
191
268
  const updateBody = buildTaskRequestBody({
@@ -247,6 +324,8 @@ function registerTaskToolsWrite(server, userData) {
247
324
  if (tagUpdateResults.length > 0) {
248
325
  responseLines.push('tag_warnings: ' + tagUpdateResults.join('; '));
249
326
  }
327
+ responseLines.push(...formatAttachedImages(imageResults));
328
+ responseLines.push(...formatImageFailures(imageFailures));
250
329
  return {
251
330
  content: [
252
331
  {
@@ -278,6 +357,7 @@ function registerTaskToolsWrite(server, userData) {
278
357
  "- The response will include the new task's clickable URL - always share this link",
279
358
  "Use getListInfo first to understand the list context and available statuses.",
280
359
  "Task descriptions support full markdown formatting including **bold**, *italic*, lists, links, and code blocks.",
360
+ IMAGE_SUPPORT_HINT,
281
361
  "BEST PRACTICE: Every task creation should result in sharing the clickable task URL for future reference."
282
362
  ];
283
363
  if (config_1.CONFIG.primaryLanguageHint && config_1.CONFIG.primaryLanguageHint.toLowerCase() !== 'en') {
@@ -325,9 +405,33 @@ function registerTaskToolsWrite(server, userData) {
325
405
  throw new Error(`Error creating task: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
326
406
  }
327
407
  const createdTask = await response.json();
408
+ // Images can only be attached once the task exists, so the description is
409
+ // 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);
412
+ if (description && attachmentMap.size > 0) {
413
+ const rewritten = (0, clickup_text_1.rewriteMarkdownImageUrls)(normalizedDescription, attachmentMap);
414
+ if (rewritten !== description) {
415
+ const descriptionResponse = await fetch(`https://api.clickup.com/api/v2/task/${createdTask.id}`, {
416
+ method: 'PUT',
417
+ headers: {
418
+ Authorization: config_1.CONFIG.apiKey,
419
+ 'Content-Type': 'application/json'
420
+ },
421
+ body: JSON.stringify({ markdown_description: rewritten })
422
+ });
423
+ if (!descriptionResponse.ok) {
424
+ // The task itself exists - report the problem instead of failing the call.
425
+ 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`);
427
+ }
428
+ }
429
+ }
328
430
  const responseLines = formatTaskResponse(createdTask, 'created', {
329
431
  list_id, name, description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees
330
432
  }, userData);
433
+ responseLines.push(...formatAttachedImages(imageResults));
434
+ responseLines.push(...formatImageFailures(imageFailures));
331
435
  return {
332
436
  content: [
333
437
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hauptsache.net/clickup-mcp",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "description": "Search, create, and retrieve tasks, add comments, and track time through natural language commands.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -14,11 +14,12 @@
14
14
  "start": "node dist/index.js",
15
15
  "dev": "npx tsc -w & nodemon dist/index.js",
16
16
  "cli": "npx ts-node src/cli.ts",
17
+ "smoke": "npx ts-node src/protocol-smoke.ts",
17
18
  "prettier": "prettier --write src/**/*.ts",
18
19
  "prepublishOnly": "rm -r dist && npm run build",
19
20
  "release": "npm run build && npm publish --access public && git add . && git commit -m \"Release v$(node -p 'require(\"./package.json\").version')\" && git tag -a v$(node -p 'require(\"./package.json\").version') -m \"Release v$(node -p 'require(\"./package.json\").version')\" && git push && git push --tags",
20
21
  "mcpb": "npm run build && mcpb pack . ClickUp.mcpb",
21
- "test": "node --test -r ts-node/register src/**/*.test.ts"
22
+ "test": "node --test -r ts-node/register -r ./src/tests/setup.ts src/**/*.test.ts"
22
23
  },
23
24
  "keywords": [
24
25
  "clickup",