@sebastienheyd/clickup-mcp 1.7.3 → 1.8.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 +37 -1
- package/dist/cli.js +3 -1
- package/dist/clickup-text.d.ts +82 -2
- package/dist/clickup-text.d.ts.map +1 -1
- package/dist/clickup-text.js +192 -21
- package/dist/index.js +0 -0
- package/dist/shared/attachments.d.ts +112 -0
- package/dist/shared/attachments.d.ts.map +1 -0
- package/dist/shared/attachments.js +268 -0
- package/dist/shared/config.d.ts +2 -0
- package/dist/shared/config.d.ts.map +1 -1
- package/dist/shared/config.js +23 -0
- package/dist/shared/image-processing.d.ts +5 -0
- package/dist/shared/image-processing.d.ts.map +1 -1
- package/dist/shared/image-processing.js +1 -0
- package/dist/tools/task-write-tools.d.ts.map +1 -1
- package/dist/tools/task-write-tools.js +323 -4
- package/package.json +4 -3
|
@@ -5,6 +5,88 @@ 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 - ``.",
|
|
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
|
+
* Phase 1 of image handling: parse the markdown and resolve every image source
|
|
21
|
+
* (read files, download URLs, decode data URIs) WITHOUT writing anything.
|
|
22
|
+
*
|
|
23
|
+
* Throws when any reference is unusable, listing every broken source at once.
|
|
24
|
+
* Nothing has been posted to ClickUp when this throws, so the caller's generic
|
|
25
|
+
* error path returns the report and the client can fix the markdown and retry.
|
|
26
|
+
*/
|
|
27
|
+
async function resolveImagesOrAbort(markdown, abortNotice) {
|
|
28
|
+
if (!markdown) {
|
|
29
|
+
return { markdown: markdown ?? "", images: [] };
|
|
30
|
+
}
|
|
31
|
+
// Normalise first, then use the same string for collecting and converting - the
|
|
32
|
+
// sources must line up with what the converter later looks up.
|
|
33
|
+
const normalized = (0, clickup_text_1.normalizeImageDestinations)(markdown);
|
|
34
|
+
const sources = (0, clickup_text_1.collectMarkdownImageSources)(normalized);
|
|
35
|
+
if (sources.length === 0) {
|
|
36
|
+
return { markdown: normalized, images: [] };
|
|
37
|
+
}
|
|
38
|
+
const { resolved, failures } = await (0, attachments_1.resolveMarkdownImages)(sources);
|
|
39
|
+
if (failures.length > 0) {
|
|
40
|
+
throw new Error([
|
|
41
|
+
`${failures.length} image reference(s) could not be used, so ${abortNotice}:`,
|
|
42
|
+
...failures.map((failure) => ` - ${failure.src}: ${failure.error}`),
|
|
43
|
+
`Fix or remove these image references and retry.`,
|
|
44
|
+
].join("\n"));
|
|
45
|
+
}
|
|
46
|
+
return { markdown: normalized, images: resolved };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Phase 2: upload the resolved images to the task.
|
|
50
|
+
*
|
|
51
|
+
* Throws on the first upload failure. Everything uploaded before the failure is
|
|
52
|
+
* listed with its CDN URL so a retry can reference those URLs directly (existing
|
|
53
|
+
* ClickUp URLs are embedded without re-uploading).
|
|
54
|
+
*/
|
|
55
|
+
async function uploadImagesOrAbort(taskId, images, abortNotice) {
|
|
56
|
+
if (images.length === 0) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const { uploaded, failure } = await (0, attachments_1.uploadResolvedImages)(taskId, images);
|
|
60
|
+
if (failure) {
|
|
61
|
+
const lines = [
|
|
62
|
+
`Uploading image "${failure.src}" failed, so ${abortNotice}:`,
|
|
63
|
+
` ${failure.error}`,
|
|
64
|
+
];
|
|
65
|
+
if (uploaded.length > 0) {
|
|
66
|
+
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}`));
|
|
67
|
+
}
|
|
68
|
+
throw new Error(lines.join("\n"));
|
|
69
|
+
}
|
|
70
|
+
return uploaded;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Echo a markdown field back without repeating inline base64 payloads.
|
|
74
|
+
* Without this a single data-URI screenshot would be mirrored back into the
|
|
75
|
+
* response, costing as many tokens again as it did going in.
|
|
76
|
+
*/
|
|
77
|
+
function summarizeMarkdownForEcho(markdown) {
|
|
78
|
+
return markdown.replace(/(!\[[^\]]*\]\()data:([^;,)]+)[^)]*(\))/g, (_match, prefix, mimeType, suffix) => `${prefix}[inline ${mimeType} data]${suffix}`);
|
|
79
|
+
}
|
|
80
|
+
/** Report successfully attached images so the caller can verify and link to them */
|
|
81
|
+
function formatAttachedImages(uploaded) {
|
|
82
|
+
if (uploaded.length === 0) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
return [
|
|
86
|
+
`images_attached: ${uploaded.length}`,
|
|
87
|
+
...uploaded.map((u) => ` - ${u.attachment.name} (${u.attachment.url})`),
|
|
88
|
+
];
|
|
89
|
+
}
|
|
8
90
|
// Shared schemas for task parameters
|
|
9
91
|
const taskNameSchema = zod_1.z.string().min(1).describe("The name/title of the task");
|
|
10
92
|
const taskPrioritySchema = zod_1.z.enum(["urgent", "high", "normal", "low"]).optional().describe("Optional priority level");
|
|
@@ -20,9 +102,12 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
20
102
|
"Adds a comment to a specific task.",
|
|
21
103
|
"LINKING BEST PRACTICES:",
|
|
22
104
|
"- Always reference related tasks using ClickUp URLs (https://app.clickup.com/t/TASK_ID)",
|
|
105
|
+
"- 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",
|
|
23
106
|
"- Include task links when mentioning dependencies, related work, or follow-ups",
|
|
24
107
|
"- Link to relevant lists, spaces, or other ClickUp entities when applicable",
|
|
25
108
|
"PROGRESS UPDATES: Include current status, progress information, and next steps.",
|
|
109
|
+
IMAGE_SUPPORT_HINT,
|
|
110
|
+
"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**`).",
|
|
26
111
|
"If external links are provided, verify they are publicly accessible and incorporate relevant information.",
|
|
27
112
|
"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."
|
|
28
113
|
];
|
|
@@ -43,8 +128,15 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
43
128
|
try {
|
|
44
129
|
// Resolve custom task ID to internal ID if needed
|
|
45
130
|
const resolved_task_id = await (0, utils_1.resolveTaskId)(task_id);
|
|
131
|
+
// Resolve and upload referenced images first - the fragments need the
|
|
132
|
+
// attachment objects from the upload response, a bare URL renders as an
|
|
133
|
+
// empty tile. Any image problem aborts BEFORE the comment is posted, so
|
|
134
|
+
// the caller can fix the markdown and retry without creating duplicates.
|
|
135
|
+
const abortNotice = "the comment was NOT posted";
|
|
136
|
+
const { markdown, images } = await resolveImagesOrAbort(comment, abortNotice);
|
|
137
|
+
const uploaded = await uploadImagesOrAbort(resolved_task_id, images, abortNotice);
|
|
46
138
|
// Convert markdown to ClickUp formatted blocks
|
|
47
|
-
const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(
|
|
139
|
+
const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(uploaded));
|
|
48
140
|
const requestBody = {
|
|
49
141
|
comment: commentBlocks,
|
|
50
142
|
notify_all: true
|
|
@@ -70,9 +162,10 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
70
162
|
`Comment added successfully!`,
|
|
71
163
|
`comment_id: ${commentData.id || 'N/A'}`,
|
|
72
164
|
`task_id: ${resolved_task_id}`,
|
|
73
|
-
`comment: ${comment}`,
|
|
165
|
+
`comment: ${summarizeMarkdownForEcho(comment)}`,
|
|
74
166
|
`date: ${timestampToIso(commentData.date || Date.now())}`,
|
|
75
167
|
`user: ${commentData.user?.username || 'Current user'}`,
|
|
168
|
+
...formatAttachedImages(uploaded),
|
|
76
169
|
].join('\n')
|
|
77
170
|
}
|
|
78
171
|
],
|
|
@@ -90,6 +183,92 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
90
183
|
};
|
|
91
184
|
}
|
|
92
185
|
});
|
|
186
|
+
tool("editComment", (() => {
|
|
187
|
+
const descriptionBase = [
|
|
188
|
+
"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.",
|
|
189
|
+
"The new text REPLACES the old one completely, it is not appended. Anything worth keeping must be repeated in `comment`.",
|
|
190
|
+
`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.`,
|
|
191
|
+
"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.",
|
|
192
|
+
"Editing does not reset the creation date, so the edit window does not get extended by editing.",
|
|
193
|
+
IMAGE_SUPPORT_HINT,
|
|
194
|
+
"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.",
|
|
195
|
+
"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.",
|
|
196
|
+
];
|
|
197
|
+
if (config_1.CONFIG.primaryLanguageHint && config_1.CONFIG.primaryLanguageHint.toLowerCase() !== 'en') {
|
|
198
|
+
descriptionBase.splice(1, 0, `For optimal results, consider writing comments in '${config_1.CONFIG.primaryLanguageHint}' unless the task is already in another language.`);
|
|
199
|
+
}
|
|
200
|
+
return descriptionBase.join("\n");
|
|
201
|
+
})(), {
|
|
202
|
+
task_id: zod_1.z.string().min(1).refine(val => (0, utils_1.isTaskId)(val) || (0, utils_1.isCustomTaskId)(val), {
|
|
203
|
+
message: "Must be an internal task ID (6+ alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
|
|
204
|
+
}).describe("The ID of the task the comment belongs to - needed to locate the comment and to upload images. Internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
|
|
205
|
+
comment_id: zod_1.z.string().min(1).describe("The ID of the comment to edit, as returned by addComment or getTaskById"),
|
|
206
|
+
comment: zod_1.z.string().min(1).describe("The new comment text, replacing the previous text completely"),
|
|
207
|
+
}, {
|
|
208
|
+
readOnlyHint: false,
|
|
209
|
+
destructiveHint: true,
|
|
210
|
+
idempotentHint: true,
|
|
211
|
+
}, async ({ task_id, comment_id, comment }) => {
|
|
212
|
+
try {
|
|
213
|
+
// Resolve custom task ID to internal ID if needed
|
|
214
|
+
const resolved_task_id = await (0, utils_1.resolveTaskId)(task_id);
|
|
215
|
+
const [existing, userData] = await Promise.all([
|
|
216
|
+
findTaskComment(resolved_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(resolved_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: ${resolved_task_id}`,
|
|
250
|
+
`task_url: https://app.clickup.com/t/${resolved_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
|
+
});
|
|
93
272
|
tool("updateTask", (() => {
|
|
94
273
|
const descriptionBase = [
|
|
95
274
|
"Updates various aspects of an existing task including dependencies and relationships.",
|
|
@@ -97,6 +276,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
97
276
|
"Use getListInfo first to see valid status options.",
|
|
98
277
|
"SAFETY FEATURE: Description updates are APPEND-ONLY to prevent data loss - existing content is preserved.",
|
|
99
278
|
"STATUS UPDATES: Use the `addComment` tool for progress reports, work logs, and status updates rather than the task description.",
|
|
279
|
+
IMAGE_SUPPORT_HINT,
|
|
100
280
|
"Task descriptions should contain requirements, specifications, and core task information.",
|
|
101
281
|
"LINKING IN DESCRIPTIONS: When appending descriptions, include links to related tasks, lists, or external resources.",
|
|
102
282
|
"IMPORTANT: When updating tasks (especially when booking time or adding progress), ensure the status makes sense for the work being done - tasks in 'backlog' or 'closed' states usually shouldn't have active work.",
|
|
@@ -141,6 +321,19 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
141
321
|
throw new Error(`Error fetching task: ${taskResponse.status} ${taskResponse.statusText}`);
|
|
142
322
|
}
|
|
143
323
|
const taskData = await taskResponse.json();
|
|
324
|
+
// Resolve and upload description images FIRST - an image problem must
|
|
325
|
+
// abort before dependencies, tags or the task itself are touched, so the
|
|
326
|
+
// caller can fix the markdown and retry the whole call cleanly.
|
|
327
|
+
let appendedDescription;
|
|
328
|
+
let uploadedImages = [];
|
|
329
|
+
if (append_description) {
|
|
330
|
+
const abortNotice = "the task was NOT updated";
|
|
331
|
+
const prepared = await resolveImagesOrAbort(append_description, abortNotice);
|
|
332
|
+
uploadedImages = await uploadImagesOrAbort(resolved_task_id, prepared.images, abortNotice);
|
|
333
|
+
// Descriptions render plain markdown, so no image fragments are involved
|
|
334
|
+
// here - the local paths are simply swapped for the CDN URLs.
|
|
335
|
+
appendedDescription = (0, clickup_text_1.rewriteMarkdownImageUrls)(prepared.markdown, (0, attachments_1.toAttachmentMap)(uploadedImages));
|
|
336
|
+
}
|
|
144
337
|
// Handle dependencies separately since they need individual API calls
|
|
145
338
|
let dependencyUpdateResults = [];
|
|
146
339
|
if (blocking !== undefined || waiting_on !== undefined || linked_tasks !== undefined) {
|
|
@@ -191,11 +384,11 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
191
384
|
}
|
|
192
385
|
// Handle append-only description update with markdown support
|
|
193
386
|
let finalDescription;
|
|
194
|
-
if (
|
|
387
|
+
if (appendedDescription !== undefined) {
|
|
195
388
|
const currentDescription = taskData.markdown_description || "";
|
|
196
389
|
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
|
197
390
|
const separator = currentDescription.trim() ? "\n\n---\n" : "";
|
|
198
|
-
finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${
|
|
391
|
+
finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${appendedDescription}`;
|
|
199
392
|
}
|
|
200
393
|
// Build update body without tags (they're handled separately)
|
|
201
394
|
const updateBody = buildTaskRequestBody({
|
|
@@ -257,6 +450,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
257
450
|
if (tagUpdateResults.length > 0) {
|
|
258
451
|
responseLines.push('tag_warnings: ' + tagUpdateResults.join('; '));
|
|
259
452
|
}
|
|
453
|
+
responseLines.push(...formatAttachedImages(uploadedImages));
|
|
260
454
|
return {
|
|
261
455
|
content: [
|
|
262
456
|
{
|
|
@@ -288,6 +482,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
288
482
|
"- The response will include the new task's clickable URL - always share this link",
|
|
289
483
|
"Use getListInfo first to understand the list context and available statuses.",
|
|
290
484
|
"Task descriptions support full markdown formatting including **bold**, *italic*, lists, links, and code blocks.",
|
|
485
|
+
IMAGE_SUPPORT_HINT,
|
|
291
486
|
"BEST PRACTICE: Every task creation should result in sharing the clickable task URL for future reference."
|
|
292
487
|
];
|
|
293
488
|
if (config_1.CONFIG.primaryLanguageHint && config_1.CONFIG.primaryLanguageHint.toLowerCase() !== 'en') {
|
|
@@ -313,6 +508,11 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
313
508
|
openWorldHint: true
|
|
314
509
|
}, async ({ list_id, name, description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees }) => {
|
|
315
510
|
try {
|
|
511
|
+
// Resolve description images BEFORE creating the task: a broken reference
|
|
512
|
+
// (missing file, dead URL, non-image) must not leave a half-finished task
|
|
513
|
+
// behind. Uploading has to wait until the task exists, though - ClickUp
|
|
514
|
+
// attachments always belong to a task.
|
|
515
|
+
const { markdown: normalizedDescription, images } = await resolveImagesOrAbort(description, "the task was NOT created");
|
|
316
516
|
const userData = await (0, utils_1.getCurrentUser)();
|
|
317
517
|
const currentUserId = userData.user.id;
|
|
318
518
|
const requestBody = buildTaskRequestBody({
|
|
@@ -358,9 +558,41 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
358
558
|
createdTask = await refreshResponse.json();
|
|
359
559
|
}
|
|
360
560
|
}
|
|
561
|
+
// Images can only be attached once the task exists, so the description is
|
|
562
|
+
// written first with its original sources and then rewritten to the CDN URLs.
|
|
563
|
+
// At this point every source resolved successfully - only the upload API
|
|
564
|
+
// itself can still fail, and then the task already exists, so that is
|
|
565
|
+
// reported as a warning instead of pretending the task was not created.
|
|
566
|
+
const imageWarnings = [];
|
|
567
|
+
const { uploaded, failure: uploadFailure } = await (0, attachments_1.uploadResolvedImages)(createdTask.id, images);
|
|
568
|
+
if (uploadFailure) {
|
|
569
|
+
console.error(`Failed to attach image "${uploadFailure.src}": ${uploadFailure.error}`);
|
|
570
|
+
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.`);
|
|
571
|
+
}
|
|
572
|
+
const attachmentMap = (0, attachments_1.toAttachmentMap)(uploaded);
|
|
573
|
+
if (description && attachmentMap.size > 0) {
|
|
574
|
+
const rewritten = (0, clickup_text_1.rewriteMarkdownImageUrls)(normalizedDescription, attachmentMap);
|
|
575
|
+
if (rewritten !== description) {
|
|
576
|
+
const descriptionResponse = await fetch(`https://api.clickup.com/api/v2/task/${createdTask.id}`, {
|
|
577
|
+
method: 'PUT',
|
|
578
|
+
headers: {
|
|
579
|
+
Authorization: config_1.CONFIG.apiKey,
|
|
580
|
+
'Content-Type': 'application/json'
|
|
581
|
+
},
|
|
582
|
+
body: JSON.stringify({ markdown_description: rewritten })
|
|
583
|
+
});
|
|
584
|
+
if (!descriptionResponse.ok) {
|
|
585
|
+
// The task itself exists - report the problem instead of failing the call.
|
|
586
|
+
console.error(`Failed to write image URLs into description: ${descriptionResponse.status}`);
|
|
587
|
+
imageWarnings.push(`WARNING: description update failed (${descriptionResponse.status} ${descriptionResponse.statusText}) - the images are attached to the task but not embedded in the description`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
361
591
|
const responseLines = formatTaskResponse(createdTask, 'created', {
|
|
362
592
|
list_id, name, description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees
|
|
363
593
|
}, userData);
|
|
594
|
+
responseLines.push(...formatAttachedImages(uploaded));
|
|
595
|
+
responseLines.push(...imageWarnings);
|
|
364
596
|
return {
|
|
365
597
|
content: [
|
|
366
598
|
{
|
|
@@ -406,6 +638,93 @@ function formatTimeEstimate(hours) {
|
|
|
406
638
|
const displayMinutes = Math.round((hours - displayHours) * 60);
|
|
407
639
|
return displayHours > 0 ? `${displayHours}h ${displayMinutes}m` : `${displayMinutes}m`;
|
|
408
640
|
}
|
|
641
|
+
/**
|
|
642
|
+
* Never page further back than this. A generous edit window would otherwise walk
|
|
643
|
+
* the entire comment history of a busy ticket and eat the 100 calls/minute budget.
|
|
644
|
+
*/
|
|
645
|
+
const MAX_COMMENT_PAGES = 10;
|
|
646
|
+
/** One page of task comments, newest first, 25 per page */
|
|
647
|
+
async function fetchCommentPage(taskId, cursor) {
|
|
648
|
+
// Note there is no `start_date` parameter - passing one is silently ignored.
|
|
649
|
+
// Older pages are reached with `start` + `start_id` of the previous page's last entry.
|
|
650
|
+
const query = cursor
|
|
651
|
+
? `?${new URLSearchParams({ start: cursor.start, start_id: cursor.startId })}`
|
|
652
|
+
: "";
|
|
653
|
+
const response = await fetch(`https://api.clickup.com/api/v2/task/${taskId}/comment${query}`, { headers: { Authorization: config_1.CONFIG.apiKey } });
|
|
654
|
+
if (!response.ok) {
|
|
655
|
+
const errorData = await response.json().catch(() => ({}));
|
|
656
|
+
throw new Error(`Error loading comments of task ${taskId}: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
|
|
657
|
+
}
|
|
658
|
+
const data = await response.json();
|
|
659
|
+
return Array.isArray(data.comments) ? data.comments : [];
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Load a single comment of a task.
|
|
663
|
+
*
|
|
664
|
+
* ClickUp has no `GET /comment/{id}`, so the task's comment list is the only way
|
|
665
|
+
* to learn a comment's author and age - both of which editComment has to check
|
|
666
|
+
* before touching anything.
|
|
667
|
+
*
|
|
668
|
+
* The list returns the 25 newest comments per page, so a busy ticket needs paging
|
|
669
|
+
* to reach the wanted comment. Paging stops as soon as a page ends outside the edit
|
|
670
|
+
* window: everything older would be refused anyway, which keeps this to a single
|
|
671
|
+
* request in the normal case.
|
|
672
|
+
*
|
|
673
|
+
* Note the list only contains top-level comments; replies inside a thread live
|
|
674
|
+
* behind `/comment/{parent_id}/reply` and are therefore not editable here.
|
|
675
|
+
*/
|
|
676
|
+
async function findTaskComment(taskId, commentId) {
|
|
677
|
+
const oldestEditableDate = Date.now() - config_1.CONFIG.commentEditWindowHours * 60 * 60 * 1000;
|
|
678
|
+
let cursor;
|
|
679
|
+
let checked = 0;
|
|
680
|
+
let pages = 0;
|
|
681
|
+
let sawThreadedReplies = false;
|
|
682
|
+
while (pages < MAX_COMMENT_PAGES) {
|
|
683
|
+
const page = await fetchCommentPage(taskId, cursor);
|
|
684
|
+
pages++;
|
|
685
|
+
if (page.length === 0) {
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
const match = page.find((entry) => String(entry.id) === String(commentId));
|
|
689
|
+
if (match) {
|
|
690
|
+
return match;
|
|
691
|
+
}
|
|
692
|
+
checked += page.length;
|
|
693
|
+
sawThreadedReplies || (sawThreadedReplies = page.some((entry) => (entry.reply_count ?? 0) > 0));
|
|
694
|
+
// Comments come back newest first, so once a page runs past the edit window
|
|
695
|
+
// there is nothing editable further back.
|
|
696
|
+
const oldest = page[page.length - 1];
|
|
697
|
+
if (Number(oldest.date) < oldestEditableDate) {
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
cursor = { start: String(oldest.date), startId: String(oldest.id) };
|
|
701
|
+
}
|
|
702
|
+
const threadedHint = sawThreadedReplies
|
|
703
|
+
? " This task has threaded replies, and replies inside a thread cannot be edited - answer them with a new comment instead."
|
|
704
|
+
: "";
|
|
705
|
+
throw new Error(`Comment ${commentId} was not found on task ${taskId} (${checked} top-level comment(s) checked across ${pages} page(s), newest first).${threadedHint}`);
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* The whole safety model of editComment.
|
|
709
|
+
*
|
|
710
|
+
* ClickUp cannot tell "written through this MCP" from "written by the token owner
|
|
711
|
+
* in the web UI" - both carry the same user id - so the author check only keeps
|
|
712
|
+
* other people's comments safe, and the time window is what keeps the tool from
|
|
713
|
+
* rewriting history.
|
|
714
|
+
*/
|
|
715
|
+
function assertCommentIsEditable(comment, currentUserId) {
|
|
716
|
+
const windowHours = config_1.CONFIG.commentEditWindowHours;
|
|
717
|
+
if (!(windowHours > 0)) {
|
|
718
|
+
throw new Error(`Editing comments is disabled (CLICKUP_COMMENT_EDIT_WINDOW_HOURS=${windowHours}). Add a new comment instead.`);
|
|
719
|
+
}
|
|
720
|
+
if (String(comment.user?.id ?? '') !== String(currentUserId)) {
|
|
721
|
+
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.`);
|
|
722
|
+
}
|
|
723
|
+
const ageHours = (Date.now() - Number(comment.date)) / (1000 * 60 * 60);
|
|
724
|
+
if (ageHours > windowHours) {
|
|
725
|
+
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.`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
409
728
|
/**
|
|
410
729
|
* Formats timestamp to ISO string with local timezone (not UTC)
|
|
411
730
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sebastienheyd/clickup-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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",
|
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
"start": "node dist/index.js",
|
|
17
17
|
"dev": "npx tsc -w & nodemon dist/index.js",
|
|
18
18
|
"cli": "npx ts-node src/cli.ts",
|
|
19
|
+
"smoke": "npx ts-node src/protocol-smoke.ts",
|
|
19
20
|
"prettier": "prettier --write src/**/*.ts",
|
|
20
|
-
"prepublishOnly": "rm -r dist && npm run build",
|
|
21
|
+
"prepublishOnly": "rm -r dist && npm run build && chmod +x dist/index.js",
|
|
21
22
|
"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",
|
|
22
23
|
"mcpb": "npm run build && mcpb pack . ClickUp.mcpb",
|
|
23
|
-
"test": "node --test -r ts-node/register src/**/*.test.ts"
|
|
24
|
+
"test": "node --test -r ts-node/register -r ./src/tests/setup.ts src/**/*.test.ts"
|
|
24
25
|
},
|
|
25
26
|
"keywords": [
|
|
26
27
|
"clickup",
|