@taazkareem/clickup-mcp-server
Version:
ClickUp MCP Server - Integrate ClickUp tasks with AI through Model Context Protocol
358 lines (357 loc) • 15.4 kB
JavaScript
/**
* SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
* SPDX-License-Identifier: MIT
*
* ClickUp MCP Task Attachment Tool
*
* This module implements a tool for attaching files to ClickUp tasks
* with automatic method selection based on file source and size.
*/
import { clickUpServices } from '../../services/shared.js';
import { validateTaskIdentification } from './utilities.js';
import { sponsorService } from '../../utils/sponsor-service.js';
import { Logger } from '../../logger.js';
// Use shared services instance
const { task: taskService } = clickUpServices;
// Create a logger instance for attachments
const logger = new Logger('TaskAttachments');
// Session storage for chunked uploads (in-memory for demonstration)
const chunkSessions = new Map();
// Clean up expired sessions periodically
setInterval(() => {
const now = Date.now();
const expired = 24 * 60 * 60 * 1000; // 24 hours
for (const [token, session] of chunkSessions.entries()) {
if (now - session.timestamp > expired) {
chunkSessions.delete(token);
logger.debug(`Cleaned up expired upload session: ${token}`);
}
}
}, 3600 * 1000); // Check every hour
/**
* Single unified tool for attaching files to ClickUp tasks
*/
export const attachTaskFileTool = {
name: "attach_task_file",
description: `Attaches file to task. Use taskId (preferred) or taskName + optional listName. File sources: 1) base64 + filename (≤10MB), 2) URL (http/https), 3) local path (absolute), 4) chunked for large files. WARNING: taskName without listName may match multiple tasks.`,
inputSchema: {
type: "object",
properties: {
taskId: {
type: "string",
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')."
},
taskName: {
type: "string",
description: "Name of the task to attach the file to. The tool will search for tasks with this name across all lists unless listName is specified."
},
listName: {
type: "string",
description: "Optional: Name of list containing the task. Providing this narrows the search to a specific list, improving performance and reducing ambiguity."
},
file_name: {
type: "string",
description: "Name of the file to be attached (include the extension). Required when using file_data."
},
file_data: {
type: "string",
description: "Base64-encoded content of the file (without the data URL prefix)."
},
file_url: {
type: "string",
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."
},
auth_header: {
type: "string",
description: "Optional authorization header to use when downloading from a web URL (ignored for local files)."
},
// Advanced parameters for chunked uploads - usually not needed as chunking is automatic
chunk_index: {
type: "number",
description: "Optional: For advanced usage with large file chunking. The 0-based index of this chunk."
},
chunk_session: {
type: "string",
description: "Optional: For advanced usage with large file chunking. Session identifier from a previous chunk upload."
},
chunk_total: {
type: "number",
description: "Optional: For advanced usage with large file chunking. Total number of chunks expected."
},
chunk_is_last: {
type: "boolean",
description: "Optional: For advanced usage with large file chunking. Whether this is the final chunk."
}
}
}
};
/**
* Handler function for the attachTaskFileTool
*/
async function attachTaskFileHandler(params) {
// Extract common parameters
const { taskId, taskName, listName, customTaskId, file_name, file_data, file_url, auth_header, chunk_total, chunk_size, chunk_index, session_id } = params;
// Validate task identification
const validationResult = validateTaskIdentification({ taskId, taskName, listName, customTaskId }, { useGlobalLookup: true });
if (!validationResult.isValid) {
throw new Error(validationResult.errorMessage);
}
// Validate file source - either file_data or file_url must be provided
if (!file_data && !file_url && !session_id) {
throw new Error("Either file_data, file_url, or session_id must be provided");
}
// Resolve task ID
const result = await taskService.findTasks({
taskId,
taskName,
listName,
allowMultipleMatches: false,
useSmartDisambiguation: true,
includeFullDetails: false
});
if (!result || Array.isArray(result)) {
throw new Error("Task not found");
}
const resolvedTaskId = result.id;
try {
// CASE 1: Chunked upload continuation
if (session_id) {
return await handleChunkUpload(resolvedTaskId, session_id, chunk_index, file_data, chunk_total === chunk_index + 1);
}
// CASE 2: URL-based upload or local file path
if (file_url) {
// Check if it's a local file path
logger.debug(`Checking if path is local: ${file_url}`);
if (file_url.startsWith('/') || /^[A-Za-z]:\\/.test(file_url)) {
logger.debug(`Detected as local path, proceeding to handle: ${file_url}`);
return await handleLocalFileUpload(resolvedTaskId, file_url, file_name);
}
else if (file_url.startsWith('http://') || file_url.startsWith('https://')) {
logger.debug(`Detected as URL, proceeding with URL upload: ${file_url}`);
return await handleUrlUpload(resolvedTaskId, file_url, file_name, auth_header);
}
else {
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://)`);
}
}
// CASE 3: Base64 upload (with automatic chunking for large files)
if (file_data) {
if (!file_name) {
throw new Error("file_name is required when using file_data");
}
// Check if we need to use chunking (file > 10MB)
const fileBuffer = Buffer.from(file_data, 'base64');
const fileSize = fileBuffer.length;
if (fileSize > 10 * 1024 * 1024) {
// For large files, start chunked upload process
return await startChunkedUpload(resolvedTaskId, file_name, fileBuffer);
}
else {
// For small files, upload directly
return await handleDirectUpload(resolvedTaskId, file_name, fileBuffer);
}
}
throw new Error("Invalid parameters: Unable to determine upload method");
}
catch (error) {
logger.error(`Error attaching file to task:`, error);
throw error;
}
}
/**
* Handle direct upload for small files
*/
async function handleDirectUpload(taskId, fileName, fileBuffer) {
try {
// Call service method
const result = await taskService.uploadTaskAttachment(taskId, fileBuffer, fileName);
return {
success: true,
message: `File "${fileName}" successfully attached to task ${taskId}`,
attachment: result
};
}
catch (error) {
throw new Error(`Failed to upload file: ${error.message}`);
}
}
/**
* Handle URL-based upload
*/
async function handleUrlUpload(taskId, fileUrl, fileName, authHeader) {
try {
// Extract filename from URL if not provided
const extractedFileName = fileName || new URL(fileUrl).pathname.split('/').pop() || 'downloaded-file';
// Call service method
const result = await taskService.uploadTaskAttachmentFromUrl(taskId, fileUrl, extractedFileName, authHeader);
return {
success: true,
message: `File from "${fileUrl}" successfully attached to task ${taskId}`,
attachment: result
};
}
catch (error) {
if (error.message === 'Invalid URL') {
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://`);
}
throw new Error(`Failed to upload file from URL: ${error.message}`);
}
}
/**
* Start a chunked upload process for large files
*/
async function startChunkedUpload(taskId, fileName, fileBuffer) {
// Generate a session token
const sessionToken = `chunk_session_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
// Store the file in chunks (for demonstration - in production would store chunk info only)
// Split the file into chunks for storage
const chunkSize = 5 * 1024 * 1024; // 5MB chunks
const chunksMap = new Map();
for (let i = 0; i < fileBuffer.length; i += chunkSize) {
const chunk = fileBuffer.slice(i, i + chunkSize);
chunksMap.set(Math.floor(i / chunkSize), chunk);
}
// Create a new session
chunkSessions.set(sessionToken, {
taskId,
fileName,
fileSize: fileBuffer.length,
chunks: chunksMap,
timestamp: Date.now()
});
// Return initial chunk
return {
success: true,
message: `Large file detected. Chunked upload initialized for "${fileName}" (${fileBuffer.length} bytes)`,
chunk_session: sessionToken,
chunks_total: chunksMap.size,
chunk_uploaded: 1,
attachment: null,
details: {
taskId,
fileName,
fileSize: fileBuffer.length,
chunkCount: chunksMap.size,
progress: Math.round((1 / chunksMap.size) * 100)
}
};
}
/**
* Handle chunk upload as part of a multi-chunk process
*/
async function handleChunkUpload(taskId, sessionToken, chunkIndex, chunkData, isLastChunk) {
// Verify session exists
const session = chunkSessions.get(sessionToken);
if (!session) {
throw new Error("Upload session not found or expired");
}
// If this is the last chunk or all chunks are uploaded, finalize the upload
if (isLastChunk || (session.chunks.size === 1 && chunkIndex === undefined)) {
// Combine all chunks
const fileData = Buffer.allocUnsafe(session.fileSize);
let offset = 0;
// Sort chunks by index
const sortedChunks = Array.from(session.chunks.entries())
.sort((a, b) => a[0] - b[0]);
for (const entry of sortedChunks) {
const [index, chunk] = entry;
chunk.copy(fileData, offset);
offset += chunk.length;
}
try {
// Call service method
const result = await taskService.uploadTaskAttachment(session.taskId, fileData, session.fileName);
// Clean up the session
chunkSessions.delete(sessionToken);
return {
success: true,
message: `File "${session.fileName}" successfully attached to task ${session.taskId}`,
attachment: result
};
}
catch (error) {
throw new Error(`Failed to upload file: ${error.message}`);
}
}
// Otherwise handle the current chunk
if (chunkIndex === undefined || chunkData === undefined) {
throw new Error("chunk_index and chunk_data are required for chunk uploads");
}
// Store the chunk
// (In a real implementation, we'd append to a temp file or storage)
session.chunks.delete(chunkIndex); // Remove the chunk if it exists
session.chunks.set(chunkIndex, Buffer.from(chunkData, 'base64'));
return {
success: true,
message: `Chunk ${chunkIndex + 1}/${session.chunks.size} received`,
chunk_session: sessionToken,
chunks_remaining: session.chunks.size - chunkIndex - 1,
details: {
taskId: session.taskId,
fileName: session.fileName,
chunksReceived: chunkIndex + 1,
progress: Math.round(((chunkIndex + 1) / session.chunks.size) * 100)
}
};
}
/**
* Handle local file path upload
*/
async function handleLocalFileUpload(taskId, filePath, fileName) {
try {
// Import fs and path modules
const fs = await import('fs');
const path = await import('path');
logger.debug(`Processing absolute file path: ${filePath}`);
// Normalize the path to prevent directory traversal attacks
const normalizedPath = path.normalize(filePath);
// Check if file exists
if (!fs.existsSync(normalizedPath)) {
throw new Error(`Local file not found: ${normalizedPath}`);
}
// Validate file stats
const stats = fs.statSync(normalizedPath);
if (!stats.isFile()) {
throw new Error(`Path is not a file: ${normalizedPath}`);
}
// Get file name if not provided
const extractedFileName = fileName || path.basename(normalizedPath);
// Read file
const fileBuffer = fs.readFileSync(normalizedPath);
const fileSize = fileBuffer.length;
logger.debug(`Successfully read file: ${extractedFileName} (${fileSize} bytes)`);
// Choose upload method based on file size
if (fileSize > 10 * 1024 * 1024) {
// For large files, start chunked upload process
return await startChunkedUpload(taskId, extractedFileName, fileBuffer);
}
else {
// For small files, upload directly
return await handleDirectUpload(taskId, extractedFileName, fileBuffer);
}
}
catch (error) {
if (error.message.includes('ENOENT')) {
throw new Error(`Failed to upload local file: Local file not found: ${filePath}. Make sure the file exists and the path is absolute.`);
}
else if (error.message.includes('EACCES')) {
throw new Error(`Failed to upload local file: Permission denied accessing: ${filePath}. Check file permissions.`);
}
throw new Error(`Failed to upload local file: ${error.message}`);
}
}
/**
* Creates a wrapped handler function with standard error handling and response formatting
*/
function createHandlerWrapper(handler, formatResponse = (result) => result) {
return async (parameters) => {
try {
const result = await handler(parameters);
return sponsorService.createResponse(formatResponse(result), true);
}
catch (error) {
return sponsorService.createErrorResponse(error, parameters);
}
};
}
export const handleAttachTaskFile = createHandlerWrapper(attachTaskFileHandler);