@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 +9 -4
- package/dist/clickup-text.d.ts +15 -0
- package/dist/clickup-text.d.ts.map +1 -1
- package/dist/clickup-text.js +202 -21
- package/dist/shared/attachments.d.ts +37 -16
- package/dist/shared/attachments.d.ts.map +1 -1
- package/dist/shared/attachments.js +45 -31
- package/dist/shared/config.d.ts +1 -0
- package/dist/shared/config.d.ts.map +1 -1
- package/dist/shared/config.js +41 -1
- package/dist/tools/task-write-tools.d.ts.map +1 -1
- package/dist/tools/task-write-tools.js +295 -49
- package/package.json +1 -1
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
|
|
315
|
-
|
|
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
|
package/dist/clickup-text.d.ts
CHANGED
|
@@ -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":"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;
|
|
1
|
+
{"version":3,"file":"clickup-text.d.ts","sourceRoot":"","sources":["../src/clickup-text.ts"],"names":[],"mappings":"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"}
|
package/dist/clickup-text.js
CHANGED
|
@@ -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 ``;
|
|
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
|
-
//
|
|
88
|
-
|
|
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
|
-
//
|
|
352
|
+
// Keep the markdown syntax, so the reference stays usable in a write call
|
|
296
353
|
const imageFileName = altText || "image";
|
|
297
|
-
currentTextBlock += `\
|
|
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
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
65
|
-
export interface
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
*
|
|
77
|
+
* Phase 1 of attaching images: resolve every source without writing anything.
|
|
75
78
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
84
|
+
export declare function resolveMarkdownImages(images: {
|
|
82
85
|
src: string;
|
|
83
86
|
alt: string;
|
|
84
|
-
}[], baseDir?: string): Promise<
|
|
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
|
-
*
|
|
87
|
-
*
|
|
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
|
|
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,
|
|
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.
|
|
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
|
-
*
|
|
189
|
+
* Phase 1 of attaching images: resolve every source without writing anything.
|
|
189
190
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
const seen = new
|
|
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
|
-
|
|
201
|
-
if (cached) {
|
|
202
|
-
results.push(cached);
|
|
201
|
+
if (seen.has(src)) {
|
|
203
202
|
continue;
|
|
204
203
|
}
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
+
return { uploaded, failure: { src, error: message } };
|
|
236
257
|
}
|
|
237
|
-
seen.set(src, result);
|
|
238
|
-
results.push(result);
|
|
239
258
|
}
|
|
240
|
-
return
|
|
259
|
+
return { uploaded, failure: null };
|
|
241
260
|
}
|
|
242
|
-
/**
|
|
243
|
-
|
|
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
|
|
249
|
-
|
|
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
|
}
|
package/dist/shared/config.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/shared/config.js
CHANGED
|
@@ -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:
|
|
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;
|
|
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 - ``.",
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
|
35
|
+
async function resolveImagesOrAbort(markdown, abortNotice) {
|
|
24
36
|
if (!markdown) {
|
|
25
|
-
return {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
49
|
-
function formatAttachedImages(
|
|
50
|
-
|
|
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: ${
|
|
56
|
-
...
|
|
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
|
-
//
|
|
104
|
-
// from the upload response, a bare URL renders as an
|
|
105
|
-
|
|
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)(
|
|
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(
|
|
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
|
-
|
|
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}):** ${
|
|
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(
|
|
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
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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(
|
|
434
|
-
responseLines.push(...
|
|
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