@taazkareem/clickup-mcp-server 0.4.75 → 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 +7 -4
- package/build/index.js +0 -6
- package/build/server.js +9 -3
- package/build/services/clickup/task.js +141 -0
- package/build/tools/task/attachments.js +354 -0
- package/build/tools/task/handlers.js +30 -0
- package/build/tools/task/index.js +4 -2
- package/build/tools/task/main.js +11 -2
- package/build/tools/task/single-operations.js +50 -0
- package/package.json +1 -1
- package/build/mcp-tools.js +0 -64
- package/build/server-state.js +0 -93
- package/build/server.log +0 -164
- package/build/tools/bulk-tasks.js +0 -36
- package/build/tools/debug.js +0 -76
- package/build/tools/logs.js +0 -55
- package/build/tools/task.js +0 -1554
- package/build/utils/params-utils.js +0 -39
- package/build/utils/sponsor-analytics.js +0 -100
- package/build/utils/sponsor-utils.js +0 -57
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
|
|
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
|
|
56
|
+
- Create, retrieve, update, and delete tasks
|
|
56
57
|
- Move and duplicate tasks between lists, spaces, and folders
|
|
57
|
-
-
|
|
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` |
|
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.
|
|
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:
|
|
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":
|
|
@@ -373,6 +373,67 @@ export class TaskService extends BaseClickUpService {
|
|
|
373
373
|
throw this.handleError(error, 'Failed to get task comments');
|
|
374
374
|
}
|
|
375
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
|
+
}
|
|
376
437
|
/**
|
|
377
438
|
* Validate that a list exists
|
|
378
439
|
*
|
|
@@ -394,4 +455,84 @@ export class TaskService extends BaseClickUpService {
|
|
|
394
455
|
throw new ClickUpServiceError(`Failed to validate list existence: ${error instanceof Error ? error.message : String(error)}`, ErrorCode.UNKNOWN, { listId });
|
|
395
456
|
}
|
|
396
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
|
+
}
|
|
397
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);
|
|
@@ -156,6 +156,36 @@ export async function getTaskCommentsHandler(params) {
|
|
|
156
156
|
const { start, startId } = params;
|
|
157
157
|
return await taskService.getTaskComments(taskId, start, startId);
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Handler for creating a task comment
|
|
161
|
+
*/
|
|
162
|
+
export async function createTaskCommentHandler(params) {
|
|
163
|
+
// Validate required parameters
|
|
164
|
+
if (!params.commentText) {
|
|
165
|
+
throw new Error('Comment text is required');
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
// Resolve the task ID
|
|
169
|
+
const taskId = await getTaskId(params.taskId, params.taskName, params.listName);
|
|
170
|
+
// Extract other parameters with defaults
|
|
171
|
+
const { commentText, notifyAll = false, assignee = null } = params;
|
|
172
|
+
// Create the comment
|
|
173
|
+
return await taskService.createTaskComment(taskId, commentText, notifyAll, assignee);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// If this is a task lookup error, provide more helpful message
|
|
177
|
+
if (error.message?.includes('not found') || error.message?.includes('identify task')) {
|
|
178
|
+
if (params.taskName) {
|
|
179
|
+
throw new Error(`Could not find task "${params.taskName}" in list "${params.listName}"`);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
throw new Error(`Task with ID "${params.taskId}" not found`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Otherwise, rethrow the original error
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
159
189
|
//=============================================================================
|
|
160
190
|
// BULK TASK OPERATIONS
|
|
161
191
|
//=============================================================================
|
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
// Re-export from main module
|
|
7
7
|
export * from './main.js';
|
|
8
8
|
// Re-export single task operation tools
|
|
9
|
-
export { createTaskTool, getTaskTool, getTasksTool, updateTaskTool, moveTaskTool, duplicateTaskTool, deleteTaskTool, getTaskCommentsTool } from './single-operations.js';
|
|
9
|
+
export { createTaskTool, getTaskTool, getTasksTool, updateTaskTool, moveTaskTool, duplicateTaskTool, deleteTaskTool, getTaskCommentsTool, createTaskCommentTool } from './single-operations.js';
|
|
10
10
|
// Re-export bulk task operation tools
|
|
11
11
|
export { createBulkTasksTool, updateBulkTasksTool, moveBulkTasksTool, deleteBulkTasksTool } from './bulk-operations.js';
|
|
12
|
+
// Re-export attachment tool
|
|
13
|
+
export { attachTaskFileTool, handleAttachTaskFile } from './attachments.js';
|
|
12
14
|
// Re-export handlers
|
|
13
15
|
export {
|
|
14
16
|
// Single task operation handlers
|
|
15
|
-
createTaskHandler, getTaskHandler, getTasksHandler, updateTaskHandler, moveTaskHandler, duplicateTaskHandler, deleteTaskHandler, getTaskCommentsHandler,
|
|
17
|
+
createTaskHandler, getTaskHandler, getTasksHandler, updateTaskHandler, moveTaskHandler, duplicateTaskHandler, deleteTaskHandler, getTaskCommentsHandler, createTaskCommentHandler,
|
|
16
18
|
// Bulk task operation handlers
|
|
17
19
|
createBulkTasksHandler, updateBulkTasksHandler, moveBulkTasksHandler, deleteBulkTasksHandler } from './handlers.js';
|
|
18
20
|
// Re-export utilities
|