@sebastienheyd/clickup-mcp 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,10 +96,10 @@ Turn natural language into powerful ClickUp actions:
96
96
  - Create, read, and update documents and pages
97
97
  - Add comments and collaborate with team members
98
98
  - Manage priorities, due dates, assignees, and tags
99
- - Handle time estimates and custom field values
99
+ - Handle time estimates, sprint points and custom field values
100
100
 
101
101
  ### 🔒 **Safety Features**
102
- - **Append-Only Descriptions**: Description fields are never overwritten - new content is safely appended with timestamps
102
+ - **Append-First Descriptions**: `append_description` adds a dated block under the existing description and is the default for notes. `replace_description` rewrites the whole description on purpose - the previous content is echoed back so a wrong replacement can be undone
103
103
  - **Normal Field Updates**: Status, priority, assignees, tags, and dates can be updated normally (easily revertible through ClickUp's history)
104
104
 
105
105
  ## Installation
@@ -191,14 +191,17 @@ The ClickUp MCP supports three operational modes to balance functionality, secur
191
191
  | `getTaskById` | ✅ | ✅ | ✅ | Get complete task details including comments, images, and metadata |
192
192
  | `addComment` | ❌ | ❌ | ✅ | Add comments to tasks for collaboration |
193
193
  | `editComment` | ❌ | ❌ | ✅ | Correct your own comment within 24h instead of posting a follow-up |
194
- | `updateTask` | ❌ | ❌ | ✅ | Update tasks (status, priority, assignees, etc.) with **SAFE APPEND-ONLY** descriptions |
194
+ | `deleteComment` | ❌ | ❌ | ✅ | Delete your own comment within 24h (deleted text is echoed back) |
195
+ | `updateTask` | ❌ | ❌ | ✅ | Update tasks (status, priority, assignees, etc.); append to or replace descriptions |
195
196
  | `createTask` | ❌ | ❌ | ✅ | Create new tasks with full markdown support |
196
197
  | `searchTasks` | ✅ | ✅ | ✅ | Find tasks by content, keywords, assignees, or project context |
197
198
  | `searchSpaces` | ❌ | ✅ | ✅ | Browse workspace structure, project organization, and documents |
198
199
  | `getListInfo` | ❌ | ✅ | ✅ | Get list details and available statuses for task creation |
199
- | `updateListInfo` | ❌ | ❌ | ✅ | **SAFE APPEND-ONLY** updates to list descriptions (preserves existing content) |
200
+ | `updateListInfo` | ❌ | ❌ | ✅ | Append to or replace list descriptions (previous content echoed back on replace) |
200
201
  | `getTimeEntries` | ❌ | ✅ | ✅ | View time entries and analyze time spent across projects |
201
202
  | `createTimeEntry` | ❌ | ❌ | ✅ | Log time entries for task tracking |
203
+ | `updateTimeEntry` | ❌ | ❌ | ✅ | Adjust your own time entries (duration, start, description, task) |
204
+ | `deleteTimeEntry` | ❌ | ❌ | ✅ | Delete your own time entries (irreversible, values are echoed back) |
202
205
  | `readDocument` | ❌ | ✅ | ✅ | Get document details, page structure, and content with navigation |
203
206
  | `searchDocuments` | ❌ | ✅ | ✅ | Search documents by name and space with fuzzy matching and space filtering |
204
207
  | `updateDocumentPage` | ❌ | ❌ | ✅ | Update existing page content or name with replace/append modes |
@@ -277,8 +280,8 @@ Create a task called "API Integration" with description:
277
280
  See related task: https://app.clickup.com/t/abc123
278
281
  ```
279
282
 
280
- **Append-Only Updates (Safe):**
281
- When updating task descriptions, content is safely appended:
283
+ **Append or Replace Descriptions:**
284
+ By default (`append_description`), content is appended under the existing description:
282
285
  ```markdown
283
286
  [Existing task description content]
284
287
 
@@ -288,7 +291,7 @@ When updating task descriptions, content is safely appended:
288
291
  - Performance requirement: < 2s load time
289
292
  ```
290
293
 
291
- This ensures no existing content is ever lost while maintaining a clear audit trail.
294
+ This keeps existing content and a clear audit trail. To correct or restructure a description, `replace_description` rewrites it entirely; the tool answers with the previous description so nothing is silently lost. The same two parameters exist on `updateListInfo`.
292
295
 
293
296
  ## Writing Images Into Tickets
294
297
 
package/dist/cli.js CHANGED
@@ -124,7 +124,7 @@ async function main() {
124
124
  // Try to parse as JSON if it looks like a JSON value
125
125
  try {
126
126
  if (value.startsWith('{') || value.startsWith('[') ||
127
- value === 'true' || value === 'false' ||
127
+ value === 'true' || value === 'false' || value === 'null' ||
128
128
  (value.startsWith('"') && value.endsWith('"')) ||
129
129
  (!isNaN(Number(value)) && !key.includes('id') && !value.startsWith('"'))) {
130
130
  params[key] = JSON.parse(value);
@@ -639,7 +639,24 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0, attachmentsByS
639
639
  }
640
640
  break;
641
641
  }
642
+ // Keep the blank line the source puts between two blocks: remark drops it, and
643
+ // ClickUp would otherwise glue paragraphs and lists into one dense block
644
+ if (hasBlankLineBefore(nodes[i + 1], node)) {
645
+ blocks.push({ text: '\n', attributes: {} });
646
+ }
647
+ }
648
+ }
649
+ /**
650
+ * Whether the source has at least one blank line between `previous` and `next`.
651
+ * Several blank lines count as one, as in rendered markdown.
652
+ */
653
+ function hasBlankLineBefore(next, previous) {
654
+ const previousEnd = previous.position?.end.line;
655
+ const nextStart = next?.position?.start.line;
656
+ if (previousEnd === undefined || nextStart === undefined) {
657
+ return false;
642
658
  }
659
+ return nextStart - previousEnd > 1;
643
660
  }
644
661
  /**
645
662
  * Recursively walk phrasing content (inline nodes) and build ClickUp blocks
@@ -1 +1 @@
1
- {"version":3,"file":"list-tools.d.ts","sourceRoot":"","sources":["../../src/tools/list-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAKpE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,QAqHtD;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,QAiFvD"}
1
+ {"version":3,"file":"list-tools.d.ts","sourceRoot":"","sources":["../../src/tools/list-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAKpE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,QAqHtD;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,QAkGvD"}
@@ -112,21 +112,27 @@ function registerListToolsWrite(server) {
112
112
  // Workaround: SDK 1.27+ dual Zod v3/v4 type causes TS2589 on server.tool() generics
113
113
  const tool = server.tool.bind(server);
114
114
  tool("updateListInfo", [
115
- "Appends documentation or context to a list's description.",
115
+ "Appends to or replaces a list's description.",
116
116
  "ALWAYS reference the list URL (https://app.clickup.com/v/l/LIST_ID) when updating or discussing lists.",
117
- "SAFETY FEATURE: Description updates are APPEND-ONLY to prevent data loss - existing content is preserved.",
117
+ "`append_description` adds a dated block under the existing description and is the safe default. `replace_description` rewrites the whole description - read it with getListInfo first and repeat everything worth keeping; the previous description is echoed back so it can be restored.",
118
118
  "Use this to add project context, requirements, or guidelines that LLMs should consider when working with tasks in this list.",
119
119
  "Include links to related tasks, spaces, or external resources in the appended content.",
120
120
  "Content is appended in markdown format with timestamp for tracking changes."
121
121
  ].join("\n"), {
122
122
  list_id: zod_1.z.string().min(1).describe("The list ID to update"),
123
- append_description: zod_1.z.string().min(1).describe("Markdown content to APPEND to existing list description (preserves existing content for safety)")
123
+ append_description: zod_1.z.string().min(1).optional().describe("Markdown content to APPEND under the existing list description as a dated block (preserves existing content). Mutually exclusive with replace_description"),
124
+ replace_description: zod_1.z.string().optional().describe("Markdown content that REPLACES the whole list description. Anything not repeated here is lost (the previous description is echoed back). Mutually exclusive with append_description")
124
125
  }, {
125
126
  readOnlyHint: false,
126
- destructiveHint: false,
127
+ destructiveHint: true,
127
128
  idempotentHint: false,
128
- }, async ({ list_id, append_description }) => {
129
+ }, async ({ list_id, append_description, replace_description }) => {
129
130
  try {
131
+ if ((append_description === undefined) === (replace_description === undefined)) {
132
+ return {
133
+ content: [{ type: "text", text: "Pass either append_description or replace_description (exactly one). The list was NOT updated." }],
134
+ };
135
+ }
130
136
  // Get current list info including description (try to get markdown content)
131
137
  const listResponse = await fetch(`https://api.clickup.com/api/v2/list/${list_id}?include_markdown_description=true`, {
132
138
  headers: { Authorization: config_1.CONFIG.apiKey },
@@ -135,11 +141,17 @@ function registerListToolsWrite(server) {
135
141
  throw new Error(`Error fetching list: ${listResponse.status} ${listResponse.statusText}`);
136
142
  }
137
143
  const listData = await listResponse.json();
138
- // Handle append-only description update with markdown support
144
+ // Append adds a dated block under the current description; replace sends the new text as is
139
145
  const currentDescription = listData.markdown_description || listData.markdown_content || listData.content || "";
140
146
  const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
141
- const separator = currentDescription.trim() ? "\n\n---\n" : "";
142
- const finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${append_description}`;
147
+ let finalDescription;
148
+ if (replace_description !== undefined) {
149
+ finalDescription = replace_description;
150
+ }
151
+ else {
152
+ const separator = currentDescription.trim() ? "\n\n---\n" : "";
153
+ finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${append_description}`;
154
+ }
143
155
  // Update the list description using markdown_content
