@sebastienheyd/clickup-mcp 1.9.0 → 1.10.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.
@@ -6,10 +6,20 @@ const config_1 = require("../shared/config");
6
6
  const utils_1 = require("../shared/utils");
7
7
  const clickup_text_1 = require("../clickup-text");
8
8
  const attachments_1 = require("../shared/attachments");
9
+ const comments_1 = require("../shared/comments");
10
+ const dates_1 = require("../shared/dates");
9
11
  /**
10
12
  * Shared wording for the image support of every markdown field in this file.
11
13
  * Kept in one place so the tools stay consistent about what a client may pass.
12
14
  */
15
+ /**
16
+ * Shared wording for what markdown ClickUp comments can actually render.
17
+ * Kept in one place so addComment and editComment stay consistent.
18
+ */
19
+ const COMMENT_FORMATTING_HINT = [
20
+ "FORMATTING: Headings, **bold**, *italic*, ~~strikethrough~~, `inline code`, code blocks, links, blockquotes, bullet/numbered/nested lists and checkboxes (- [ ] / - [x]) all render natively.",
21
+ "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.",
22
+ ].join("\n");
13
23
  const IMAGE_SUPPORT_HINT = [
14
24
  "IMAGES: Reference images with normal markdown - `![caption](/absolute/path/to/screenshot.png)`.",
15
25
  "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.",
@@ -93,21 +103,25 @@ const taskPrioritySchema = zod_1.z.enum(["urgent", "high", "normal", "low"]).opt
93
103
  const taskDueDateSchema = zod_1.z.string().optional().describe("Optional due date as ISO date string (e.g., '2024-10-06T23:59:59+02:00')");
94
104
  const taskStartDateSchema = zod_1.z.string().optional().describe("Optional start date as ISO date string (e.g., '2024-10-06T09:00:00+02:00')");
95
105
  const taskTimeEstimateSchema = zod_1.z.number().optional().describe("Optional time estimate in hours (will be converted to milliseconds)");
96
- const taskPointsSchema = zod_1.z.number().nonnegative().optional().describe("Optional sprint points - requires the Sprint Points ClickApp on the space. Valid values depend on the points scale configured in that workspace: ClickUp rejects any other value with 'not a valid points selection'");
97
106
  const taskTagsSchema = zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of tag names");
107
+ const taskPointsSchema = zod_1.z.number().nonnegative().optional().describe("Optional sprint points - requires the Sprint Points ClickApp on the space. Valid values depend on the points scale configured in that workspace: ClickUp rejects any other value with 'not a valid points selection'");
108
+ const taskIdSchema = zod_1.z.string().min(1).refine(val => (0, utils_1.isTaskId)(val) || (0, utils_1.isCustomTaskId)(val), {
109
+ message: "Must be an internal task ID (6-16 alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
110
+ });
98
111
  const CLEAR_HINT = " - pass null to clear the current value";
99
112
  function registerTaskToolsWrite(server, userData) {
100
113
  // Workaround: SDK 1.27+ dual Zod v3/v4 type causes TS2589 on server.tool() generics
101
114
  const tool = server.tool.bind(server);
102
115
  tool("addComment", (() => {
103
116
  const descriptionBase = [
104
- "Adds a comment to a specific task.",
117
+ "Adds a comment to a specific task, or a threaded reply to an existing comment when `parent_comment_id` is set.",
105
118
  "LINKING BEST PRACTICES:",
106
119
  "- Always reference related tasks using ClickUp URLs (https://app.clickup.com/t/TASK_ID)",
107
120
  "- 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",
108
121
  "- Include task links when mentioning dependencies, related work, or follow-ups",
109
122
  "- Link to relevant lists, spaces, or other ClickUp entities when applicable",
110
123
  "PROGRESS UPDATES: Include current status, progress information, and next steps.",
124
+ COMMENT_FORMATTING_HINT,
111
125
  IMAGE_SUPPORT_HINT,
112
126
  "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**`).",
113
127
  "If external links are provided, verify they are publicly accessible and incorporate relevant information.",
@@ -118,32 +132,56 @@ function registerTaskToolsWrite(server, userData) {
118
132
  }
119
133
  return descriptionBase.join("\n");
120
134
  })(), {
121
- task_id: zod_1.z.string().min(1).refine(val => (0, utils_1.isTaskId)(val) || (0, utils_1.isCustomTaskId)(val), {
122
- message: "Must be an internal task ID (6+ alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
123
- }).describe("The task ID to comment on: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
135
+ task_id: taskIdSchema.describe("The task ID to comment on: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
124
136
  comment: zod_1.z.string().min(1).describe("The comment text to add to the task"),
137
+ parent_comment_id: zod_1.z.string().min(1).optional().describe("Optional: reply inside an existing comment thread instead of posting a new top-level comment. Pass the comment_id of a TOP-LEVEL comment as returned by getTaskById or addComment - ClickUp threads are one level deep, so a reply cannot have replies of its own."),
125
138
  }, {
126
139
  readOnlyHint: false,
127
140
  destructiveHint: false,
128
141
  idempotentHint: false,
129
- }, async ({ task_id, comment }) => {
142
+ }, async ({ task_id, comment, parent_comment_id }) => {
130
143
  try {
131
- // Resolve custom task ID to internal ID if needed
132
- const resolved_task_id = await (0, utils_1.resolveTaskId)(task_id);
144
+ // Resolve custom task IDs (e.g. "SOI-4422") to internal IDs
145
+ task_id = await (0, utils_1.resolveTaskId)(task_id);
146
+ // The reply endpoint anchors the reply to the parent comment's task and
147
+ // ignores task_id, while images below are uploaded to task_id - so the
148
+ // parent must verifiably be a top-level comment of THIS task before
149
+ // anything is written. This also rejects reply ids (nested replies are
150
+ // not supported by ClickUp's one-level threads).
151
+ if (parent_comment_id) {
152
+ const { comment: parent, exhaustive } = await (0, comments_1.findTopLevelComment)(task_id, parent_comment_id);
153
+ if (!parent && !exhaustive) {
154
+ // The lookup stopped at the page cap: the id may well be a valid but old
155
+ // comment, so do not claim it is invalid.
156
+ throw new Error(`Comment ${parent_comment_id} was not found among the ${comments_1.MAX_COMMENT_PAGES * comments_1.COMMENTS_PER_PAGE} newest top-level comments of task ${task_id}, so no reply was posted. ` +
157
+ `The lookup stops after ${comments_1.MAX_COMMENT_PAGES} pages to protect the API budget and getTaskById shows the same range - ` +
158
+ `reply to a comment listed there, or post a new top-level comment instead.`);
159
+ }
160
+ if (!parent) {
161
+ throw new Error(`Comment ${parent_comment_id} is not a top-level comment of task ${task_id}, so no reply was posted. ` +
162
+ `parent_comment_id must be the comment_id of a top-level comment of this task as returned by getTaskById - ` +
163
+ `a reply's id or a comment of another task cannot be used.`);
164
+ }
165
+ }
133
166
  // Resolve and upload referenced images first - the fragments need the
134
167
  // attachment objects from the upload response, a bare URL renders as an
135
168
  // empty tile. Any image problem aborts BEFORE the comment is posted, so
136
169
  // the caller can fix the markdown and retry without creating duplicates.
137
170
  const abortNotice = "the comment was NOT posted";
138
171
  const { markdown, images } = await resolveImagesOrAbort(comment, abortNotice);
139
- const uploaded = await uploadImagesOrAbort(resolved_task_id, images, abortNotice);
172
+ const uploaded = await uploadImagesOrAbort(task_id, images, abortNotice);
140
173
  // Convert markdown to ClickUp formatted blocks
141
174
  const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(uploaded));
142
175
  const requestBody = {
143
176
  comment: commentBlocks,
144
177
  notify_all: true
145
178
  };
146
- const response = await fetch(`https://api.clickup.com/api/v2/task/${resolved_task_id}/comment`, {
179
+ // A threaded reply goes to the parent comment's reply endpoint; the body
180
+ // is identical to a top-level comment.
181
+ const url = parent_comment_id
182
+ ? `https://api.clickup.com/api/v2/comment/${parent_comment_id}/reply`
183
+ : `https://api.clickup.com/api/v2/task/${task_id}/comment`;
184
+ const response = await fetch(url, {
147
185
  method: 'POST',
148
186
  headers: {
149
187
  Authorization: config_1.CONFIG.apiKey,
@@ -153,7 +191,7 @@ function registerTaskToolsWrite(server, userData) {
153
191
  });
154
192
  if (!response.ok) {
155
193
  const errorData = await response.json().catch(() => ({}));
156
- throw new Error(`Error adding comment: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
194
+ throw new Error(`ClickUp API ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
157
195
  }
158
196
  const commentData = await response.json();
159
197
  return {
@@ -161,9 +199,13 @@ function registerTaskToolsWrite(server, userData) {
161
199
  {
162
200
  type: "text",
163
201
  text: [
164
- `Comment added successfully!`,
202
+ parent_comment_id ? `Reply added successfully!` : `Comment added successfully!`,
165
203
  `comment_id: ${commentData.id || 'N/A'}`,
166
- `task_id: ${resolved_task_id}`,
204
+ ...(parent_comment_id ? [
205
+ `parent_comment_id: ${parent_comment_id}`,
206
+ `note: ClickUp threads are one level deep - a reply's comment_id cannot be used as parent_comment_id or with editComment.`,
207
+ ] : []),
208
+ `task_id: ${task_id}`,
167
209
  `comment: ${summarizeMarkdownForEcho(comment)}`,
168
210
  `date: ${timestampToIso(commentData.date || Date.now())}`,
169
211
  `user: ${commentData.user?.username || 'Current user'}`,
@@ -192,8 +234,9 @@ function registerTaskToolsWrite(server, userData) {
192
234
  `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.`,
193
235
  "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.",
194
236
  "Editing does not reset the creation date, so the edit window does not get extended by editing.",
195
- IMAGE_SUPPORT_HINT,
196
- "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.",
237
+ COMMENT_FORMATTING_HINT,
238
+ "IMAGES ON EDIT: an edit can only KEEP the images already in this comment. Reading the comment (getTaskById) returns them as markdown, and passing that markdown back keeps them; an image whose markdown you drop disappears.",
239
+ "ClickUp refuses to add any other image to an existing comment - a new file, a data URI, a web URL, or an image of another comment or of the task description - so such a reference is rejected before anything is uploaded. To share a new image, post a new comment with addComment.",
197
240
  "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.",
198
241
  ];
199
242
  if (config_1.CONFIG.primaryLanguageHint && config_1.CONFIG.primaryLanguageHint.toLowerCase() !== 'en') {
@@ -201,10 +244,8 @@ function registerTaskToolsWrite(server, userData) {
201
244
  }
202
245
  return descriptionBase.join("\n");
203
246
  })(), {
204
- task_id: zod_1.z.string().min(1).refine(val => (0, utils_1.isTaskId)(val) || (0, utils_1.isCustomTaskId)(val), {
205
- message: "Must be an internal task ID (6+ alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
206
- }).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\")"),
207
- comment_id: zod_1.z.string().min(1).describe("The ID of the comment to edit, as returned by addComment or getTaskById"),
247
+ task_id: taskIdSchema.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\")"),
248
+ comment_id: zod_1.z.string().min(1).describe("The ID of the comment to edit, as returned by addComment or getTaskById. Only top-level comments can be edited - replies inside a thread cannot."),
208
249
  comment: zod_1.z.string().min(1).describe("The new comment text, replacing the previous text completely"),
209
250
  }, {
210
251
  readOnlyHint: false,
@@ -212,21 +253,21 @@ function registerTaskToolsWrite(server, userData) {
212
253
  idempotentHint: true,
213
254
  }, async ({ task_id, comment_id, comment }) => {
214
255
  try {
215
- // Resolve custom task ID to internal ID if needed
216
- const resolved_task_id = await (0, utils_1.resolveTaskId)(task_id);
256
+ // Resolve custom task IDs (e.g. "SOI-4422") to internal IDs
257
+ task_id = await (0, utils_1.resolveTaskId)(task_id);
217
258
  const [existing, userData] = await Promise.all([
218
- findTaskComment(resolved_task_id, comment_id),
259
+ findTaskComment(task_id, comment_id),
219
260
  (0, utils_1.getCurrentUser)(),
220
261
  ]);
221
262
  assertCommentIsEditable(existing, userData.user.id);
222
- // Same pipeline as addComment - the undocumented rich `comment` array is
223
- // accepted by PUT too, so formatting and images survive an edit. Images are
224
- // resolved and uploaded before the PUT, so a broken reference leaves the
225
- // existing comment untouched.
263
+ // Same converter as addComment - the undocumented rich `comment` array is
264
+ // accepted by PUT too, so formatting survives an edit. Images are different:
265
+ // ClickUp only lets an edit keep the comment's OWN images, so they are
266
+ // matched against the stored fragments and nothing is ever uploaded here.
226
267
  const abortNotice = "the comment was NOT changed";
227
- const { markdown, images } = await resolveImagesOrAbort(comment, abortNotice);
228
- const uploaded = await uploadImagesOrAbort(resolved_task_id, images, abortNotice);
229
- const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(uploaded));
268
+ const markdown = (0, clickup_text_1.normalizeImageDestinations)(comment);
269
+ const keptImages = keepOwnCommentImages(existing, markdown, abortNotice);
270
+ const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(keptImages));
230
271
  // Only `comment` is sent: sending `comment_text` alongside it appends that
231
272
  // string to the blocks instead of being ignored.
232
273
  const response = await fetch(`https://api.clickup.com/api/v2/comment/${comment_id}`, {
@@ -239,7 +280,7 @@ function registerTaskToolsWrite(server, userData) {
239
280
  });
240
281
  if (!response.ok) {
241
282
  const errorData = await response.json().catch(() => ({}));
242
- throw new Error(`Error editing comment: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
283
+ throw new Error(`ClickUp API ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
243
284
  }
244
285
  return {
245
286
  content: [
@@ -248,12 +289,15 @@ function registerTaskToolsWrite(server, userData) {
248
289
  text: [
249
290
  `Comment edited successfully!`,
250
291
  `comment_id: ${comment_id}`,
251
- `task_id: ${resolved_task_id}`,
252
- `task_url: https://app.clickup.com/t/${resolved_task_id}`,
292
+ `task_id: ${task_id}`,
293
+ `task_url: https://app.clickup.com/t/${task_id}`,
253
294
  `created: ${timestampToIso(existing.date)} (unchanged by the edit)`,
254
295
  `previous_text: ${existing.comment_text || '(no plain text available)'}`,
255
296
  `new_comment: ${summarizeMarkdownForEcho(comment)}`,
256
- ...formatAttachedImages(uploaded),
297
+ ...(keptImages.length > 0 ? [
298
+ `images_kept: ${keptImages.length}`,
299
+ ...keptImages.map((k) => ` - ${k.attachment.name} (${k.attachment.url})`),
300
+ ] : []),
257
301
  ].join('\n')
258
302
  }
259
303
  ],
@@ -275,9 +319,7 @@ function registerTaskToolsWrite(server, userData) {
275
319
  "Deletes a task comment. This cannot be undone - the deleted text is echoed back so it can be re-posted with addComment if needed.",
276
320
  `GUARDRAILS: same as editComment - only comments written by the API token's own user can be deleted, and only within ${config_1.CONFIG.commentEditWindowHours} hours of their creation. Replies inside a thread cannot be deleted.`,
277
321
  ].join("\n"), {
278
- task_id: zod_1.z.string().min(1).refine(val => (0, utils_1.isTaskId)(val) || (0, utils_1.isCustomTaskId)(val), {
279
- message: "Must be an internal task ID (6+ alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
280
- }).describe("The ID of the task the comment belongs to - needed to locate the comment. Internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
322
+ task_id: taskIdSchema.describe("The ID of the task the comment belongs to - needed to locate the comment. Internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
281
323
  comment_id: zod_1.z.string().min(1).describe("The ID of the comment to delete, as returned by addComment or getTaskById"),
282
324
  }, {
283
325
  readOnlyHint: false,
@@ -297,7 +339,7 @@ function registerTaskToolsWrite(server, userData) {
297
339
  });
298
340
  if (!response.ok) {
299
341
  const errorData = await response.json().catch(() => ({}));
300
- throw new Error(`Error deleting comment: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
342
+ throw new Error(`ClickUp API ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
301
343
  }
302
344
  return {
303
345
  content: [
@@ -332,11 +374,12 @@ function registerTaskToolsWrite(server, userData) {
332
374
  "Updates various aspects of an existing task including dependencies and relationships.",
333
375
  "ALWAYS include the task URL (https://app.clickup.com/t/TASK_ID) when updating or referencing tasks.",
334
376
  "Use getListInfo first to see valid status options.",
335
- "DESCRIPTION: `append_description` adds a dated block under the existing description and is the safe default for notes. `replace_description` rewrites the whole description - use it to correct or restructure the specification. Read the current description with getTaskById first and repeat everything worth keeping; the previous description is echoed back so it can be restored with another call.",
377
+ "DESCRIPTIONS: `append_description` adds a dated section below the existing text; `description` REPLACES the whole text (use it to restructure, shorten or correct a description). Pass only one of them.",
378
+ "Before replacing, read the current description via getTaskById and carry over everything that is still needed - ClickUp keeps a description history, so a bad replacement can be restored in the UI (edits within the same minute merge into one version), but the MCP cannot undo it.",
336
379
  "STATUS UPDATES: Use the `addComment` tool for progress reports, work logs, and status updates rather than the task description.",
337
380
  IMAGE_SUPPORT_HINT,
338
381
  "Task descriptions should contain requirements, specifications, and core task information.",
339
- "LINKING IN DESCRIPTIONS: When appending descriptions, include links to related tasks, lists, or external resources.",
382
+ "LINKING IN DESCRIPTIONS: Include links to related tasks, lists, or external resources (format: https://app.clickup.com/t/TASK_ID).",
340
383
  "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.",
341
384
  "Suggest appropriate status transitions and always provide the clickable task URL in responses."
342
385
  ];
@@ -345,12 +388,10 @@ function registerTaskToolsWrite(server, userData) {
345
388
  }
346
389
  return descriptionBase.join("\n");
347
390
  })(), {
348
- task_id: zod_1.z.string().min(1).refine(val => (0, utils_1.isTaskId)(val) || (0, utils_1.isCustomTaskId)(val), {
349
- message: "Must be an internal task ID (6+ alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
350
- }).describe("The task ID to update: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
391
+ task_id: taskIdSchema.describe("The task ID to update: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
351
392
  name: taskNameSchema.optional(),
352
- append_description: zod_1.z.string().optional().describe("Optional markdown content to APPEND under the existing task description as a dated block (preserves existing content). Mutually exclusive with replace_description"),
353
- replace_description: zod_1.z.string().optional().describe("Optional markdown content that REPLACES the whole task description. Anything not repeated here is lost (the previous description is echoed back in the response). Mutually exclusive with append_description"),
393
+ description: zod_1.z.string().optional().describe("Optional markdown that REPLACES the entire task description. Read the current description first (getTaskById) and repeat everything worth keeping - nothing is merged. Cannot be combined with append_description."),
394
+ append_description: zod_1.z.string().optional().describe("Optional markdown content to APPEND below the existing task description as a dated `**Edit (YYYY-MM-DD):**` section (existing content is preserved). Cannot be combined with description."),
354
395
  status: zod_1.z.string().optional().describe("Optional new status name - use getListInfo to see valid options"),
355
396
  priority: taskPrioritySchema,
356
397
  due_date: taskDueDateSchema.nullable().describe(taskDueDateSchema.description + CLEAR_HINT),
@@ -358,7 +399,7 @@ function registerTaskToolsWrite(server, userData) {
358
399
  time_estimate: taskTimeEstimateSchema.nullable().describe(taskTimeEstimateSchema.description + CLEAR_HINT),
359
400
  points: taskPointsSchema.nullable().describe(taskPointsSchema.description + CLEAR_HINT),
360
401
  tags: taskTagsSchema.describe("Optional array of tag names (will replace existing tags)"),
361
- parent_task_id: zod_1.z.string().optional().describe("Optional parent task ID to change parent/child relationships"),
402
+ parent_task_id: taskIdSchema.optional().describe("Optional parent task ID to move this task under, as a subtask: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
362
403
  assignees: zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of user IDs to add as assignees - existing assignees are kept"),
363
404
  remove_assignees: zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of user IDs to unassign from the task. Combine with assignees to replace one assignee by another."),
364
405
  waiting_on: zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of task IDs that this task should wait on (will replace existing waiting_on relationships)"),
@@ -369,18 +410,37 @@ function registerTaskToolsWrite(server, userData) {
369
410
  destructiveHint: true,
370
411
  idempotentHint: false,
371
412
  openWorldHint: true
372
- }, async ({ task_id, name, append_description, replace_description, status, priority, due_date, start_date, time_estimate, points, tags, parent_task_id, assignees, remove_assignees, blocking, waiting_on, linked_tasks }) => {
413
+ }, async ({ task_id, name, description, append_description, status, priority, due_date, start_date, time_estimate, points, tags, parent_task_id, assignees, remove_assignees, blocking, waiting_on, linked_tasks }) => {
373
414
  try {
374
- if (append_description !== undefined && replace_description !== undefined) {
415
+ // Argument errors are checked before anything hits the network: resolving a
416
+ // custom task ID costs an API call that would be wasted on a rejected request.
417
+ if (description !== undefined && append_description !== undefined) {
375
418
  return {
376
- content: [{ type: "text", text: "Pass either append_description or replace_description, not both. The task was NOT updated." }],
419
+ content: [{
420
+ type: "text",
421
+ text: "Error: `description` (replace) and `append_description` (append) are mutually exclusive - pass only one of them. The task was NOT updated."
422
+ }],
377
423
  };
378
424
  }
379
- // Resolve custom task ID to internal ID if needed
380
- const resolved_task_id = await (0, utils_1.resolveTaskId)(task_id);
425
+ // A malformed date must be refused before anything is written: it would
426
+ // otherwise be sent as null and clear the date (see parseDateInput).
427
+ assertValidTaskDates({ due_date, start_date });
428
+ // Resolve custom task IDs (e.g. "SOI-4422") to internal IDs
429
+ task_id = await (0, utils_1.resolveTaskId)(task_id);
430
+ // Current dependencies and links come back from ClickUp with internal IDs,
431
+ // so the requested lists must use them too: an unresolved "SOI-4422" would
432
+ // not match its own internal ID, and the existing relation would be removed
433
+ // as "no longer requested" while a duplicate is added. Resolved up front, so
434
+ // an unknown custom ID aborts the update before anything is written.
435
+ [blocking, waiting_on, linked_tasks] = await Promise.all([blocking, waiting_on, linked_tasks].map(resolveTaskIdList));
436
+ // ClickUp's `parent` field only takes an internal ID: a custom ID gets a bare
437
+ // 500 Internal Server Error (verified live), with nothing to tell what is wrong.
438
+ if (parent_task_id !== undefined) {
439
+ parent_task_id = await (0, utils_1.resolveTaskId)(parent_task_id);
440
+ }
381
441
  const userData = await (0, utils_1.getCurrentUser)();
382
442
  // Get task details including current markdown description
383
- const taskResponse = await fetch(`https://api.clickup.com/api/v2/task/${resolved_task_id}?include_markdown_description=true`, {
443
+ const taskResponse = await fetch(`https://api.clickup.com/api/v2/task/${task_id}?include_markdown_description=true`, {
384
444
  headers: { Authorization: config_1.CONFIG.apiKey },
385
445
  });
386
446
  if (!taskResponse.ok) {
@@ -390,15 +450,16 @@ function registerTaskToolsWrite(server, userData) {
390
450
  // Resolve and upload description images FIRST - an image problem must
391
451
  // abort before dependencies, tags or the task itself are touched, so the
392
452
  // caller can fix the markdown and retry the whole call cleanly.
393
- // Both description modes go through the same pipeline: only what is done
394
- // with the prepared markdown differs (appended block vs whole description).
453
+ // `description` replaces, `append_description` appends; both go through
454
+ // the same image pipeline. An empty `description` is a valid request to
455
+ // clear the description, so check for undefined rather than truthiness.
456
+ const descriptionInput = description !== undefined ? description : append_description;
395
457
  let preparedDescription;
396
458
  let uploadedImages = [];
397
- const descriptionInput = replace_description !== undefined ? replace_description : append_description;
398
- if (descriptionInput) {
459
+ if (descriptionInput !== undefined) {
399
460
  const abortNotice = "the task was NOT updated";
400
461
  const prepared = await resolveImagesOrAbort(descriptionInput, abortNotice);
401
- uploadedImages = await uploadImagesOrAbort(resolved_task_id, prepared.images, abortNotice);
462
+ uploadedImages = await uploadImagesOrAbort(task_id, prepared.images, abortNotice);
402
463
  // Descriptions render plain markdown, so no image fragments are involved
403
464
  // here - the local paths are simply swapped for the CDN URLs.
404
465
  preparedDescription = (0, clickup_text_1.rewriteMarkdownImageUrls)(prepared.markdown, (0, attachments_1.toAttachmentMap)(uploadedImages));
@@ -406,7 +467,7 @@ function registerTaskToolsWrite(server, userData) {
406
467
  // Handle dependencies separately since they need individual API calls
407
468
  let dependencyUpdateResults = [];
408
469
  if (blocking !== undefined || waiting_on !== undefined || linked_tasks !== undefined) {
409
- const dependencyResults = await updateTaskDependencies(resolved_task_id, taskData, { blocking, waiting_on, linked_tasks });
470
+ const dependencyResults = await updateTaskDependencies(task_id, taskData, { blocking, waiting_on, linked_tasks });
410
471
  dependencyUpdateResults = dependencyResults;
411
472
  }
412
473
  // Handle tags separately since they need individual API calls
@@ -419,7 +480,7 @@ function registerTaskToolsWrite(server, userData) {
419
480
  // Add new tags
420
481
  for (const tagName of tagsToAdd) {
421
482
  try {
422
- const addTagResponse = await fetch(`https://api.clickup.com/api/v2/task/${resolved_task_id}/tag/${encodeURIComponent(tagName)}`, {
483
+ const addTagResponse = await fetch(`https://api.clickup.com/api/v2/task/${task_id}/tag/${encodeURIComponent(tagName)}`, {
423
484
  method: 'POST',
424
485
  headers: { Authorization: config_1.CONFIG.apiKey }
425
486
  });
@@ -436,7 +497,7 @@ function registerTaskToolsWrite(server, userData) {
436
497
  // Remove old tags
437
498
  for (const tagName of tagsToRemove) {
438
499
  try {
439
- const removeTagResponse = await fetch(`https://api.clickup.com/api/v2/task/${resolved_task_id}/tag/${encodeURIComponent(tagName)}`, {
500
+ const removeTagResponse = await fetch(`https://api.clickup.com/api/v2/task/${task_id}/tag/${encodeURIComponent(tagName)}`, {
440
501
  method: 'DELETE',
441
502
  headers: { Authorization: config_1.CONFIG.apiKey }
442
503
  });
@@ -451,14 +512,15 @@ function registerTaskToolsWrite(server, userData) {
451
512
  }
452
513
  }
453
514
  }
454
- // Append adds a dated block under the current description; replace sends
455
- // the prepared markdown as is. An empty replace_description clears it.
515
+ // Build the description to write: a full replacement, or the existing
516
+ // text plus a dated append section.
456
517
  let finalDescription;
457
- const currentDescription = taskData.markdown_description || "";
458
- if (replace_description !== undefined) {
518
+ if (description !== undefined) {
519
+ // An empty string clears the description (the image pipeline skips empty input)
459
520
  finalDescription = preparedDescription ?? "";
460
521
  }
461
522
  else if (preparedDescription !== undefined) {
523
+ const currentDescription = taskData.markdown_description || "";
462
524
  const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
463
525
  const separator = currentDescription.trim() ? "\n\n---\n" : "";
464
526
  finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${preparedDescription}`;
@@ -489,7 +551,7 @@ function registerTaskToolsWrite(server, userData) {
489
551
  // Update the task (if there are non-tag updates)
490
552
  let updatedTask = taskData;
491
553
  if (Object.keys(updateBody).length > 0) {
492
- const updateResponse = await fetch(`https://api.clickup.com/api/v2/task/${resolved_task_id}`, {
554
+ const updateResponse = await fetch(`https://api.clickup.com/api/v2/task/${task_id}`, {
493
555
  method: 'PUT',
494
556
  headers: {
495
557
  Authorization: config_1.CONFIG.apiKey,
@@ -499,13 +561,13 @@ function registerTaskToolsWrite(server, userData) {
499
561
  });
500
562
  if (!updateResponse.ok) {
501
563
  const errorData = await updateResponse.json().catch(() => ({}));
502
- throw new Error(`Error updating task: ${updateResponse.status} ${updateResponse.statusText} - ${JSON.stringify(errorData)}`);
564
+ throw new Error(`ClickUp API ${updateResponse.status} ${updateResponse.statusText} - ${JSON.stringify(errorData)}`);
503
565
  }
504
566
  updatedTask = await updateResponse.json();
505
567
  }
506
568
  // If only tags or dependencies were updated, fetch the task again to get the updated state
507
569
  if ((tags !== undefined || blocking !== undefined || waiting_on !== undefined || linked_tasks !== undefined) && Object.keys(updateBody).length === 0) {
508
- const refreshResponse = await fetch(`https://api.clickup.com/api/v2/task/${resolved_task_id}`, {
570
+ const refreshResponse = await fetch(`https://api.clickup.com/api/v2/task/${task_id}`, {
509
571
  headers: { Authorization: config_1.CONFIG.apiKey },
510
572
  });
511
573
  if (refreshResponse.ok) {
@@ -513,11 +575,16 @@ function registerTaskToolsWrite(server, userData) {
513
575
  }
514
576
  }
515
577
  const responseLines = formatTaskResponse(updatedTask, 'updated', {
516
- name, append_description, status, priority, due_date, start_date, time_estimate, points, tags, parent_task_id, assignees, remove_assignees, blocking, waiting_on, linked_tasks
578
+ name, description, append_description, status, priority, due_date, start_date, time_estimate, points, tags, parent_task_id, assignees, remove_assignees, blocking, waiting_on, linked_tasks
517
579
  }, userData);
518
- // Echo what was overwritten so a wrong replacement can be undone with another call
519
- if (replace_description !== undefined) {
520
- responseLines.push(`previous_description: ${currentDescription.trim() ? currentDescription : '(empty)'}`);
580
+ if (description !== undefined) {
581
+ const previousDescription = taskData.markdown_description || "";
582
+ responseLines.push(`description: replaced (previous ${previousDescription.length} chars -> ${finalDescription?.length ?? 0} chars; the old version stays restorable via ClickUp's description history)`);
583
+ // Echo what was overwritten so a wrong replacement can be undone with another call
584
+ responseLines.push(`previous_description: ${previousDescription.trim() ? previousDescription : '(empty)'}`);
585
+ }
586
+ else if (append_description !== undefined) {
587
+ responseLines.push('description: appended as dated edit section');
521
588
  }
522
589
  // Add dependency update results if any
523
590
  if (dependencyUpdateResults.length > 0) {
@@ -577,7 +644,7 @@ function registerTaskToolsWrite(server, userData) {
577
644
  time_estimate: taskTimeEstimateSchema,
578
645
  points: taskPointsSchema,
579
646
  tags: taskTagsSchema,
580
- parent_task_id: zod_1.z.string().optional().describe("Optional parent task ID to create this as a subtask"),
647
+ parent_task_id: taskIdSchema.optional().describe("Optional parent task ID to create this as a subtask: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
581
648
  assignees: zod_1.z.array(zod_1.z.string()).optional().describe(createAssigneeDescription(userData))
582
649
  }, {
583
650
  readOnlyHint: false,
@@ -591,6 +658,12 @@ function registerTaskToolsWrite(server, userData) {
591
658
  // behind. Uploading has to wait until the task exists, though - ClickUp
592
659
  // attachments always belong to a task.
593
660
  const { markdown: normalizedDescription, images } = await resolveImagesOrAbort(description, "the task was NOT created");
661
+ // ClickUp's `parent` field only takes an internal ID: a custom ID gets a bare
662
+ // 500 Internal Server Error (verified live). Resolved before the task exists,
663
+ // so an unknown parent leaves nothing behind.
664
+ if (parent_task_id !== undefined) {
665
+ parent_task_id = await (0, utils_1.resolveTaskId)(parent_task_id);
666
+ }
594
667
  const userData = await (0, utils_1.getCurrentUser)();
595
668
  const currentUserId = userData.user.id;
596
669
  const requestBody = buildTaskRequestBody({
@@ -610,30 +683,29 @@ function registerTaskToolsWrite(server, userData) {
610
683
  });
611
684
  if (!response.ok) {
612
685
  const errorData = await response.json().catch(() => ({}));
613
- throw new Error(`Error creating task: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
686
+ throw new Error(`ClickUp API ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
614
687
  }
615
- let createdTask = await response.json();
616
- // Handle tags separately via dedicated API endpoints
617
- if (tags && tags.length > 0) {
688
+ const createdTask = await response.json();
689
+ // Tags are omitted from the create body by buildTaskRequestBody because they
690
+ // need the dedicated tag endpoints, so apply them here - the same way
691
+ // updateTask does - otherwise the requested tags are silently dropped.
692
+ const tagCreateResults = [];
693
+ if (tags !== undefined && tags.length > 0) {
618
694
  for (const tagName of tags) {
619
- const encodedTag = encodeURIComponent(tagName);
620
- const tagResponse = await fetch(`https://api.clickup.com/api/v2/task/${createdTask.id}/tag/${encodedTag}`, {
621
- method: 'POST',
622
- headers: {
623
- Authorization: config_1.CONFIG.apiKey,
624
- 'Content-Type': 'application/json'
695
+ try {
696
+ const addTagResponse = await fetch(`https://api.clickup.com/api/v2/task/${createdTask.id}/tag/${encodeURIComponent(tagName)}`, {
697
+ method: 'POST',
698
+ headers: { Authorization: config_1.CONFIG.apiKey }
699
+ });
700
+ if (!addTagResponse.ok) {
701
+ console.error(`Failed to add tag "${tagName}": ${addTagResponse.status}`);
702
+ tagCreateResults.push(`Failed to add tag: ${tagName}`);
625
703
  }
626
- });
627
- if (!tagResponse.ok) {
628
- console.error(`Error adding tag "${tagName}" to task ${createdTask.id}: ${tagResponse.status}`);
629
704
  }
630
- }
631
- // Re-fetch task to get updated tags
632
- const refreshResponse = await fetch(`https://api.clickup.com/api/v2/task/${createdTask.id}`, {
633
- headers: { Authorization: config_1.CONFIG.apiKey }
634
- });
635
- if (refreshResponse.ok) {
636
- createdTask = await refreshResponse.json();
705
+ catch (error) {
706
+ console.error(`Error adding tag "${tagName}":`, error);
707
+ tagCreateResults.push(`Error adding tag: ${tagName}`);
708
+ }
637
709
  }
638
710
  }
639
711
  // Images can only be attached once the task exists, so the description is
@@ -671,6 +743,9 @@ function registerTaskToolsWrite(server, userData) {
671
743
  }, userData);
672
744
  responseLines.push(...formatAttachedImages(uploaded));
673
745
  responseLines.push(...imageWarnings);
746
+ if (tagCreateResults.length > 0) {
747
+ responseLines.push('tag_warnings: ' + tagCreateResults.join('; '));
748
+ }
674
749
  return {
675
750
  content: [
676
751
  {
@@ -707,9 +782,16 @@ function convertPriorityToNumber(priority) {
707
782
  default: return 3;
708
783
  }
709
784
  }
785
+ /**
786
+ * The task payload carries the priority NAME (`priority: { id: "4", priority: "low" }`),
787
+ * so a numeric-only lookup turned every real priority into "unknown". Accept both.
788
+ */
710
789
  function convertPriorityToString(priority) {
790
+ if (typeof priority === 'string' && ['urgent', 'high', 'normal', 'low'].includes(priority.toLowerCase())) {
791
+ return priority.toLowerCase();
792
+ }
711
793
  const priorityMap = { 1: 'urgent', 2: 'high', 3: 'normal', 4: 'low' };
712
- return priorityMap[priority] || 'unknown';
794
+ return priorityMap[Number(priority)] || 'unknown';
713
795
  }
714
796
  function formatTimeEstimate(hours) {
715
797
  const displayHours = Math.floor(hours);
@@ -717,24 +799,67 @@ function formatTimeEstimate(hours) {
717
799
  return displayHours > 0 ? `${displayHours}h ${displayMinutes}m` : `${displayMinutes}m`;
718
800
  }
719
801
  /**
720
- * Never page further back than this. A generous edit window would otherwise walk
721
- * the entire comment history of a busy ticket and eat the 100 calls/minute budget.
802
+ * Match the image references of an edited comment against the images the comment
803
+ * already contains, and return the stored attachment object for each of them.
804
+ *
805
+ * Verified live on `PUT /comment/{id}`: an image fragment is only accepted when it
806
+ * carries the real attachment of an image that is ALREADY in that comment. Any
807
+ * other attachment - a fresh upload, the task description's image, an image of
808
+ * another comment - is refused with 401 ACCESS_610, and a fragment whose id is not
809
+ * a real attachment id fails with 404 ACCESS_028. Reusing the stored object (real
810
+ * id, dimensions included) is therefore the only working way to keep an image, and
811
+ * anything else is rejected here, before a request is made - uploading first would
812
+ * only leave an orphan attachment on the task behind the failed edit.
722
813
  */
723
- const MAX_COMMENT_PAGES = 10;
724
- /** One page of task comments, newest first, 25 per page */
725
- async function fetchCommentPage(taskId, cursor) {
726
- // Note there is no `start_date` parameter - passing one is silently ignored.
727
- // Older pages are reached with `start` + `start_id` of the previous page's last entry.
728
- const query = cursor
729
- ? `?${new URLSearchParams({ start: cursor.start, start_id: cursor.startId })}`
730
- : "";
731
- const response = await fetch(`https://api.clickup.com/api/v2/task/${taskId}/comment${query}`, { headers: { Authorization: config_1.CONFIG.apiKey } });
732
- if (!response.ok) {
733
- const errorData = await response.json().catch(() => ({}));
734
- throw new Error(`Error loading comments of task ${taskId}: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
814
+ function keepOwnCommentImages(existing, markdown, abortNotice) {
815
+ const sources = (0, clickup_text_1.collectMarkdownImageSources)(markdown);
816
+ if (sources.length === 0) {
817
+ return [];
818
+ }
819
+ const ownImages = new Map();
820
+ for (const fragment of existing.comment ?? []) {
821
+ if (fragment?.type === "image" && fragment.image?.url) {
822
+ ownImages.set(normalizeUrl(fragment.image.url), fragment.image);
823
+ }
824
+ }
825
+ const kept = [];
826
+ const refused = [];
827
+ const seen = new Set();
828
+ for (const { src } of sources) {
829
+ if (seen.has(src)) {
830
+ continue;
831
+ }
832
+ seen.add(src);
833
+ const stored = ownImages.get(normalizeUrl(src));
834
+ if (stored) {
835
+ kept.push({ src, attachment: stored });
836
+ }
837
+ else {
838
+ refused.push(src);
839
+ }
840
+ }
841
+ if (refused.length > 0) {
842
+ throw new Error([
843
+ `${refused.length} image reference(s) are not images of this comment, so ${abortNotice}:`,
844
+ ...refused.map((src) => ` - ${summarizeImageSource(src)}`),
845
+ `ClickUp only lets an edit keep the images already in the comment - it refuses to add any other image.`,
846
+ `Remove these references, or post the new image in a new comment with addComment.`,
847
+ ].join("\n"));
735
848
  }
736
- const data = await response.json();
737
- return Array.isArray(data.comments) ? data.comments : [];
849
+ return kept;
850
+ }
851
+ /** Compare attachment URLs regardless of percent-encoding of the filename */
852
+ function normalizeUrl(url) {
853
+ try {
854
+ return decodeURI(url.trim());
855
+ }
856
+ catch {
857
+ return url.trim();
858
+ }
859
+ }
860
+ /** Shorten a data URI so an error message never repeats a base64 payload */
861
+ function summarizeImageSource(src) {
862
+ return src.startsWith("data:") ? `${src.slice(0, 40)}... (data URI)` : src;
738
863
  }
739
864
  /**
740
865
  * Load a single comment of a task.
@@ -757,8 +882,8 @@ async function findTaskComment(taskId, commentId) {
757
882
  let checked = 0;
758
883
  let pages = 0;
759
884
  let sawThreadedReplies = false;
760
- while (pages < MAX_COMMENT_PAGES) {
761
- const page = await fetchCommentPage(taskId, cursor);
885
+ while (pages < comments_1.MAX_COMMENT_PAGES) {
886
+ const page = await (0, comments_1.fetchCommentPage)(taskId, cursor);
762
887
  pages++;
763
888
  if (page.length === 0) {
764
889
  break;
@@ -769,6 +894,10 @@ async function findTaskComment(taskId, commentId) {
769
894
  }
770
895
  checked += page.length;
771
896
  sawThreadedReplies || (sawThreadedReplies = page.some((entry) => (entry.reply_count ?? 0) > 0));
897
+ // A short page is the last one - requesting the next would only return [].
898
+ if (page.length < comments_1.COMMENTS_PER_PAGE) {
899
+ break;
900
+ }
772
901
  // Comments come back newest first, so once a page runs past the edit window
773
902
  // there is nothing editable further back.
774
903
  const oldest = page[page.length - 1];
@@ -822,6 +951,35 @@ function timestampToIso(timestamp) {
822
951
  const timezoneOffset = sign + String(offsetHours).padStart(2, '0') + ':' + String(offsetMinutes).padStart(2, '0');
823
952
  return `${year}-${month}-${day}T${hours}:${minutes}${timezoneOffset}`;
824
953
  }
954
+ /**
955
+ * Convert a due/start date parameter for the task API.
956
+ *
957
+ * ClickUp drops the time of day unless `due_date_time`/`start_date_time` is true
958
+ * (verified live: 18:00 came back as 04:00, its all-day placeholder), so report
959
+ * whether the caller gave a time. A bare date (YYYY-MM-DD) is an all-day value at
960
+ * local midnight. Invalid or impossible dates throw instead of being sent as null.
961
+ */
962
+ function parseTaskDate(value, field) {
963
+ return (0, dates_1.parseDateInput)(value, field, "start");
964
+ }
965
+ /** Validate due/start dates up front so a bad value never reaches a write */
966
+ function assertValidTaskDates(dates) {
967
+ for (const field of ["due_date", "start_date"]) {
968
+ const value = dates[field];
969
+ if (typeof value === "string") {
970
+ parseTaskDate(value, field);
971
+ }
972
+ }
973
+ }
974
+ /** Resolve every ID of a relation list to an internal ID, without duplicates */
975
+ async function resolveTaskIdList(ids) {
976
+ if (ids === undefined) {
977
+ return undefined;
978
+ }
979
+ // Deduplicate before resolving too: each custom ID costs one API call
980
+ const resolved = await Promise.all([...new Set(ids)].map((id) => (0, utils_1.resolveTaskId)(id)));
981
+ return [...new Set(resolved)];
982
+ }
825
983
  function buildTaskRequestBody(params, currentUserId) {
826
984
  const requestBody = {};
827
985
  if (params.name !== undefined) {
@@ -836,10 +994,24 @@ function buildTaskRequestBody(params, currentUserId) {
836
994
  // null clears the value - it must be sent as is, new Date(null) would set 1970-01-01.
837
995
  // time_estimate is the exception: ClickUp accepts null but silently keeps the estimate, only 0 clears it.
838
996
  if (params.due_date !== undefined) {
839
- requestBody.due_date = params.due_date === null ? null : new Date(params.due_date).getTime();
997
+ if (params.due_date === null) {
998
+ requestBody.due_date = null;
999
+ }
1000
+ else {
1001
+ const due = parseTaskDate(params.due_date, 'due_date');
1002
+ requestBody.due_date = due.timestamp;
1003
+ requestBody.due_date_time = due.hasTime;
1004
+ }
840
1005
  }
841
1006
  if (params.start_date !== undefined) {
842
- requestBody.start_date = params.start_date === null ? null : new Date(params.start_date).getTime();
1007
+ if (params.start_date === null) {
1008
+ requestBody.start_date = null;
1009
+ }
1010
+ else {
1011
+ const start = parseTaskDate(params.start_date, 'start_date');
1012
+ requestBody.start_date = start.timestamp;
1013
+ requestBody.start_date_time = start.hasTime;
1014
+ }
843
1015
  }
844
1016
  if (params.time_estimate !== undefined) {
845
1017
  requestBody.time_estimate = params.time_estimate === null ? 0 : Math.round(params.time_estimate * 60 * 60 * 1000);
@@ -863,10 +1035,28 @@ function buildTaskRequestBody(params, currentUserId) {
863
1035
  // Helper function to manage task dependencies
864
1036
  async function updateTaskDependencies(taskId, taskData, dependencies) {
865
1037
  const errors = [];
866
- // Get current dependencies
867
- const currentBlocking = taskData.blocking?.map((dep) => dep.id) || [];
868
- const currentWaitingOn = taskData.waiting_on?.map((dep) => dep.id) || [];
869
- const currentLinked = taskData.linked_tasks?.map((task) => task.id) || [];
1038
+ // Get current dependencies. The task payload has no `blocking`/`waiting_on`
1039
+ // arrays (verified live): both directions live in one flat `dependencies` array
1040
+ // of `{ task_id, depends_on }` records, and this task can sit on either side.
1041
+ // Reading the missing fields made every current dependency look absent, so
1042
+ // `waiting_on: []`/`blocking: []` never removed anything and reported no error.
1043
+ const dependencyRecords = Array.isArray(taskData.dependencies) ? taskData.dependencies : [];
1044
+ const currentWaitingOn = dependencyRecords
1045
+ .filter((dep) => dep.task_id === taskId && dep.depends_on)
1046
+ .map((dep) => dep.depends_on);
1047
+ const currentBlocking = dependencyRecords
1048
+ .filter((dep) => dep.depends_on === taskId && dep.task_id)
1049
+ .map((dep) => dep.task_id);
1050
+ // `linked_tasks` entries are link records, not tasks: each has `task_id` and
1051
+ // `link_id` (the two ends of the link) and no `id` at all. Mapping `.id` here
1052
+ // produced `[undefined, ...]`, so every existing link looked like it was no
1053
+ // longer requested, and the removal loop then issued
1054
+ // `DELETE /task/{id}/link/undefined`, which the API rejects. The result was a
1055
+ // `linked_tasks` field documented as "replace" that could never remove
1056
+ // anything. Take whichever end of the record is not the task being updated.
1057
+ const currentLinked = taskData.linked_tasks
1058
+ ?.map((link) => (link.task_id === taskData.id ? link.link_id : link.task_id))
1059
+ .filter((id) => Boolean(id)) || [];
870
1060
  // Helper function to make dependency API calls
871
1061
  async function modifyDependency(operation, type, fromTaskId, toTaskId, dependsOn) {
872
1062
  try {