UNPKG

@hauptsache.net/clickup-mcp

Version:

Search, create, and retrieve tasks, add comments, and track time through natural language commands.

325 lines (324 loc) 14.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.registerTaskToolsRead = registerTaskToolsRead; exports.generateTaskMetadata = generateTaskMetadata; const zod_1 = require("zod"); const clickup_text_1 = require("../clickup-text"); const config_1 = require("../shared/config"); const utils_1 = require("../shared/utils"); const image_processing_1 = require("../shared/image-processing"); // Read-specific utility functions function registerTaskToolsRead(server, userData) { server.tool("getTaskById", [ "Get a ClickUp task with images and comments by ID.", "Always use this URL when referencing tasks in conversations or sharing with others.", "The response provides complete context including task details, comments, and status history." ].join("\n"), { id: zod_1.z .string() .min(6) .max(9) .refine(val => (0, utils_1.isTaskId)(val), { message: "Task ID must be 6-9 alphanumeric characters only" }) .describe(`The 6-9 character ID of the task to get without a prefix like "#", "CU-" or "https://app.clickup.com/t/"`), }, { readOnlyHint: true }, async ({ id }) => { // 1. Load base task content, comment events, and status change events in parallel const [taskDetailContentBlocks, commentEvents, statusChangeEvents] = await Promise.all([ loadTaskContent(id), // Returns Promise<ContentBlock[]> loadTaskComments(id), // Returns Promise<DatedContentEvent[]> loadTimeInStatusHistory(id), // Returns Promise<DatedContentEvent[]> ]); // 2. Combine comment and status change events const allDatedEvents = [...commentEvents, ...statusChangeEvents]; // 3. Sort all dated events chronologically allDatedEvents.sort((a, b) => { const dateA = a.date ? parseInt(a.date) : 0; const dateB = b.date ? parseInt(b.date) : 0; return dateA - dateB; }); // 4. Flatten sorted events into a single ContentBlock stream let processedEventBlocks = []; for (const event of allDatedEvents) { processedEventBlocks.push(...event.contentBlocks); } // 5. Combine task details with processed event blocks const allContentBlocks = [...taskDetailContentBlocks, ...processedEventBlocks]; // 6. Download images with smart size limiting const limitedContent = await (0, image_processing_1.downloadImages)(allContentBlocks); return { content: limitedContent, }; }); } /** * Fetch time entries for a specific task (all time, not date-limited for detail view) */ async function fetchTaskTimeEntries(taskId) { try { // Get all team members for assignee filter const teamMembers = await (0, utils_1.getAllTeamMembers)(); const params = new URLSearchParams({ task_id: taskId, include_location_names: 'true', start_date: '0', // overwrite the default 30 days }); if (teamMembers.length > 0) { params.append('assignee', teamMembers.join(',')); } const response = await fetch(`https://api.clickup.com/api/v2/team/${config_1.CONFIG.teamId}/time_entries?${params}`, { headers: { Authorization: config_1.CONFIG.apiKey }, }); if (!response.ok) { console.error(`Error fetching time entries for task ${taskId}: ${response.status} ${response.statusText}`); return []; } const data = await response.json(); return data.data || []; } catch (error) { console.error('Error fetching task time entries:', error); return []; } } async function loadTaskContent(taskId) { 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 } }); const task = await response.json(); const [taskMetadata, content] = await Promise.all([ // Create the task metadata block using the helper functions (async () => { const timeEntries = await fetchTaskTimeEntries(task.id); return await generateTaskMetadata(task, timeEntries, true); })(), // process markdown and download images (0, clickup_text_1.convertMarkdownToToolCallResult)(task.markdown_description || "", task.attachments || []), ]); return [taskMetadata, ...content]; } async function loadTaskComments(id) { const response = await fetch(`https://api.clickup.com/api/v2/task/${id}/comment?start_date=0`, // Ensure all comments are fetched { headers: { Authorization: config_1.CONFIG.apiKey } }); if (!response.ok) { console.error(`Error fetching comments for task ${id}: ${response.status} ${response.statusText}`); return []; } const commentsData = await response.json(); if (!commentsData.comments || !Array.isArray(commentsData.comments)) { console.error(`Unexpected comment data structure for task ${id}`); return []; } const commentEvents = await Promise.all(commentsData.comments.map(async (comment) => { const headerBlock = { type: "text", text: `Comment by ${comment.user.username} on ${timestampToIso(comment.date)}:`, }; const commentBodyBlocks = await (0, clickup_text_1.convertClickUpTextItemsToToolCallResult)(comment.comment); return { date: comment.date, // String timestamp from ClickUp for sorting contentBlocks: [headerBlock, ...commentBodyBlocks], }; })); return commentEvents; } async function loadTimeInStatusHistory(taskId) { const url = `https://api.clickup.com/api/v2/task/${taskId}/time_in_status`; try { const response = await fetch(url, { headers: { Authorization: config_1.CONFIG.apiKey } }); if (!response.ok) { console.error(`Error fetching time in status for task ${taskId}: ${response.status} ${response.statusText}`); return []; } // Using 'any' for less strict typing as per user preference, but keeping structure for clarity const data = await response.json(); const events = []; const processStatusEntry = (entry) => { if (!entry || !entry.total_time || !entry.total_time.since || !entry.status) return null; return { date: entry.total_time.since, contentBlocks: [{ type: "text", text: `Status set to '${entry.status}' on ${timestampToIso(entry.total_time.since)}`, }], }; }; if (data.status_history && Array.isArray(data.status_history)) { data.status_history.forEach((historyEntry) => { const event = processStatusEntry(historyEntry); if (event) events.push(event); }); } if (data.current_status) { const event = processStatusEntry(data.current_status); // Ensure current_status is only added if it's distinct or more recent than the last history item. // The deduplication logic below handles if it's the same as the last history entry. if (event) events.push(event); } // Deduplicate events based on date and status name to avoid adding current_status if it's identical to the last history entry const uniqueEvents = Array.from(new Map(events.map(event => { const firstBlock = event.contentBlocks[0]; const textKey = firstBlock && 'text' in firstBlock ? firstBlock.text : 'unknown'; return [`${event.date}-${textKey}`, event]; })).values()); return uniqueEvents; } catch (error) { console.error(`Exception fetching time in status for task ${taskId}:`, error); return []; } } /** * Formats timestamp to ISO string with local timezone (not UTC) */ function timestampToIso(timestamp) { const date = new Date(+timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); // Calculate timezone offset const offset = date.getTimezoneOffset(); const offsetHours = Math.floor(Math.abs(offset) / 60); const offsetMinutes = Math.abs(offset) % 60; const sign = offset <= 0 ? '+' : '-'; const timezoneOffset = sign + String(offsetHours).padStart(2, '0') + ':' + String(offsetMinutes).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}${timezoneOffset}`; } /** * Helper function to filter and format time entries for a specific task */ function filterTaskTimeEntries(taskId, timeEntries) { if (!timeEntries || timeEntries.length === 0) { return null; } // Filter entries for this specific task const taskEntries = timeEntries.filter((entry) => entry.task?.id === taskId); if (taskEntries.length === 0) { return null; } // Group time entries by user (same logic as original getTaskTimeEntries) const timeByUser = new Map(); taskEntries.forEach((entry) => { const username = entry.user?.username || 'Unknown User'; const currentTime = timeByUser.get(username) || 0; const entryDurationMs = parseInt(entry.duration) || 0; timeByUser.set(username, currentTime + entryDurationMs); }); // Format results (same logic as original) const userTimeEntries = []; for (const [username, totalMs] of timeByUser.entries()) { const hours = totalMs / (1000 * 60 * 60); const displayHours = Math.floor(hours); const displayMinutes = Math.round((hours - displayHours) * 60); const timeDisplay = displayHours > 0 ? `${displayHours}h ${displayMinutes}m` : `${displayMinutes}m`; userTimeEntries.push(`${username}: ${timeDisplay}`); } return userTimeEntries.length > 0 ? userTimeEntries.join(', ') : null; } /** * Helper function to generate consistent task metadata */ async function generateTaskMetadata(task, timeEntries, isDetailView = false) { let spaceName = task.space?.name || 'Unknown Space'; let spaceIdForDisplay = task.space?.id || 'N/A'; if (spaceName === 'Unknown Space' && task.space?.id) { const spaceDetails = await (0, utils_1.getSpaceDetails)(task.space.id); if (spaceDetails && spaceDetails.name) { spaceName = spaceDetails.name; } } const metadataLines = [ `task_id: ${task.id}`, `task_url: ${task.url}`, `name: ${task.name}`, `status: ${task.status.status}`, `date_created: ${timestampToIso(task.date_created)}`, `date_updated: ${timestampToIso(task.date_updated)}`, `creator: ${task.creator.username} (${task.creator.id})`, `assignee: ${task.assignees.map((a) => `${a.username} (${a.id})`).join(', ')}`, `list: ${task.list.name} (${task.list.id})`, `space: ${spaceName} (${spaceIdForDisplay})`, ]; // Add priority if it exists if (task.priority !== undefined && task.priority !== null) { const priorityName = task.priority.priority || 'none'; metadataLines.push(`priority: ${priorityName}`); } // Add due date if it exists if (task.due_date) { metadataLines.push(`due_date: ${timestampToIso(task.due_date)}`); } // Add start date if it exists if (task.start_date) { metadataLines.push(`start_date: ${timestampToIso(task.start_date)}`); } // Add time estimate if it exists if (task.time_estimate) { const hours = Math.floor(task.time_estimate / 3600000); const minutes = Math.floor((task.time_estimate % 3600000) / 60000); metadataLines.push(`time_estimate: ${hours}h ${minutes}m`); } // Add time booked (tracked time entries) - only if timeEntries provided if (timeEntries) { const timeBooked = filterTaskTimeEntries(task.id, timeEntries); if (timeBooked) { const disclaimer = isDetailView ? "" : " (last 30 days)"; metadataLines.push(`time_booked${disclaimer}: ${timeBooked}`); } } // Add tags if they exist if (task.tags && task.tags.length > 0) { metadataLines.push(`tags: ${task.tags.map((t) => t.name).join(', ')}`); } // Add watchers if they exist if (task.watchers && task.watchers.length > 0) { metadataLines.push(`watchers: ${task.watchers.map((w) => w.username).join(', ')}`); } // Add parent task information if it exists if (typeof task.parent === "string") { metadataLines.push(`parent_task_id: ${task.parent}`); } // Add child task information if it exists if (task.subtasks && task.subtasks.length > 0) { metadataLines.push(`child_task_ids: ${task.subtasks.map((st) => st.id).join(', ')}`); } // Add archived status if true if (task.archived) { metadataLines.push(`archived: true`); } // Add custom fields if they exist if (task.custom_fields && task.custom_fields.length > 0) { task.custom_fields.forEach((field) => { if (field.value !== undefined && field.value !== null && field.value !== '') { const fieldName = field.name.toLowerCase().replace(/\s+/g, '_'); let fieldValue = field.value; // Handle different custom field types if (field.type === 'drop_down' && typeof field.value === 'number') { // For dropdown fields, find the selected option const selectedOption = field.type_config?.options?.find((opt) => opt.orderindex === field.value); fieldValue = selectedOption?.name || field.value; } else if (Array.isArray(field.value)) { // For multi-select or array values fieldValue = field.value.map((v) => v.name || v).join(', '); } else if (typeof field.value === 'object') { // For object values (like users), extract meaningful data fieldValue = field.value.username || field.value.name || JSON.stringify(field.value); } metadataLines.push(`custom_${fieldName}: ${fieldValue}`); } }); } return { type: "text", text: metadataLines.join("\n"), }; }