144
156
  const updateResponse = await fetch(`https://api.clickup.com/api/v2/list/${list_id}`, {
145
157
  method: 'PUT',
@@ -159,7 +171,12 @@ function registerListToolsWrite(server) {
159
171
  content: [
160
172
  {
161
173
  type: "text",
162
- text: `Successfully appended content to list "${listData.name}". The new content has been added with timestamp (${timestamp}) while preserving existing description.`,
174
+ text: replace_description !== undefined
175
+ ? [
176
+ `Successfully replaced the description of list "${listData.name}" (list_id: ${list_id}).`,
177
+ `previous_description: ${currentDescription.trim() ? currentDescription : '(empty)'}`,
178
+ ].join('\n')
179
+ : `Successfully appended content to list "${listData.name}" (list_id: ${list_id}). The new content has been added with timestamp (${timestamp}) while preserving existing description.`,
163
180
  },
164
181
  ],
165
182
  };
@@ -1 +1 @@
1
- {"version":3,"file":"task-tools.d.ts","sourceRoot":"","sources":["../../src/tools/task-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGpE,OAAO,EAAE,YAAY,EAAyC,MAAM,iBAAiB,CAAC;AAOtF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QAgErE;AA+MD;;GAEG;AACH,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,GAAG,EAAE,WAAW,CAAC,EAAE,GAAG,EAAE,EAAE,YAAY,GAAE,OAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAmH/H"}
1
+ {"version":3,"file":"task-tools.d.ts","sourceRoot":"","sources":["../../src/tools/task-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGpE,OAAO,EAAE,YAAY,EAAyC,MAAM,iBAAiB,CAAC;AAOtF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QAgErE;AA+MD;;GAEG;AACH,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,GAAG,EAAE,WAAW,CAAC,EAAE,GAAG,EAAE,EAAE,YAAY,GAAE,OAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAwH/H"}
@@ -273,6 +273,10 @@ async function generateTaskMetadata(task, timeEntries, isDetailView = false) {
273
273
  const minutes = Math.floor((task.time_estimate % 3600000) / 60000);
274
274
  metadataLines.push(`time_estimate: ${hours}h ${minutes}m`);
275
275
  }
276
+ // Add sprint points if they exist
277
+ if (task.points !== undefined && task.points !== null) {
278
+ metadataLines.push(`points: ${task.points}`);
279
+ }
276
280
  // Add time booked (tracked time entries) - only if timeEntries provided
277
281
  if (timeEntries) {
278
282
  const timeBooked = filterTaskTimeEntries(task.id, timeEntries);
@@ -1 +1 @@
1
- {"version":3,"file":"task-write-tools.d.ts","sourceRoot":"","sources":["../../src/tools/task-write-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAsIpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QAmnBtE"}
1
+ {"version":3,"file":"task-write-tools.d.ts","sourceRoot":"","sources":["../../src/tools/task-write-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAwIpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QA4sBtE"}
@@ -93,7 +93,9 @@ const taskPrioritySchema = zod_1.z.enum(["urgent", "high", "normal", "low"]).opt
93
93
  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
94
  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
95
  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'");
96
97
  const taskTagsSchema = zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of tag names");
98
+ const CLEAR_HINT = " - pass null to clear the current value";
97
99
  function registerTaskToolsWrite(server, userData) {
98
100
  // Workaround: SDK 1.27+ dual Zod v3/v4 type causes TS2589 on server.tool() generics
99
101
  const tool = server.tool.bind(server);
@@ -269,12 +271,68 @@ function registerTaskToolsWrite(server, userData) {
269
271
  };
270
272
  }
271
273
  });
274
+ tool("deleteComment", [
275
+ "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
+ `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
+ ].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\")"),
281
+ comment_id: zod_1.z.string().min(1).describe("The ID of the comment to delete, as returned by addComment or getTaskById"),
282
+ }, {
283
+ readOnlyHint: false,
284
+ destructiveHint: true,
285
+ idempotentHint: false,
286
+ }, async ({ task_id, comment_id }) => {
287
+ try {
288
+ const resolved_task_id = await (0, utils_1.resolveTaskId)(task_id);
289
+ const [existing, userData] = await Promise.all([
290
+ findTaskComment(resolved_task_id, comment_id),
291
+ (0, utils_1.getCurrentUser)(),
292
+ ]);
293
+ assertCommentIsEditable(existing, userData.user.id, 'deleted');
294
+ const response = await fetch(`https://api.clickup.com/api/v2/comment/${comment_id}`, {
295
+ method: 'DELETE',
296
+ headers: { Authorization: config_1.CONFIG.apiKey }
297
+ });
298
+ if (!response.ok) {
299
+ const errorData = await response.json().catch(() => ({}));
300
+ throw new Error(`Error deleting comment: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
301
+ }
302
+ return {
303
+ content: [
304
+ {
305
+ type: "text",
306
+ text: [
307
+ `Comment deleted successfully!`,
308
+ `comment_id: ${comment_id}`,
309
+ `task_id: ${resolved_task_id}`,
310
+ `task_url: https://app.clickup.com/t/${resolved_task_id}`,
311
+ `created: ${timestampToIso(existing.date)}`,
312
+ `deleted_text: ${existing.comment_text || '(no plain text available)'}`,
313
+ ].join('\n')
314
+ }
315
+ ],
316
+ };
317
+ }
318
+ catch (error) {
319
+ console.error('Error deleting comment:', error);
320
+ return {
321
+ content: [
322
+ {
323
+ type: "text",
324
+ text: `Error deleting comment: ${error instanceof Error ? error.message : 'Unknown error'}`,
325
+ },
326
+ ],
327
+ };
328
+ }
329
+ });
272
330
  tool("updateTask", (() => {
273
331
  const descriptionBase = [
274
332
  "Updates various aspects of an existing task including dependencies and relationships.",
275
333
  "ALWAYS include the task URL (https://app.clickup.com/t/TASK_ID) when updating or referencing tasks.",
276
334
  "Use getListInfo first to see valid status options.",
277
- "SAFETY FEATURE: Description updates are APPEND-ONLY to prevent data loss - existing content is preserved.",
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.",
278
336
  "STATUS UPDATES: Use the `addComment` tool for progress reports, work logs, and status updates rather than the task description.",
279
337
  IMAGE_SUPPORT_HINT,
280
338
  "Task descriptions should contain requirements, specifications, and core task information.",
@@ -291,15 +349,18 @@ function registerTaskToolsWrite(server, userData) {
291
349
  message: "Must be an internal task ID (6+ alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
292
350
  }).describe("The task ID to update: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
293
351
  name: taskNameSchema.optional(),
294
- append_description: zod_1.z.string().optional().describe("Optional markdown content to APPEND to existing task description (preserves existing content for safety)"),
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"),
295
354
  status: zod_1.z.string().optional().describe("Optional new status name - use getListInfo to see valid options"),
296
355
  priority: taskPrioritySchema,
297
- due_date: taskDueDateSchema,
298
- start_date: taskStartDateSchema,
299
- time_estimate: taskTimeEstimateSchema,
356
+ due_date: taskDueDateSchema.nullable().describe(taskDueDateSchema.description + CLEAR_HINT),
357
+ start_date: taskStartDateSchema.nullable().describe(taskStartDateSchema.description + CLEAR_HINT),
358
+ time_estimate: taskTimeEstimateSchema.nullable().describe(taskTimeEstimateSchema.description + CLEAR_HINT),
359
+ points: taskPointsSchema.nullable().describe(taskPointsSchema.description + CLEAR_HINT),
300
360
  tags: taskTagsSchema.describe("Optional array of tag names (will replace existing tags)"),
301
361
  parent_task_id: zod_1.z.string().optional().describe("Optional parent task ID to change parent/child relationships"),
302
- assignees: zod_1.z.array(zod_1.z.string()).optional().describe(createAssigneeDescription(userData)),
362
+ 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
+ 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."),
303
364
  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)"),
304
365
  blocking: zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of task IDs that this task should block. Note: This creates dependencies FROM those tasks TO this task (those tasks will wait on this one)"),
305
366
  linked_tasks: zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of task IDs to link as related tasks without blocking (will replace existing linked tasks)")
@@ -308,8 +369,13 @@ function registerTaskToolsWrite(server, userData) {
308
369
  destructiveHint: true,
309
370
  idempotentHint: false,
310
371
  openWorldHint: true
311
- }, async ({ task_id, name, append_description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees, blocking, waiting_on, linked_tasks }) => {
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 }) => {
312
373
  try {
374
+ if (append_description !== undefined && replace_description !== undefined) {
375
+ return {
376
+ content: [{ type: "text", text: "Pass either append_description or replace_description, not both. The task was NOT updated." }],
377
+ };
378
+ }
313
379
  // Resolve custom task ID to internal ID if needed
314
380
  const resolved_task_id = await (0, utils_1.resolveTaskId)(task_id);
315
381
  const userData = await (0, utils_1.getCurrentUser)();
@@ -324,15 +390,18 @@ function registerTaskToolsWrite(server, userData) {
324
390
  // Resolve and upload description images FIRST - an image problem must
325
391
  // abort before dependencies, tags or the task itself are touched, so the
326
392
  // caller can fix the markdown and retry the whole call cleanly.
327
- let appendedDescription;
393
+ // Both description modes go through the same pipeline: only what is done
394
+ // with the prepared markdown differs (appended block vs whole description).
395
+ let preparedDescription;
328
396
  let uploadedImages = [];
329
- if (append_description) {
397
+ const descriptionInput = replace_description !== undefined ? replace_description : append_description;
398
+ if (descriptionInput) {
330
399
  const abortNotice = "the task was NOT updated";
331
- const prepared = await resolveImagesOrAbort(append_description, abortNotice);
400
+ const prepared = await resolveImagesOrAbort(descriptionInput, abortNotice);
332
401
  uploadedImages = await uploadImagesOrAbort(resolved_task_id, prepared.images, abortNotice);
333
402
  // Descriptions render plain markdown, so no image fragments are involved
334
403
  // 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));
404
+ preparedDescription = (0, clickup_text_1.rewriteMarkdownImageUrls)(prepared.markdown, (0, attachments_1.toAttachmentMap)(uploadedImages));
336
405
  }
337
406
  // Handle dependencies separately since they need individual API calls
338
407
  let dependencyUpdateResults = [];
@@ -382,25 +451,29 @@ function registerTaskToolsWrite(server, userData) {
382
451
  }
383
452
  }
384
453
  }
385
- // Handle append-only description update with markdown support
454
+ // Append adds a dated block under the current description; replace sends
455
+ // the prepared markdown as is. An empty replace_description clears it.
386
456
  let finalDescription;
387
- if (appendedDescription !== undefined) {
388
- const currentDescription = taskData.markdown_description || "";
457
+ const currentDescription = taskData.markdown_description || "";
458
+ if (replace_description !== undefined) {
459
+ finalDescription = preparedDescription ?? "";
460
+ }
461
+ else if (preparedDescription !== undefined) {
389
462
  const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
390
463
  const separator = currentDescription.trim() ? "\n\n---\n" : "";
391
- finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${appendedDescription}`;
464
+ finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${preparedDescription}`;
392
465
  }
393
466
  // Build update body without tags (they're handled separately)
394
467
  const updateBody = buildTaskRequestBody({
395
- name, status, priority, due_date, start_date, time_estimate, parent_task_id, assignees
468
+ name, status, priority, due_date, start_date, time_estimate, points, parent_task_id, assignees
396
469
  });
397
470
  // Add markdown description if we have content to append
398
471
  if (finalDescription !== undefined) {
399
472
  updateBody.markdown_description = finalDescription;
400
473
  }
401
- // Handle assignees for updates (different from creates)
402
- if (assignees !== undefined) {
403
- updateBody.assignees = { add: assignees, rem: [] }; // Add new assignees, remove none
474
+ // Handle assignees for updates (different from creates) - ClickUp requires both add and rem
475
+ if (assignees !== undefined || remove_assignees !== undefined) {
476
+ updateBody.assignees = { add: assignees ?? [], rem: remove_assignees ?? [] };
404
477
  }
405
478
  // Check if there's anything to update (including tags and dependencies which were handled separately)
406
479
  if (Object.keys(updateBody).length === 0 && tags === undefined && blocking === undefined && waiting_on === undefined && linked_tasks === undefined) {
@@ -440,8 +513,12 @@ function registerTaskToolsWrite(server, userData) {
440
513
  }
441
514
  }
442
515
  const responseLines = formatTaskResponse(updatedTask, 'updated', {
443
- name, append_description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees, blocking, waiting_on, linked_tasks
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
444
517
  }, 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)'}`);
521
+ }
445
522
  // Add dependency update results if any
446
523
  if (dependencyUpdateResults.length > 0) {
447
524
  responseLines.push('dependency_warnings: ' + dependencyUpdateResults.join('; '));
@@ -498,6 +575,7 @@ function registerTaskToolsWrite(server, userData) {
498
575
  due_date: taskDueDateSchema,
499
576
  start_date: taskStartDateSchema,
500
577
  time_estimate: taskTimeEstimateSchema,
578
+ points: taskPointsSchema,
501
579
  tags: taskTagsSchema,
502
580
  parent_task_id: zod_1.z.string().optional().describe("Optional parent task ID to create this as a subtask"),
503
581
  assignees: zod_1.z.array(zod_1.z.string()).optional().describe(createAssigneeDescription(userData))
@@ -506,7 +584,7 @@ function registerTaskToolsWrite(server, userData) {
506
584
  destructiveHint: false,
507
585
  idempotentHint: false,
508
586
  openWorldHint: true
509
- }, async ({ list_id, name, description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees }) => {
587
+ }, async ({ list_id, name, description, status, priority, due_date, start_date, time_estimate, points, tags, parent_task_id, assignees }) => {
510
588
  try {
511
589
  // Resolve description images BEFORE creating the task: a broken reference
512
590
  // (missing file, dead URL, non-image) must not leave a half-finished task
@@ -516,7 +594,7 @@ function registerTaskToolsWrite(server, userData) {
516
594
  const userData = await (0, utils_1.getCurrentUser)();
517
595
  const currentUserId = userData.user.id;
518
596
  const requestBody = buildTaskRequestBody({
519
- name, status, priority, due_date, start_date, time_estimate, tags, assignees, parent_task_id
597
+ name, status, priority, due_date, start_date, time_estimate, points, tags, assignees, parent_task_id
520
598
  }, currentUserId);
521
599
  // Add markdown description if provided
522
600
  if (description) {
@@ -589,7 +667,7 @@ function registerTaskToolsWrite(server, userData) {
589
667
  }
590
668
  }
591
669
  const responseLines = formatTaskResponse(createdTask, 'created', {
592
- list_id, name, description, status, priority, due_date, start_date, time_estimate, tags, parent_task_id, assignees
670
+ list_id, name, description, status, priority, due_date, start_date, time_estimate, points, tags, parent_task_id, assignees
593
671
  }, userData);
594
672
  responseLines.push(...formatAttachedImages(uploaded));
595
673
  responseLines.push(...imageWarnings);
@@ -712,17 +790,18 @@ async function findTaskComment(taskId, commentId) {
712
790
  * other people's comments safe, and the time window is what keeps the tool from
713
791
  * rewriting history.
714
792
  */
715
- function assertCommentIsEditable(comment, currentUserId) {
793
+ function assertCommentIsEditable(comment, currentUserId, action = 'edited') {
716
794
  const windowHours = config_1.CONFIG.commentEditWindowHours;
795
+ const verb = action === 'deleted' ? 'Deleting' : 'Editing';
717
796
  if (!(windowHours > 0)) {
718
- throw new Error(`Editing comments is disabled (CLICKUP_COMMENT_EDIT_WINDOW_HOURS=${windowHours}). Add a new comment instead.`);
797
+ throw new Error(`${verb} comments is disabled (CLICKUP_COMMENT_EDIT_WINDOW_HOURS=${windowHours}). Add a new comment instead.`);
719
798
  }
720
799
  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.`);
800
+ 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 ${action} - reply with a new comment instead.`);
722
801
  }
723
802
  const ageHours = (Date.now() - Number(comment.date)) / (1000 * 60 * 60);
724
803
  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.`);
804
+ throw new Error(`Comment ${comment.id} was created ${ageHours.toFixed(1)} hours ago (${timestampToIso(comment.date)}), which is outside the ${windowHours} hour edit window. Comments that old can no longer be ${action} - add a new comment instead.`);
726
805
  }
727
806
  }
728
807
  /**
@@ -754,14 +833,19 @@ function buildTaskRequestBody(params, currentUserId) {
754
833
  if (params.priority !== undefined) {
755
834
  requestBody.priority = convertPriorityToNumber(params.priority);
756
835
  }
836
+ // null clears the value - it must be sent as is, new Date(null) would set 1970-01-01.
837
+ // time_estimate is the exception: ClickUp accepts null but silently keeps the estimate, only 0 clears it.
757
838
  if (params.due_date !== undefined) {
758
- requestBody.due_date = new Date(params.due_date).getTime();
839
+ requestBody.due_date = params.due_date === null ? null : new Date(params.due_date).getTime();
759
840
  }
760
841
  if (params.start_date !== undefined) {
761
- requestBody.start_date = new Date(params.start_date).getTime();
842
+ requestBody.start_date = params.start_date === null ? null : new Date(params.start_date).getTime();
762
843
  }
763
844
  if (params.time_estimate !== undefined) {
764
- requestBody.time_estimate = Math.round(params.time_estimate * 60 * 60 * 1000);
845
+ requestBody.time_estimate = params.time_estimate === null ? 0 : Math.round(params.time_estimate * 60 * 60 * 1000);
846
+ }
847
+ if (params.points !== undefined) {
848
+ requestBody.points = params.points;
765
849
  }
766
850
  // Tags are handled separately via dedicated API endpoints
767
851
  // Do not include in the update request body
@@ -891,14 +975,20 @@ function formatTaskResponse(task, operation, params, userData) {
891
975
  params.priority ? params.priority : 'unknown';
892
976
  responseLines.push(`priority: ${priority}`);
893
977
  }
978
+ if (params.remove_assignees !== undefined && params.remove_assignees.length > 0) {
979
+ responseLines.push(`removed_assignees: ${params.remove_assignees.join(', ')}`);
980
+ }
894
981
  if (params.due_date !== undefined) {
895
- responseLines.push(`due_date: ${params.due_date}`);
982
+ responseLines.push(`due_date: ${params.due_date ?? 'cleared'}`);
896
983
  }
897
984
  if (params.start_date !== undefined) {
898
- responseLines.push(`start_date: ${params.start_date}`);
985
+ responseLines.push(`start_date: ${params.start_date ?? 'cleared'}`);
899
986
  }
900
987
  if (params.time_estimate !== undefined) {
901
- responseLines.push(`time_estimate: ${formatTimeEstimate(params.time_estimate)}`);
988
+ responseLines.push(`time_estimate: ${params.time_estimate === null ? 'cleared' : formatTimeEstimate(params.time_estimate)}`);
989
+ }
990
+ if (params.points !== undefined) {
991
+ responseLines.push(`points: ${params.points === null ? 'cleared' : task.points ?? params.points}`);
902
992
  }
903
993
  if (params.tags !== undefined && params.tags.length > 0) {
904
994
  responseLines.push(`tags: ${params.tags.join(', ')}`);
@@ -1 +1 @@
1
- {"version":3,"file":"time-tools.d.ts","sourceRoot":"","sources":["../../src/tools/time-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAyDpE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,QAqFtD;AAyLD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,QAgGvD"}
1
+ {"version":3,"file":"time-tools.d.ts","sourceRoot":"","sources":["../../src/tools/time-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAyDpE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,QAqFtD;AA6OD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,QAmPvD"}
@@ -248,7 +248,8 @@ function processTimeEntriesData(data, task_id, start_date, end_date, include_all
248
248
  entryDuration = formatDuration(rawDuration);
249
249
  }
250
250
  const entryDescription = entry.description ? ` | ${entry.description}` : '';
251
- outputLines.push(`${entryPrefix} ${entryStart} - ${entryDuration}${entryDescription}`);
251
+ // The id is what updateTimeEntry/deleteTimeEntry need to target an entry
252
+ outputLines.push(`${entryPrefix} ${entryStart} - ${entryDuration} (entry_id: ${entry.id})${entryDescription}`);
252
253
  });
253
254
  }
254
255
  }
@@ -264,9 +265,173 @@ function processTimeEntriesData(data, task_id, start_date, end_date, include_all
264
265
  ],
265
266
  };
266
267
  }
268
+ /**
269
+ * Reads a single time entry. GET /time_entries/{id} answers `{"data": null}`
270
+ * with a 200 for an unknown or already deleted id, hence the explicit check.
271
+ */
272
+ async function fetchTimeEntry(entryId) {
273
+ const response = await fetch(`https://api.clickup.com/api/v2/team/${config_1.CONFIG.teamId}/time_entries/${entryId}`, {
274
+ headers: { Authorization: config_1.CONFIG.apiKey },
275
+ });
276
+ if (!response.ok) {
277
+ throw new Error(`Error fetching time entry ${entryId}: ${response.status} ${response.statusText}`);
278
+ }
279
+ const data = await response.json();
280
+ if (!data.data) {
281
+ throw new Error(`Time entry ${entryId} was not found. Use getTimeEntries to list existing entries and their entry_id.`);
282
+ }
283
+ return data.data;
284
+ }
285
+ /**
286
+ * The safety model of updateTimeEntry/deleteTimeEntry: getTimeEntries can list
287
+ * other members' entries (include_all_users), so the owner check is what keeps
288
+ * a wrong entry_id from touching a colleague's time. A running timer is refused
289
+ * too - its duration is negative and its end does not exist yet.
290
+ */
291
+ function assertTimeEntryIsOwn(entry, currentUserId, action) {
292
+ if (String(entry.user?.id ?? '') !== String(currentUserId)) {
293
+ throw new Error(`Time entry ${entry.id} belongs to ${entry.user?.username || 'someone else'} (user_id: ${entry.user?.id ?? 'unknown'}), not to the current user (user_id: ${currentUserId}). Only your own time entries can be ${action}.`);
294
+ }
295
+ if (parseInt(entry.duration) < 0) {
296
+ throw new Error(`Time entry ${entry.id} is a running timer. Stop it in ClickUp before trying to ${action === 'deleted' ? 'delete' : 'update'} it.`);
297
+ }
298
+ }
299
+ function formatTimeEntryLines(entry) {
300
+ return [
301
+ `entry_id: ${entry.id}`,
302
+ `task: ${entry.task?.name || 'No Task'} (task_id: ${entry.task?.id || 'none'})`,
303
+ `start_time: ${timestampToIso(parseInt(entry.start))}`,
304
+ `duration: ${formatDuration(parseInt(entry.duration) || 0)}`,
305
+ ...(entry.description ? [`description: ${entry.description}`] : []),
306
+ `user: ${entry.user?.username || 'Unknown User'} (user_id: ${entry.user?.id ?? 'unknown'})`,
307
+ ];
308
+ }
267
309
  function registerTimeToolsWrite(server) {
268
310
  // Workaround: SDK 1.27+ dual Zod v3/v4 type causes TS2589 on server.tool() generics
269
311
  const tool = server.tool.bind(server);
312
+ tool("updateTimeEntry", [
313
+ "Updates an existing time entry: duration, start time, description or the task it is booked on.",
314
+ "Use getTimeEntries first to find the entry_id. Only fields provided are changed.",
315
+ "GUARDRAILS: only time entries of the API token's own user can be updated, and running timers are refused.",
316
+ "Use decimal hours for the duration (e.g., 0.25 for 15 minutes, 1.5 for 1h 30min)."
317
+ ].join("\n"), {
318
+ entry_id: zod_1.z.string().min(1).describe("The time entry ID (entry_id shown by getTimeEntries)"),
319
+ hours: zod_1.z.number().min(0.01).max(24).optional().describe("New duration in decimal hours (e.g., 0.25 = 15min, 1.5 = 1h 30min)"),
320
+ start_time: zod_1.z.string().optional().describe("New start time as ISO date string (e.g., '2024-10-06T09:00:00+02:00'); the end is recomputed from the duration"),
321
+ description: zod_1.z.string().optional().describe("New description (pass an empty string to clear it)"),
322
+ task_id: zod_1.z.string().min(1).refine(val => (0, utils_1.isTaskId)(val) || (0, utils_1.isCustomTaskId)(val), {
323
+ message: "Must be an internal task ID (6+ alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
324
+ }).optional().describe("Move the entry to another task: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")")
325
+ }, {
326
+ readOnlyHint: false,
327
+ destructiveHint: true,
328
+ idempotentHint: true,
329
+ }, async ({ entry_id, hours, start_time, description, task_id }) => {
330
+ try {
331
+ if (hours === undefined && start_time === undefined && description === undefined && task_id === undefined) {
332
+ return {
333
+ content: [{ type: "text", text: "No updates provided. Please specify at least one of hours, start_time, description or task_id." }],
334
+ };
335
+ }
336
+ const userData = await (0, utils_1.getCurrentUser)();
337
+ const existing = await fetchTimeEntry(entry_id);
338
+ assertTimeEntryIsOwn(existing, userData.user.id, 'updated');
339
+ // ClickUp leaves `end` untouched when only `start` is sent, which makes the
340
+ // entry inconsistent - so start, end and duration are always sent together.
341
+ const startMs = start_time !== undefined ? isoToTimestamp(start_time) : parseInt(existing.start);
342
+ const durationMs = hours !== undefined ? Math.round(hours * 60 * 60 * 1000) : parseInt(existing.duration);
343
+ const requestBody = {
344
+ start: startMs,
345
+ end: startMs + durationMs,
346
+ duration: durationMs,
347
+ ...(description !== undefined && { description }),
348
+ ...(task_id !== undefined && { tid: await (0, utils_1.resolveTaskId)(task_id) }),
349
+ };
350
+ const response = await fetch(`https://api.clickup.com/api/v2/team/${config_1.CONFIG.teamId}/time_entries/${entry_id}`, {
351
+ method: 'PUT',
352
+ headers: {
353
+ Authorization: config_1.CONFIG.apiKey,
354
+ 'Content-Type': 'application/json'
355
+ },
356
+ body: JSON.stringify(requestBody)
357
+ });
358
+ if (!response.ok) {
359
+ const errorData = await response.json().catch(() => ({}));
360
+ throw new Error(`Error updating time entry: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
361
+ }
362
+ // PUT answers `{"data": [entry]}` - fall back to what was sent if the shape differs
363
+ const responseData = await response.json();
364
+ const updated = Array.isArray(responseData.data) ? responseData.data[0] : responseData.data;
365
+ const finalEntry = { ...existing, ...requestBody, ...(updated || {}), task: updated?.task || existing.task };
366
+ return {
367
+ content: [
368
+ {
369
+ type: "text",
370
+ text: [`Time entry updated successfully!`, ...formatTimeEntryLines(finalEntry)].join('\n')
371
+ }
372
+ ],
373
+ };
374
+ }
375
+ catch (error) {
376
+ console.error('Error updating time entry:', error);
377
+ return {
378
+ content: [
379
+ {
380
+ type: "text",
381
+ text: `Error updating time entry: ${error instanceof Error ? error.message : 'Unknown error'}`,
382
+ },
383
+ ],
384
+ };
385
+ }
386
+ });
387
+ tool("deleteTimeEntry", [
388
+ "Deletes a time entry. This cannot be undone - check the entry with getTimeEntries first.",
389
+ "GUARDRAILS: only time entries of the API token's own user can be deleted, and running timers are refused.",
390
+ "The response echoes the deleted values so the entry can be recreated with createTimeEntry if needed."
391
+ ].join("\n"), {
392
+ entry_id: zod_1.z.string().min(1).describe("The time entry ID (entry_id shown by getTimeEntries)")
393
+ }, {
394
+ readOnlyHint: false,
395
+ destructiveHint: true,
396
+ idempotentHint: false,
397
+ }, async ({ entry_id }) => {
398
+ try {
399
+ const userData = await (0, utils_1.getCurrentUser)();
400
+ const existing = await fetchTimeEntry(entry_id);
401
+ assertTimeEntryIsOwn(existing, userData.user.id, 'deleted');
402
+ const response = await fetch(`https://api.clickup.com/api/v2/team/${config_1.CONFIG.teamId}/time_entries/${entry_id}`, {
403
+ method: 'DELETE',
404
+ headers: {
405
+ Authorization: config_1.CONFIG.apiKey,
406
+ 'Content-Type': 'application/json'
407
+ }
408
+ });
409
+ if (!response.ok) {
410
+ const errorData = await response.json().catch(() => ({}));
411
+ throw new Error(`Error deleting time entry: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
412
+ }
413
+ // DELETE answers an empty object, so the values come from the entry read beforehand
414
+ return {
415
+ content: [
416
+ {
417
+ type: "text",
418
+ text: [`Time entry deleted successfully!`, ...formatTimeEntryLines(existing)].join('\n')
419
+ }
420
+ ],
421
+ };
422
+ }
423
+ catch (error) {
424
+ console.error('Error deleting time entry:', error);
425
+ return {
426
+ content: [
427
+ {
428
+ type: "text",
429
+ text: `Error deleting time entry: ${error instanceof Error ? error.message : 'Unknown error'}`,
430
+ },
431
+ ],
432
+ };
433
+ }
434
+ });
270
435
  tool("createTimeEntry", [
271
436
  "Creates a time entry (books time) on a task for the current user.",
272
437
  "Use decimal hours (e.g., 0.25 for 15 minutes, 0.5 for 30 minutes, 2.5 for 2.5 hours).",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sebastienheyd/clickup-mcp",
3
- "version": "1.8.0",
3
+ "version": "1.9.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",