@sebastienheyd/clickup-mcp 1.6.1

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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +303 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +184 -0
  6. package/dist/clickup-text.d.ts +83 -0
  7. package/dist/clickup-text.d.ts.map +1 -0
  8. package/dist/clickup-text.js +563 -0
  9. package/dist/index.d.ts +5 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +133 -0
  12. package/dist/resources/space-resources.d.ts +6 -0
  13. package/dist/resources/space-resources.d.ts.map +1 -0
  14. package/dist/resources/space-resources.js +95 -0
  15. package/dist/shared/config.d.ts +11 -0
  16. package/dist/shared/config.d.ts.map +1 -0
  17. package/dist/shared/config.js +61 -0
  18. package/dist/shared/data-uri.d.ts +14 -0
  19. package/dist/shared/data-uri.d.ts.map +1 -0
  20. package/dist/shared/data-uri.js +34 -0
  21. package/dist/shared/image-processing.d.ts +13 -0
  22. package/dist/shared/image-processing.d.ts.map +1 -0
  23. package/dist/shared/image-processing.js +199 -0
  24. package/dist/shared/types.d.ts +21 -0
  25. package/dist/shared/types.d.ts.map +1 -0
  26. package/dist/shared/types.js +2 -0
  27. package/dist/shared/utils.d.ts +95 -0
  28. package/dist/shared/utils.d.ts.map +1 -0
  29. package/dist/shared/utils.js +630 -0
  30. package/dist/test-utils.d.ts +23 -0
  31. package/dist/test-utils.d.ts.map +1 -0
  32. package/dist/test-utils.js +44 -0
  33. package/dist/tools/doc-tools.d.ts +4 -0
  34. package/dist/tools/doc-tools.d.ts.map +1 -0
  35. package/dist/tools/doc-tools.js +436 -0
  36. package/dist/tools/list-tools.d.ts +4 -0
  37. package/dist/tools/list-tools.d.ts.map +1 -0
  38. package/dist/tools/list-tools.js +175 -0
  39. package/dist/tools/search-tools.d.ts +3 -0
  40. package/dist/tools/search-tools.d.ts.map +1 -0
  41. package/dist/tools/search-tools.js +188 -0
  42. package/dist/tools/space-tools.d.ts +3 -0
  43. package/dist/tools/space-tools.d.ts.map +1 -0
  44. package/dist/tools/space-tools.js +144 -0
  45. package/dist/tools/task-tools.d.ts +8 -0
  46. package/dist/tools/task-tools.d.ts.map +1 -0
  47. package/dist/tools/task-tools.js +325 -0
  48. package/dist/tools/task-write-tools.d.ts +3 -0
  49. package/dist/tools/task-write-tools.d.ts.map +1 -0
  50. package/dist/tools/task-write-tools.js +575 -0
  51. package/dist/tools/time-tools.d.ts +4 -0
  52. package/dist/tools/time-tools.d.ts.map +1 -0
  53. package/dist/tools/time-tools.js +338 -0
  54. package/package.json +72 -0
