@hauptsache.net/clickup-mcp 1.7.0 → 1.7.3
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/shared/utils.d.ts +1 -1
- package/dist/shared/utils.js +3 -3
- package/dist/tools/task-tools.js +3 -3
- package/dist/tools/task-write-tools.d.ts.map +1 -1
- package/dist/tools/task-write-tools.js +297 -51
- package/dist/tools/time-tools.js +2 -2
- package/package.json +1 -1
|
@@ -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.",
|
|
@@ -92,7 +123,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
92
123
|
}
|
|
93
124
|
return descriptionBase.join("\n");
|
|
94
125
|
})(), {
|
|
95
|
-
task_id: zod_1.z.string().min(6).max(
|
|
126
|
+
task_id: zod_1.z.string().min(6).max(16).describe("The 6-16 character task ID to comment on"),
|
|
96
127
|
comment: zod_1.z.string().min(1).describe("The comment text to add to the task"),
|
|
97
128
|
}, {
|
|
98
129
|
readOnlyHint: false,
|
|
@@ -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(16).describe("The 6-16 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.",
|
|
@@ -170,7 +287,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
170
287
|
}
|
|
171
288
|
return descriptionBase.join("\n");
|
|
172
289
|
})(), {
|
|
173
|
-
task_id: zod_1.z.string().min(6).max(
|
|
290
|
+
task_id: zod_1.z.string().min(6).max(16).describe("The 6-16 character task ID to update"),
|
|
174
291
|
name: taskNameSchema.optional(),
|
|
175
292
|
append_description: zod_1.z.string().optional().describe("Optional markdown content to APPEND to existing task description (preserves existing content for safety)"),
|
|
176
293
|
status: zod_1.z.string().optional().describe("Optional new status name - use getListInfo to see valid options"),
|
|
@@ -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/dist/tools/time-tools.js
CHANGED
|
@@ -52,7 +52,7 @@ function formatEntryTime(timestamp) {
|
|
|
52
52
|
}
|
|
53
53
|
function registerTimeToolsRead(server) {
|
|
54
54
|
server.tool("getTimeEntries", "Gets time entries for a specific task or all user's time entries. Returns last 30 days by default if no dates specified.", {
|
|
55
|
-
task_id: zod_1.z.string().min(6).max(
|
|
55
|
+
task_id: zod_1.z.string().min(6).max(16).optional().describe("Optional 6-16 character task ID to filter entries. If not provided, returns all user's time entries."),
|
|
56
56
|
start_date: zod_1.z.string().optional().describe("Optional start date filter as ISO date string (e.g., '2024-10-06T00:00:00+02:00'). Defaults to 30 days ago."),
|
|
57
57
|
end_date: zod_1.z.string().optional().describe("Optional end date filter as ISO date string (e.g., '2024-10-06T23:59:59+02:00'). Defaults to current date."),
|
|
58
58
|
list_id: zod_1.z.string().optional().describe("Optional single list ID to filter time entries by a specific list"),
|
|
@@ -267,7 +267,7 @@ function registerTimeToolsWrite(server) {
|
|
|
267
267
|
"IMPORTANT: Before booking time, check the task's status - booking time on tasks in 'backlog', 'closed', or similar inactive states usually doesn't make sense.",
|
|
268
268
|
"Suggest moving the task to an active status like 'in progress' first."
|
|
269
269
|
].join("\n"), {
|
|
270
|
-
task_id: zod_1.z.string().min(6).max(
|
|
270
|
+
task_id: zod_1.z.string().min(6).max(16).describe("The 6-16 character task ID to book time against"),
|
|
271
271
|
hours: zod_1.z.number().min(0.01).max(24).describe("Hours to book (decimal format, e.g., 0.25 = 15min, 1.5 = 1h 30min)"),
|
|
272
272
|
description: zod_1.z.string().optional().describe("Optional description for the time entry"),
|
|
273
273
|
start_time: zod_1.z.string().optional().describe("Optional start time as ISO date string (e.g., '2024-10-06T09:00:00+02:00', defaults to current time)")
|
package/package.json
CHANGED