@taazkareem/clickup-mcp-server 0.4.74 → 0.5.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
@@ -3,7 +3,7 @@
3
3
  # MCP Server
4
4
  A Model Context Protocol (MCP) server for integrating ClickUp tasks with AI applications. This server allows AI agents to interact with ClickUp tasks, spaces, lists, and folders through a standardized protocol.
5
5
 
6
- > 🚧 **Status Update:** -Added get_task_comments tool -Improved task name matching -Fixed workspace hierarchy display
6
+ > 🚧 **Status Update:** -Added attach_task_file tool with support for local files, URLs, and base64 data, and create_task_comment tool
7
7
 
8
8
  <a href="https://glama.ai/mcp/servers/iwjvs2zy63">
9
9
  <img width="380" height="200" src="https://glama.ai/mcp/servers/iwjvs2zy63/badge" alt="ClickUp Server MCP server" />
@@ -15,6 +15,7 @@ A Model Context Protocol (MCP) server for integrating ClickUp tasks with AI appl
15
15
  - ClickUp API key from [ClickUp Settings](https://app.clickup.com/settings/apps)
16
16
  - Team ID from your ClickUp workspace URL
17
17
  2. Choose either hosted installation (sends webhooks) or NPX installation (downloads to local path and installs dependencies)
18
+ 3. Use natural language to manage your workspace!
18
19
 
19
20
  ## Smithery Installation (Quick Start)
20
21
 
@@ -52,17 +53,17 @@ Or use this npx command:
52
53
  ## Features
53
54
 
54
55
  - 🎯 **Task Management**
55
- - Create, update, and delete individual tasks
56
+ - Create, retrieve, update, and delete tasks
56
57
  - Move and duplicate tasks between lists, spaces, and folders
57
- - Create, update, move, and delete multiple tasks in bulk
58
+ - Single operation or bulk operation
58
59
  - View and modify task details and properties
59
60
  - Get task comments
60
61
  - Set due dates using natural language and relative time expressions
62
+ - Attach files to tasks using local file paths, URL, base64, or chunked uploads
61
63
 
62
64
  - 📂 **Workspace Organization**
63
65
  - Complete workspace hierarchy (spaces, folders, lists)
64
66
  - Tree structure with clear relationships
65
- - Full CRUD operations for workspace components
66
67
  - Efficient path-based navigation
67
68
 
68
69
  - 🔄 **Integration Features**
@@ -83,6 +84,8 @@ Or use this npx command:
83
84
  | [get_tasks](docs/api-reference.md#task-management) | Get tasks from list | `listId`/`listName` |
84
85
  | [get_task](docs/api-reference.md#task-management) | Get task details | `taskId`/`taskName` |
85
86
  | [get_task_comments](docs/api-reference.md#task-management) | Get comments on a task | `taskId`/`taskName` |
87
+ | [create_task_comment](docs/api-reference.md#task-management) | Add a comment to a task | `commentText`, (`taskId`/(`taskName`+`listName`)) |
88
+ | [attach_task_file](docs/api-reference.md#task-management) | Attach file to a task | `taskId`/`taskName`, (`file_data` or `file_url`) |
86
89
  | [delete_task](docs/api-reference.md#task-management) | Remove task | `taskId`/`taskName` |
87
90
  | [delete_bulk_tasks](docs/api-reference.md#task-management) | Remove multiple tasks | `tasks[]` with IDs or names |
88
91
  | [move_task](docs/api-reference.md#task-management) | Move task | `taskId`/`taskName`, `listId`/`listName` |
@@ -126,6 +129,10 @@ If you find this project useful, please consider supporting:
126
129
 
127
130
  [![Sponsor TaazKareem](https://img.shields.io/badge/Sponsor-TaazKareem-orange?logo=github)](https://github.com/sponsors/TaazKareem)
128
131
 
132
+ ## Acknowledgements
133
+
134
+ Special thanks to [ClickUp](https://clickup.com) for their excellent API and services that make this integration possible.
135
+
129
136
  ## Contributing
130
137
 
131
138
  Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details.
package/build/index.js CHANGED
@@ -19,12 +19,6 @@
19
19
  * - Smart name-based lookups that eliminate the need for IDs
20
20
  * - Support for priorities, statuses, and due dates
21
21
  *
22
- * AI-Enhanced Capabilities:
23
- * - Task summarization and status grouping for project overviews
24
- * - Priority analysis and optimization for workload balancing
25
- * - Detailed task description generation with structured content
26
- * - Task relationship identification for dependency management
27
- *
28
22
  * Technical Features:
29
23
  * - Full markdown support for rich text content
30
24
  * - Secure credential handling through configuration
package/build/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
3
3
  import { workspaceHierarchyTool, handleGetWorkspaceHierarchy } from "./tools/workspace.js";
4
- import { createTaskTool, updateTaskTool, moveTaskTool, duplicateTaskTool, getTaskTool, getTasksTool, deleteTaskTool, getTaskCommentsTool, createBulkTasksTool, updateBulkTasksTool, moveBulkTasksTool, deleteBulkTasksTool, handleCreateTask, handleUpdateTask, handleMoveTask, handleDuplicateTask, handleGetTasks, handleDeleteTask, handleGetTaskComments, handleCreateBulkTasks, handleUpdateBulkTasks, handleMoveBulkTasks, handleDeleteBulkTasks, handleGetTask } from "./tools/task/index.js";
4
+ import { createTaskTool, updateTaskTool, moveTaskTool, duplicateTaskTool, getTaskTool, getTasksTool, deleteTaskTool, getTaskCommentsTool, createTaskCommentTool, createBulkTasksTool, updateBulkTasksTool, moveBulkTasksTool, deleteBulkTasksTool, attachTaskFileTool, handleCreateTask, handleUpdateTask, handleMoveTask, handleDuplicateTask, handleGetTasks, handleDeleteTask, handleGetTaskComments, handleCreateTaskComment, handleCreateBulkTasks, handleUpdateBulkTasks, handleMoveBulkTasks, handleDeleteBulkTasks, handleGetTask, handleAttachTaskFile } from "./tools/task/index.js";
5
5
  import { createListTool, handleCreateList, createListInFolderTool, handleCreateListInFolder, getListTool, handleGetList, updateListTool, handleUpdateList, deleteListTool, handleDeleteList } from "./tools/list.js";
6
6
  import { createFolderTool, handleCreateFolder, getFolderTool, handleGetFolder, updateFolderTool, handleUpdateFolder, deleteFolderTool, handleDeleteFolder } from "./tools/folder.js";
7
7
  import { Logger } from "./logger.js";
@@ -15,7 +15,7 @@ const { workspace } = clickUpServices;
15
15
  */
16
16
  export const server = new Server({
17
17
  name: "clickup-mcp-server",
18
- version: "0.4.72",
18
+ version: "0.5.0",
19
19
  }, {
20
20
  capabilities: {
21
21
  tools: {},
@@ -41,6 +41,8 @@ export function configureServer() {
41
41
  duplicateTaskTool,
42
42
  deleteTaskTool,
43
43
  getTaskCommentsTool,
44
+ createTaskCommentTool,
45
+ attachTaskFileTool,
44
46
  createBulkTasksTool,
45
47
  updateBulkTasksTool,
46
48
  moveBulkTasksTool,
@@ -59,7 +61,7 @@ export function configureServer() {
59
61
  });
60
62
  // Register CallTool handler with proper logging
61
63
  logger.info("Registering tool handlers", {
62
- toolCount: 22,
64
+ toolCount: 23,
63
65
  categories: ["workspace", "task", "list", "folder"]
64
66
  });
65
67
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
@@ -89,6 +91,10 @@ export function configureServer() {
89
91
  return handleDeleteTask(params);
90
92
  case "get_task_comments":
91
93
  return handleGetTaskComments(params);
94
+ case "create_task_comment":
95
+ return handleCreateTaskComment(params);
96
+ case "attach_task_file":
97
+ return handleAttachTaskFile(params);
92
98
  case "create_bulk_tasks":
93
99
  return handleCreateBulkTasks(params);
94
100
  case "update_bulk_tasks":
@@ -204,6 +204,32 @@ export class TaskService extends BaseClickUpService {
204
204
  throw this.handleError(error, `Failed to get task ${taskId}`);
205
205
  }
206
206
  }
207
+ /**
208
+ * Get a specific task by its custom ID
209
+ * @param customTaskId The custom ID of the task (e.g., "DEV-1234")
210
+ * @param listId Optional list ID to search within for better performance
211
+ * @returns The task details
212
+ */
213
+ async getTaskByCustomId(customTaskId, listId) {
214
+ this.logOperation('getTaskByCustomId', { customTaskId, listId });
215
+ try {
216
+ return await this.makeRequest(async () => {
217
+ // Build query with custom_task_ids=true
218
+ const params = new URLSearchParams({ custom_task_ids: 'true' });
219
+ // Use the ClickUp API endpoint for retrieving tasks by ID
220
+ // With custom_task_ids=true parameter, it will treat the ID as a custom ID
221
+ const response = await this.client.get(`/task/${customTaskId}?${params.toString()}`);
222
+ return response.data;
223
+ });
224
+ }
225
+ catch (error) {
226
+ // Enhance error message for custom ID lookups
227
+ if (error?.response?.status === 404) {
228
+ throw this.handleError(error, `Task with custom ID ${customTaskId} not found`);
229
+ }
230
+ throw this.handleError(error, `Failed to get task with custom ID ${customTaskId}`);
231
+ }
232
+ }
207
233
  /**
208
234
  * Update an existing task
209
235
  * @param taskId ID of the task to update
@@ -347,6 +373,67 @@ export class TaskService extends BaseClickUpService {
347
373
  throw this.handleError(error, 'Failed to get task comments');
348
374
  }
349
375
  }
376
+ /**
377
+ * Create a comment on a task
378
+ *
379
+ * @param taskId ID of the task to comment on
380
+ * @param commentText Text content of the comment
381
+ * @param notifyAll Whether to notify all assignees
382
+ * @param assignee Optional user ID to assign the comment to
383
+ * @returns The created comment
384
+ */
385
+ async createTaskComment(taskId, commentText, notifyAll = false, assignee) {
386
+ this.logOperation('createTaskComment', { taskId, commentText, notifyAll, assignee });
387
+ try {
388
+ const payload = {
389
+ comment_text: commentText,
390
+ notify_all: notifyAll
391
+ };
392
+ if (assignee) {
393
+ payload.assignee = assignee;
394
+ }
395
+ // Make the request directly without using makeRequest for better error handling
396
+ const response = await this.client.post(`/task/${taskId}/comment`, payload);
397
+ // Handle different response formats from ClickUp API
398
+ if (response.data) {
399
+ if (response.data.comment) {
400
+ // Standard format: { comment: ClickUpComment }
401
+ return response.data.comment;
402
+ }
403
+ else if (response.data.id && (response.data.comment_text || response.data.comment)) {
404
+ // Direct format: the comment object itself
405
+ return response.data;
406
+ }
407
+ else {
408
+ // Fallback: construct a minimal valid comment object
409
+ return {
410
+ id: response.data.id || `custom-${Date.now()}`,
411
+ comment: response.data.comment || commentText,
412
+ comment_text: response.data.comment_text || commentText,
413
+ user: response.data.user || { id: 0, username: 'Unknown', email: '', color: '' },
414
+ date: response.data.date || new Date().toISOString(),
415
+ resolved: false
416
+ };
417
+ }
418
+ }
419
+ throw new Error('Invalid response from ClickUp API');
420
+ }
421
+ catch (error) {
422
+ // Check if comment might have been created despite error
423
+ if (error.response?.status === 200 || error.response?.status === 201) {
424
+ // Try to construct a comment object from what we know
425
+ return {
426
+ id: `fallback-${Date.now()}`,
427
+ comment: commentText,
428
+ comment_text: commentText,
429
+ user: { id: 0, username: 'Unknown', email: '', color: '' },
430
+ date: new Date().toISOString(),
431
+ resolved: false
432
+ };
433
+ }
434
+ throw this.handleError(error, 'Failed to create task comment');
435
+ }
436
+ }
350
437
  /**
351
438
  * Validate that a list exists
352
439
  *
@@ -368,4 +455,84 @@ export class TaskService extends BaseClickUpService {
368
455
  throw new ClickUpServiceError(`Failed to validate list existence: ${error instanceof Error ? error.message : String(error)}`, ErrorCode.UNKNOWN, { listId });
369
456
  }
370
457
  }
458
+ /**
459
+ * Upload a file attachment to a ClickUp task
460
+ * @param taskId The ID of the task to attach the file to
461
+ * @param fileData The file data as a Buffer
462
+ * @param fileName The name of the file
463
+ * @returns Promise resolving to the attachment response from ClickUp
464
+ */
465
+ async uploadTaskAttachment(taskId, fileData, fileName) {
466
+ this.logOperation('uploadTaskAttachment', { taskId, fileName, fileSize: fileData.length });
467
+ try {
468
+ return await this.makeRequest(async () => {
469
+ // Create FormData for multipart/form-data upload
470
+ const FormData = (await import('form-data')).default;
471
+ const formData = new FormData();
472
+ // Add the file to the form data
473
+ formData.append('attachment', fileData, {
474
+ filename: fileName,
475
+ contentType: 'application/octet-stream' // Let ClickUp determine the content type
476
+ });
477
+ // Use the raw axios client for this request since we need to handle FormData
478
+ const response = await this.client.post(`/task/${taskId}/attachment`, formData, {
479
+ headers: {
480
+ ...formData.getHeaders(),
481
+ 'Authorization': this.apiKey
482
+ }
483
+ });
484
+ return response.data;
485
+ });
486
+ }
487
+ catch (error) {
488
+ throw this.handleError(error, `Failed to upload attachment to task ${taskId}`);
489
+ }
490
+ }
491
+ /**
492
+ * Upload a file attachment to a ClickUp task from a URL
493
+ * @param taskId The ID of the task to attach the file to
494
+ * @param fileUrl The URL of the file to download and attach
495
+ * @param fileName Optional file name (if not provided, it will be extracted from the URL)
496
+ * @param authHeader Optional authorization header for the URL
497
+ * @returns Promise resolving to the attachment response from ClickUp
498
+ */
499
+ async uploadTaskAttachmentFromUrl(taskId, fileUrl, fileName, authHeader) {
500
+ this.logOperation('uploadTaskAttachmentFromUrl', { taskId, fileUrl, fileName });
501
+ try {
502
+ return await this.makeRequest(async () => {
503
+ // Import required modules
504
+ const axios = (await import('axios')).default;
505
+ const FormData = (await import('form-data')).default;
506
+ // Download the file from the URL
507
+ const headers = {};
508
+ if (authHeader) {
509
+ headers['Authorization'] = authHeader;
510
+ }
511
+ const response = await axios.get(fileUrl, {
512
+ responseType: 'arraybuffer',
513
+ headers
514
+ });
515
+ // Extract filename from URL if not provided
516
+ const actualFileName = fileName || fileUrl.split('/').pop() || 'downloaded-file';
517
+ // Create FormData for multipart/form-data upload
518
+ const formData = new FormData();
519
+ // Add the file to the form data
520
+ formData.append('attachment', Buffer.from(response.data), {
521
+ filename: actualFileName,
522
+ contentType: 'application/octet-stream'
523
+ });
524
+ // Upload the file to ClickUp
525
+ const uploadResponse = await this.client.post(`/task/${taskId}/attachment`, formData, {
526
+ headers: {
527
+ ...formData.getHeaders(),
528
+ 'Authorization': this.apiKey
529
+ }
530
+ });
531
+ return uploadResponse.data;
532
+ });
533
+ }
534
+ catch (error) {
535
+ throw this.handleError(error, `Failed to upload attachment from URL to task ${taskId}`);
536
+ }
537
+ }
371
538
  }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * ClickUp MCP Task Attachment Tool
3
+ *
4
+ * This module implements a tool for attaching files to ClickUp tasks
5
+ * with automatic method selection based on file source and size.
6
+ */
7
+ import { taskService } from '../../services/shared.js';
8
+ import { validateTaskIdentification, resolveTaskIdWithValidation } from './utilities.js';
9
+ import { sponsorService } from '../../utils/sponsor-service.js';
10
+ // Session storage for chunked uploads (in-memory for demonstration)
11
+ // In production, this should use a more persistent store
12
+ const chunkSessions = new Map();
13
+ // Clean up expired sessions periodically
14
+ setInterval(() => {
15
+ const now = Date.now();
16
+ const expired = 24 * 60 * 60 * 1000; // 24 hours
17
+ for (const [token, session] of chunkSessions.entries()) {
18
+ if (now - session.timestamp > expired) {
19
+ chunkSessions.delete(token);
20
+ console.log(`Cleaned up expired upload session: ${token}`);
21
+ }
22
+ }
23
+ }, 3600 * 1000); // Check every hour
24
+ /**
25
+ * Single unified tool for attaching files to ClickUp tasks
26
+ */
27
+ export const attachTaskFileTool = {
28
+ name: "attach_task_file",
29
+ description: `Purpose: Attaches a file to a ClickUp task.
30
+
31
+ Valid Usage:
32
+ 1. Upload from base64: Provide file_data + file_name
33
+ 2. Upload from URL or local file: Provide file_url + optional file_name
34
+ - For web URLs: Use http:// or https:// URLs
35
+ - For local files: Use absolute file paths (starting with / or drive letter)
36
+ 3. For large files, advanced options are available via chunk_* parameters
37
+
38
+ Requirements:
39
+ - EITHER taskId OR (taskName + listName) is REQUIRED
40
+ - EITHER file_data OR file_url is REQUIRED
41
+
42
+ Notes:
43
+ - The system automatically selects the best upload method based on file size and source
44
+ - Base64 method has a 10MB size limit due to encoding overhead (file_data parameter)
45
+ - URL method works for files hosted online (file_url parameter with http/https)
46
+ - Local file method works with absolute paths only (file_url parameter with / or drive letter)
47
+ - For large files, the system may use chunked uploading automatically`,
48
+ inputSchema: {
49
+ type: "object",
50
+ properties: {
51
+ taskId: {
52
+ type: "string",
53
+ description: "ID of the task to attach the file to. Works with both regular task IDs (9 characters) and custom IDs with uppercase prefixes (like 'DEV-1234')."
54
+ },
55
+ taskName: {
56
+ type: "string",
57
+ description: "Name of the task to attach the file to. When using this parameter, you MUST also provide listName."
58
+ },
59
+ listName: {
60
+ type: "string",
61
+ description: "Name of the list containing the task. REQUIRED when using taskName."
62
+ },
63
+ file_name: {
64
+ type: "string",
65
+ description: "Name of the file to be attached (include the extension). Required when using file_data."
66
+ },
67
+ file_data: {
68
+ type: "string",
69
+ description: "Base64-encoded content of the file (without the data URL prefix)."
70
+ },
71
+ file_url: {
72
+ type: "string",
73
+ description: "DUAL PURPOSE PARAMETER: Either (1) a web URL starting with http/https to download a file from, OR (2) an absolute local file path starting with / or drive letter. DO NOT use relative paths."
74
+ },
75
+ auth_header: {
76
+ type: "string",
77
+ description: "Optional authorization header to use when downloading from a web URL (ignored for local files)."
78
+ },
79
+ // Advanced parameters for chunked uploads - usually not needed as chunking is automatic
80
+ chunk_index: {
81
+ type: "number",
82
+ description: "Optional: For advanced usage with large file chunking. The 0-based index of this chunk."
83
+ },
84
+ chunk_session: {
85
+ type: "string",
86
+ description: "Optional: For advanced usage with large file chunking. Session identifier from a previous chunk upload."
87
+ },
88
+ chunk_total: {
89
+ type: "number",
90
+ description: "Optional: For advanced usage with large file chunking. Total number of chunks expected."
91
+ },
92
+ chunk_is_last: {
93
+ type: "boolean",
94
+ description: "Optional: For advanced usage with large file chunking. Whether this is the final chunk."
95
+ }
96
+ },
97
+ required: [] // Will validate based on context in the handler
98
+ }
99
+ };
100
+ /**
101
+ * Handler function for the attachTaskFileTool
102
+ */
103
+ async function attachTaskFileHandler(params) {
104
+ // Extract common parameters
105
+ const { taskId, taskName, listName, file_name, file_data, file_url, auth_header, chunk_total, chunk_size, chunk_index, session_id } = params;
106
+ // Validate task identification
107
+ validateTaskIdentification(params);
108
+ // Validate file source - either file_data or file_url must be provided
109
+ if (!file_data && !file_url && !session_id) {
110
+ throw new Error("Either file_data, file_url, or session_id must be provided");
111
+ }
112
+ // Resolve task ID
113
+ const resolvedTaskId = await resolveTaskIdWithValidation(taskId, taskName, listName);
114
+ try {
115
+ // CASE 1: Chunked upload continuation
116
+ if (session_id) {
117
+ return await handleChunkUpload(resolvedTaskId, session_id, chunk_index, file_data, chunk_total === chunk_index + 1);
118
+ }
119
+ // CASE 2: URL-based upload or local file path
120
+ if (file_url) {
121
+ // Check if it's a local file path
122
+ console.log(`Checking if path is local: ${file_url}`);
123
+ if (file_url.startsWith('/') || /^[A-Za-z]:\\/.test(file_url)) {
124
+ console.log(`Detected as local path, proceeding to handle: ${file_url}`);
125
+ return await handleLocalFileUpload(resolvedTaskId, file_url, file_name);
126
+ }
127
+ else if (file_url.startsWith('http://') || file_url.startsWith('https://')) {
128
+ console.log(`Detected as URL, proceeding with URL upload: ${file_url}`);
129
+ return await handleUrlUpload(resolvedTaskId, file_url, file_name, auth_header);
130
+ }
131
+ else {
132
+ throw new Error(`Invalid file_url format: "${file_url}". The file_url parameter must be either an absolute file path (starting with / or drive letter) or a web URL (starting with http:// or https://)`);
133
+ }
134
+ }
135
+ // CASE 3: Base64 upload (with automatic chunking for large files)
136
+ if (file_data) {
137
+ if (!file_name) {
138
+ throw new Error("file_name is required when using file_data");
139
+ }
140
+ // Check if we need to use chunking (file > 10MB)
141
+ const fileBuffer = Buffer.from(file_data, 'base64');
142
+ const fileSize = fileBuffer.length;
143
+ if (fileSize > 10 * 1024 * 1024) {
144
+ // For large files, start chunked upload process
145
+ return await startChunkedUpload(resolvedTaskId, file_name, fileBuffer);
146
+ }
147
+ else {
148
+ // For small files, upload directly
149
+ return await handleDirectUpload(resolvedTaskId, file_name, fileBuffer);
150
+ }
151
+ }
152
+ throw new Error("Invalid parameters: Unable to determine upload method");
153
+ }
154
+ catch (error) {
155
+ console.error(`Error attaching file to task:`, error);
156
+ throw error;
157
+ }
158
+ }
159
+ /**
160
+ * Handle direct upload for small files
161
+ */
162
+ async function handleDirectUpload(taskId, fileName, fileBuffer) {
163
+ try {
164
+ // Call service method
165
+ const result = await taskService.uploadTaskAttachment(taskId, fileBuffer, fileName);
166
+ return {
167
+ success: true,
168
+ message: `File "${fileName}" successfully attached to task ${taskId}`,
169
+ attachment: result
170
+ };
171
+ }
172
+ catch (error) {
173
+ throw new Error(`Failed to upload file: ${error.message}`);
174
+ }
175
+ }
176
+ /**
177
+ * Handle URL-based upload
178
+ */
179
+ async function handleUrlUpload(taskId, fileUrl, fileName, authHeader) {
180
+ try {
181
+ // Extract filename from URL if not provided
182
+ const extractedFileName = fileName || new URL(fileUrl).pathname.split('/').pop() || 'downloaded-file';
183
+ // Call service method
184
+ const result = await taskService.uploadTaskAttachmentFromUrl(taskId, fileUrl, extractedFileName, authHeader);
185
+ return {
186
+ success: true,
187
+ message: `File from "${fileUrl}" successfully attached to task ${taskId}`,
188
+ attachment: result
189
+ };
190
+ }
191
+ catch (error) {
192
+ if (error.message === 'Invalid URL') {
193
+ throw new Error(`Failed to upload file from URL: Invalid URL format. The file_url parameter must be a valid web URL starting with http:// or https://`);
194
+ }
195
+ throw new Error(`Failed to upload file from URL: ${error.message}`);
196
+ }
197
+ }
198
+ /**
199
+ * Start a chunked upload process for large files
200
+ */
201
+ async function startChunkedUpload(taskId, fileName, fileBuffer) {
202
+ // Generate a session token
203
+ const sessionToken = `chunk_session_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
204
+ // Store the file in chunks (for demonstration - in production would store chunk info only)
205
+ // Split the file into chunks for storage
206
+ const chunkSize = 5 * 1024 * 1024; // 5MB chunks
207
+ const chunksMap = new Map();
208
+ for (let i = 0; i < fileBuffer.length; i += chunkSize) {
209
+ const chunk = fileBuffer.slice(i, i + chunkSize);
210
+ chunksMap.set(Math.floor(i / chunkSize), chunk);
211
+ }
212
+ // Create a new session
213
+ chunkSessions.set(sessionToken, {
214
+ taskId,
215
+ fileName,
216
+ fileSize: fileBuffer.length,
217
+ chunks: chunksMap,
218
+ timestamp: Date.now()
219
+ });
220
+ // Return initial chunk
221
+ return {
222
+ success: true,
223
+ message: `Large file detected. Chunked upload initialized for "${fileName}" (${fileBuffer.length} bytes)`,
224
+ chunk_session: sessionToken,
225
+ chunks_total: chunksMap.size,
226
+ chunk_uploaded: 1,
227
+ attachment: null,
228
+ details: {
229
+ taskId,
230
+ fileName,
231
+ fileSize: fileBuffer.length,
232
+ chunkCount: chunksMap.size,
233
+ progress: Math.round((1 / chunksMap.size) * 100)
234
+ }
235
+ };
236
+ }
237
+ /**
238
+ * Handle chunk upload as part of a multi-chunk process
239
+ */
240
+ async function handleChunkUpload(taskId, sessionToken, chunkIndex, chunkData, isLastChunk) {
241
+ // Verify session exists
242
+ const session = chunkSessions.get(sessionToken);
243
+ if (!session) {
244
+ throw new Error("Upload session not found or expired");
245
+ }
246
+ // If this is the last chunk or all chunks are uploaded, finalize the upload
247
+ if (isLastChunk || (session.chunks.size === 1 && chunkIndex === undefined)) {
248
+ // Combine all chunks
249
+ const fileData = Buffer.allocUnsafe(session.fileSize);
250
+ let offset = 0;
251
+ // Sort chunks by index
252
+ const sortedChunks = Array.from(session.chunks.entries())
253
+ .sort((a, b) => a[0] - b[0]);
254
+ for (const [index, chunk] of sortedChunks) {
255
+ chunk.copy(fileData, offset);
256
+ offset += chunk.length;
257
+ }
258
+ try {
259
+ // Call service method
260
+ const result = await taskService.uploadTaskAttachment(session.taskId, fileData, session.fileName);
261
+ // Clean up the session
262
+ chunkSessions.delete(sessionToken);
263
+ return {
264
+ success: true,
265
+ message: `File "${session.fileName}" successfully attached to task ${session.taskId}`,
266
+ attachment: result
267
+ };
268
+ }
269
+ catch (error) {
270
+ throw new Error(`Failed to upload file: ${error.message}`);
271
+ }
272
+ }
273
+ // Otherwise handle the current chunk
274
+ if (chunkIndex === undefined || chunkData === undefined) {
275
+ throw new Error("chunk_index and chunk_data are required for chunk uploads");
276
+ }
277
+ // Store the chunk
278
+ // (In a real implementation, we'd append to a temp file or storage)
279
+ session.chunks.delete(chunkIndex); // Remove the chunk if it exists
280
+ session.chunks.set(chunkIndex, Buffer.from(chunkData, 'base64'));
281
+ return {
282
+ success: true,
283
+ message: `Chunk ${chunkIndex + 1}/${session.chunks.size} received`,
284
+ chunk_session: sessionToken,
285
+ chunks_remaining: session.chunks.size - chunkIndex - 1,
286
+ details: {
287
+ taskId: session.taskId,
288
+ fileName: session.fileName,
289
+ chunksReceived: chunkIndex + 1,
290
+ progress: Math.round(((chunkIndex + 1) / session.chunks.size) * 100)
291
+ }
292
+ };
293
+ }
294
+ /**
295
+ * Handle local file path upload
296
+ */
297
+ async function handleLocalFileUpload(taskId, filePath, fileName) {
298
+ try {
299
+ // Import fs and path modules
300
+ const fs = await import('fs');
301
+ const path = await import('path');
302
+ console.log(`Processing absolute file path: ${filePath}`);
303
+ // Normalize the path to prevent directory traversal attacks
304
+ const normalizedPath = path.normalize(filePath);
305
+ // Check if file exists
306
+ if (!fs.existsSync(normalizedPath)) {
307
+ throw new Error(`Local file not found: ${normalizedPath}`);
308
+ }
309
+ // Validate file stats
310
+ const stats = fs.statSync(normalizedPath);
311
+ if (!stats.isFile()) {
312
+ throw new Error(`Path is not a file: ${normalizedPath}`);
313
+ }
314
+ // Get file name if not provided
315
+ const extractedFileName = fileName || path.basename(normalizedPath);
316
+ // Read file
317
+ const fileBuffer = fs.readFileSync(normalizedPath);
318
+ const fileSize = fileBuffer.length;
319
+ console.log(`Successfully read file: ${extractedFileName} (${fileSize} bytes)`);
320
+ // Choose upload method based on file size
321
+ if (fileSize > 10 * 1024 * 1024) {
322
+ // For large files, start chunked upload process
323
+ return await startChunkedUpload(taskId, extractedFileName, fileBuffer);
324
+ }
325
+ else {
326
+ // For small files, upload directly
327
+ return await handleDirectUpload(taskId, extractedFileName, fileBuffer);
328
+ }
329
+ }
330
+ catch (error) {
331
+ if (error.message.includes('ENOENT')) {
332
+ throw new Error(`Failed to upload local file: Local file not found: ${filePath}. Make sure the file exists and the path is absolute.`);
333
+ }
334
+ else if (error.message.includes('EACCES')) {
335
+ throw new Error(`Failed to upload local file: Permission denied accessing: ${filePath}. Check file permissions.`);
336
+ }
337
+ throw new Error(`Failed to upload local file: ${error.message}`);
338
+ }
339
+ }
340
+ /**
341
+ * Creates a wrapped handler function with standard error handling and response formatting
342
+ */
343
+ function createHandlerWrapper(handler, formatResponse = (result) => result) {
344
+ return async (parameters) => {
345
+ try {
346
+ const result = await handler(parameters);
347
+ return sponsorService.createResponse(formatResponse(result), true);
348
+ }
349
+ catch (error) {
350
+ return sponsorService.createErrorResponse(error, parameters);
351
+ }
352
+ };
353
+ }
354
+ export const handleAttachTaskFile = createHandlerWrapper(attachTaskFileHandler);
@@ -47,7 +47,7 @@ const bulkOptionsSchema = {
47
47
  const taskIdentifierSchema = {
48
48
  taskId: {
49
49
  type: "string",
50
- description: "Task ID (preferred). Use instead of taskName if available."
50
+ description: "Task ID (preferred). Works with both regular task IDs (9 characters) and custom IDs with uppercase prefixes (like 'DEV-1234')."
51
51
  },
52
52
  taskName: {
53
53
  type: "string",
@@ -56,6 +56,10 @@ const taskIdentifierSchema = {
56
56
  listName: {
57
57
  type: "string",
58
58
  description: "REQUIRED with taskName: List containing the task."
59
+ },
60
+ customTaskId: {
61
+ type: "string",
62
+ description: "Custom task ID (e.g., 'DEV-1234'). Only use if you want to explicitly force custom ID lookup. In most cases, use taskId which auto-detects ID format."
59
63
  }
60
64
  };
61
65
  //=============================================================================