@@ -0,0 +1,325 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerTaskToolsRead = registerTaskToolsRead;
4
+ exports.generateTaskMetadata = generateTaskMetadata;
5
+ const zod_1 = require("zod");
6
+ const clickup_text_1 = require("../clickup-text");
7
+ const config_1 = require("../shared/config");
8
+ const utils_1 = require("../shared/utils");
9
+ const image_processing_1 = require("../shared/image-processing");
10
+ // Read-specific utility functions
11
+ function registerTaskToolsRead(server, userData) {
12
+ server.tool("getTaskById", [
13
+ "Get a ClickUp task with images and comments by ID.",
14
+ "Always use this URL when referencing tasks in conversations or sharing with others.",
15
+ "The response provides complete context including task details, comments, and status history."
16
+ ].join("\n"), {
17
+ id: zod_1.z
18
+ .string()
19
+ .min(1)
20
+ .refine(val => (0, utils_1.isTaskId)(val) || (0, utils_1.isCustomTaskId)(val), {
21
+ message: "Must be an internal task ID (6-9 alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
22
+ })
23
+ .describe(`The task ID: either an internal ID (6-9 alphanumeric characters like "869c4za0g") or a custom task ID (e.g. "SOI-4422"). Do not include prefixes like "#", "CU-" or URLs.`),
24
+ }, {
25
+ readOnlyHint: true
26
+ }, async ({ id }) => {
27
+ // Resolve custom task ID to internal ID if needed
28
+ const resolvedId = await (0, utils_1.resolveTaskId)(id);
29
+ // 1. Load base task content, comment events, and status change events in parallel
30
+ const [taskDetailContentBlocks, commentEvents, statusChangeEvents] = await Promise.all([
31
+ loadTaskContent(resolvedId), // Returns Promise<ContentBlock[]>
32
+ loadTaskComments(resolvedId), // Returns Promise<DatedContentEvent[]>
33
+ loadTimeInStatusHistory(resolvedId), // Returns Promise<DatedContentEvent[]>
34
+ ]);
35
+ // 2. Combine comment and status change events
36
+ const allDatedEvents = [...commentEvents, ...statusChangeEvents];
37
+ // 3. Sort all dated events chronologically
38
+ allDatedEvents.sort((a, b) => {
39
+ const dateA = a.date ? parseInt(a.date) : 0;
40
+ const dateB = b.date ? parseInt(b.date) : 0;
41
+ return dateA - dateB;
42
+ });
43
+ // 4. Flatten sorted events into a single ContentBlock stream
44
+ let processedEventBlocks = [];
45
+ for (const event of allDatedEvents) {
46
+ processedEventBlocks.push(...event.contentBlocks);
47
+ }
48
+ // 5. Combine task details with processed event blocks
49
+ const allContentBlocks = [...taskDetailContentBlocks, ...processedEventBlocks];
50
+ // 6. Download images with smart size limiting
51
+ const limitedContent = await (0, image_processing_1.downloadImages)(allContentBlocks);
52
+ return {
53
+ content: limitedContent,
54
+ };
55
+ });
56
+ }
57
+ /**
58
+ * Fetch time entries for a specific task (all time, not date-limited for detail view)
59
+ */
60
+ async function fetchTaskTimeEntries(taskId) {
61
+ try {
62
+ // Get all team members for assignee filter
63
+ const teamMembers = await (0, utils_1.getAllTeamMembers)();
64
+ const params = new URLSearchParams({
65
+ task_id: taskId,
66
+ include_location_names: 'true',
67
+ start_date: '0', // overwrite the default 30 days
68
+ });
69
+ if (teamMembers.length > 0) {
70
+ params.append('assignee', teamMembers.join(','));
71
+ }
72
+ const response = await fetch(`https://api.clickup.com/api/v2/team/${config_1.CONFIG.teamId}/time_entries?${params}`, {
73
+ headers: { Authorization: config_1.CONFIG.apiKey },
74
+ });
75
+ if (!response.ok) {
76
+ console.error(`Error fetching time entries for task ${taskId}: ${response.status} ${response.statusText}`);
77
+ return [];
78
+ }
79
+ const data = await response.json();
80
+ return data.data || [];
81
+ }
82
+ catch (error) {
83
+ console.error('Error fetching task time entries:', error);
84
+ return [];
85
+ }
86
+ }
87
+ async function loadTaskContent(taskId) {
88
+ const response = await fetch(`https://api.clickup.com/api/v2/task/${taskId}?include_markdown_description=true&include_subtasks=true`, { headers: { Authorization: config_1.CONFIG.apiKey } });
89
+ const task = await response.json();
90
+ const [taskMetadata, content] = await Promise.all([
91
+ // Create the task metadata block using the helper functions
92
+ (async () => {
93
+ const timeEntries = await fetchTaskTimeEntries(task.id);
94
+ return await generateTaskMetadata(task, timeEntries, true);
95
+ })(),
96
+ // process markdown and download images
97
+ (0, clickup_text_1.convertMarkdownToToolCallResult)(task.markdown_description || "", task.attachments || []),
98
+ ]);
99
+ return [taskMetadata, ...content];
100
+ }
101
+ async function loadTaskComments(id) {
102
+ const response = await fetch(`https://api.clickup.com/api/v2/task/${id}/comment?start_date=0`, // Ensure all comments are fetched
103
+ { headers: { Authorization: config_1.CONFIG.apiKey } });
104
+ if (!response.ok) {
105
+ console.error(`Error fetching comments for task ${id}: ${response.status} ${response.statusText}`);
106
+ return [];
107
+ }
108
+ const commentsData = await response.json();
109
+ if (!commentsData.comments || !Array.isArray(commentsData.comments)) {
110
+ console.error(`Unexpected comment data structure for task ${id}`);
111
+ return [];
112
+ }
113
+ const commentEvents = await Promise.all(commentsData.comments.map(async (comment) => {
114
+ const headerBlock = {
115
+ type: "text",
116
+ text: `Comment by ${comment.user.username} on ${timestampToIso(comment.date)}:`,
117
+ };
118
+ const commentBodyBlocks = await (0, clickup_text_1.convertClickUpTextItemsToToolCallResult)(comment.comment);
119
+ return {
120
+ date: comment.date, // String timestamp from ClickUp for sorting
121
+ contentBlocks: [headerBlock, ...commentBodyBlocks],
122
+ };
123
+ }));
124
+ return commentEvents;
125
+ }
126
+ async function loadTimeInStatusHistory(taskId) {
127
+ const url = `https://api.clickup.com/api/v2/task/${taskId}/time_in_status`;
128
+ try {
129
+ const response = await fetch(url, { headers: { Authorization: config_1.CONFIG.apiKey } });
130
+ if (!response.ok) {
131
+ console.error(`Error fetching time in status for task ${taskId}: ${response.status} ${response.statusText}`);
132
+ return [];
133
+ }
134
+ // Using 'any' for less strict typing as per user preference, but keeping structure for clarity
135
+ const data = await response.json();
136
+ const events = [];
137
+ const processStatusEntry = (entry) => {
138
+ if (!entry || !entry.total_time || !entry.total_time.since || !entry.status)
139
+ return null;
140
+ return {
141
+ date: entry.total_time.since,
142
+ contentBlocks: [{
143
+ type: "text",
144
+ text: `Status set to '${entry.status}' on ${timestampToIso(entry.total_time.since)}`,
145
+ }],
146
+ };
147
+ };
148
+ if (data.status_history && Array.isArray(data.status_history)) {
149
+ data.status_history.forEach((historyEntry) => {
150
+ const event = processStatusEntry(historyEntry);
151
+ if (event)
152
+ events.push(event);
153
+ });
154
+ }
155
+ if (data.current_status) {
156
+ const event = processStatusEntry(data.current_status);
157
+ // Ensure current_status is only added if it's distinct or more recent than the last history item.
158
+ // The deduplication logic below handles if it's the same as the last history entry.
159
+ if (event)
160
+ events.push(event);
161
+ }
162
+ // Deduplicate events based on date and status name to avoid adding current_status if it's identical to the last history entry
163
+ const uniqueEvents = Array.from(new Map(events.map(event => {
164
+ const firstBlock = event.contentBlocks[0];
165
+ const textKey = firstBlock && 'text' in firstBlock ? firstBlock.text : 'unknown';
166
+ return [`${event.date}-${textKey}`, event];
167
+ })).values());
168
+ return uniqueEvents;
169
+ }
170
+ catch (error) {
171
+ console.error(`Exception fetching time in status for task ${taskId}:`, error);
172
+ return [];
173
+ }
174
+ }
175
+ /**
176
+ * Formats timestamp to ISO string with local timezone (not UTC)
177
+ */
178
+ function timestampToIso(timestamp) {
179
+ const date = new Date(+timestamp);
180
+ const year = date.getFullYear();
181
+ const month = String(date.getMonth() + 1).padStart(2, '0');
182
+ const day = String(date.getDate()).padStart(2, '0');
183
+ const hours = String(date.getHours()).padStart(2, '0');
184
+ const minutes = String(date.getMinutes()).padStart(2, '0');
185
+ // Calculate timezone offset
186
+ const offset = date.getTimezoneOffset();
187
+ const offsetHours = Math.floor(Math.abs(offset) / 60);
188
+ const offsetMinutes = Math.abs(offset) % 60;
189
+ const sign = offset <= 0 ? '+' : '-';
190
+ const timezoneOffset = sign + String(offsetHours).padStart(2, '0') + ':' + String(offsetMinutes).padStart(2, '0');
191
+ return `${year}-${month}-${day}T${hours}:${minutes}${timezoneOffset}`;
192
+ }
193
+ /**
194
+ * Helper function to filter and format time entries for a specific task
195
+ */
196
+ function filterTaskTimeEntries(taskId, timeEntries) {
197
+ if (!timeEntries || timeEntries.length === 0) {
198
+ return null;
199
+ }
200
+ // Filter entries for this specific task
201
+ const taskEntries = timeEntries.filter((entry) => entry.task?.id === taskId);
202
+ if (taskEntries.length === 0) {
203
+ return null;
204
+ }
205
+ // Group time entries by user (same logic as original getTaskTimeEntries)
206
+ const timeByUser = new Map();
207
+ taskEntries.forEach((entry) => {
208
+ const username = entry.user?.username || 'Unknown User';
209
+ const currentTime = timeByUser.get(username) || 0;
210
+ const entryDurationMs = parseInt(entry.duration) || 0;
211
+ timeByUser.set(username, currentTime + entryDurationMs);
212
+ });
213
+ // Format results (same logic as original)
214
+ const userTimeEntries = [];
215
+ for (const [username, totalMs] of timeByUser.entries()) {
216
+ const hours = totalMs / (1000 * 60 * 60);
217
+ const displayHours = Math.floor(hours);
218
+ const displayMinutes = Math.round((hours - displayHours) * 60);
219
+ const timeDisplay = displayHours > 0 ?
220
+ `${displayHours}h ${displayMinutes}m` :
221
+ `${displayMinutes}m`;
222
+ userTimeEntries.push(`${username}: ${timeDisplay}`);
223
+ }
224
+ return userTimeEntries.length > 0 ? userTimeEntries.join(', ') : null;
225
+ }
226
+ /**
227
+ * Helper function to generate consistent task metadata
228
+ */
229
+ async function generateTaskMetadata(task, timeEntries, isDetailView = false) {
230
+ let spaceName = task.space?.name || 'Unknown Space';
231
+ let spaceIdForDisplay = task.space?.id || 'N/A';
232
+ if (spaceName === 'Unknown Space' && task.space?.id) {
233
+ const spaceDetails = await (0, utils_1.getSpaceDetails)(task.space.id);
234
+ if (spaceDetails && spaceDetails.name) {
235
+ spaceName = spaceDetails.name;
236
+ }
237
+ }
238
+ const metadataLines = [
239
+ `task_id: ${task.id}`,
240
+ `task_url: ${task.url}`,
241
+ `name: ${task.name}`,
242
+ `status: ${task.status.status}`,
243
+ `date_created: ${timestampToIso(task.date_created)}`,
244
+ `date_updated: ${timestampToIso(task.date_updated)}`,
245
+ `creator: ${task.creator.username} (${task.creator.id})`,
246
+ `assignee: ${task.assignees.map((a) => `${a.username} (${a.id})`).join(', ')}`,
247
+ `list: ${task.list.name} (${task.list.id})`,
248
+ `space: ${spaceName} (${spaceIdForDisplay})`,
249
+ ];
250
+ // Add priority if it exists
251
+ if (task.priority !== undefined && task.priority !== null) {
252
+ const priorityName = task.priority.priority || 'none';
253
+ metadataLines.push(`priority: ${priorityName}`);
254
+ }
255
+ // Add due date if it exists
256
+ if (task.due_date) {
257
+ metadataLines.push(`due_date: ${timestampToIso(task.due_date)}`);
258
+ }
259
+ // Add start date if it exists
260
+ if (task.start_date) {
261
+ metadataLines.push(`start_date: ${timestampToIso(task.start_date)}`);
262
+ }
263
+ // Add time estimate if it exists
264
+ if (task.time_estimate) {
265
+ const hours = Math.floor(task.time_estimate / 3600000);
266
+ const minutes = Math.floor((task.time_estimate % 3600000) / 60000);
267
+ metadataLines.push(`time_estimate: ${hours}h ${minutes}m`);
268
+ }
269
+ // Add time booked (tracked time entries) - only if timeEntries provided
270
+ if (timeEntries) {
271
+ const timeBooked = filterTaskTimeEntries(task.id, timeEntries);
272
+ if (timeBooked) {
273
+ const disclaimer = isDetailView ? "" : " (last 30 days)";
274
+ metadataLines.push(`time_booked${disclaimer}: ${timeBooked}`);
275
+ }
276
+ }
277
+ // Add tags if they exist
278
+ if (task.tags && task.tags.length > 0) {
279
+ metadataLines.push(`tags: ${task.tags.map((t) => t.name).join(', ')}`);
280
+ }
281
+ // Add watchers if they exist
282
+ if (task.watchers && task.watchers.length > 0) {
283
+ metadataLines.push(`watchers: ${task.watchers.map((w) => w.username).join(', ')}`);
284
+ }
285
+ // Add parent task information if it exists
286
+ if (typeof task.parent === "string") {
287
+ metadataLines.push(`parent_task_id: ${task.parent}`);
288
+ }
289
+ // Add child task information if it exists
290
+ if (task.subtasks && task.subtasks.length > 0) {
291
+ metadataLines.push(`child_task_ids: ${task.subtasks.map((st) => st.id).join(', ')}`);
292
+ }
293
+ // Add archived status if true
294
+ if (task.archived) {
295
+ metadataLines.push(`archived: true`);
296
+ }
297
+ // Add custom fields if they exist
298
+ if (task.custom_fields && task.custom_fields.length > 0) {
299
+ task.custom_fields.forEach((field) => {
300
+ if (field.value !== undefined && field.value !== null && field.value !== '') {
301
+ const fieldName = field.name.toLowerCase().replace(/\s+/g, '_');
302
+ let fieldValue = field.value;
303
+ // Handle different custom field types
304
+ if (field.type === 'drop_down' && typeof field.value === 'number') {
305
+ // For dropdown fields, find the selected option
306
+ const selectedOption = field.type_config?.options?.find((opt) => opt.orderindex === field.value);
307
+ fieldValue = selectedOption?.name || field.value;
308
+ }
309
+ else if (Array.isArray(field.value)) {
310
+ // For multi-select or array values
311
+ fieldValue = field.value.map((v) => v.name || v).join(', ');
312
+ }
313
+ else if (typeof field.value === 'object') {
314
+ // For object values (like users), extract meaningful data
315
+ fieldValue = field.value.username || field.value.name || JSON.stringify(field.value);
316
+ }
317
+ metadataLines.push(`custom_${fieldName}: ${fieldValue}`);
318
+ }
319
+ });
320
+ }
321
+ return {
322
+ type: "text",
323
+ text: metadataLines.join("\n"),
324
+ };
325
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerTaskToolsWrite(server: McpServer, userData: any): void;
3
+ //# sourceMappingURL=task-write-tools.d.ts.map
@@ -0,0 +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;AAcpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,QAgatE"}