UNPKG

@hauptsache.net/clickup-mcp

Version:

Transform your AI assistant into a powerful ClickUp integration for both agentic coding and productivity management. Enables seamless task context sharing, intelligent search, time tracking, and complete project management workflows.

332 lines (331 loc) 16.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.registerTimeToolsRead = registerTimeToolsRead; exports.registerTimeToolsWrite = registerTimeToolsWrite; const zod_1 = require("zod"); const config_1 = require("../shared/config"); const utils_1 = require("../shared/utils"); /** * Converts ISO date string to Unix timestamp in milliseconds */ function isoToTimestamp(isoString) { return new Date(isoString).getTime(); } /** * 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'); const seconds = String(date.getSeconds()).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}:${seconds}${timezoneOffset}`; } /** * Formats duration in milliseconds to human readable format */ function formatDuration(durationMs) { const hours = durationMs / (1000 * 60 * 60); const displayHours = Math.floor(hours); const displayMinutes = Math.round((hours - displayHours) * 60); return displayHours > 0 ? `${displayHours}h ${displayMinutes}m` : `${displayMinutes}m`; } /** * Formats timestamp to simple date and time for entry display */ function formatEntryTime(timestamp) { const date = new Date(timestamp); 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'); return `${date.getFullYear()}-${month}-${day} ${hours}:${minutes}`; } function registerTimeToolsRead(server) { server.tool("getTimeEntries", "Gets time entries for a specific task or all user's time entries. Returns last 30 days by default if no dates specified.", { task_id: zod_1.z.string().min(6).max(9).optional().describe("Optional 6-9 character task ID to filter entries. If not provided, returns all user's time entries."), start_date: zod_1.z.string().optional().describe("Optional start date filter as ISO date string (e.g., '2024-10-06T00:00:00+02:00'). Defaults to 30 days ago."), end_date: zod_1.z.string().optional().describe("Optional end date filter as ISO date string (e.g., '2024-10-06T23:59:59+02:00'). Defaults to current date."), list_id: zod_1.z.string().optional().describe("Optional single list ID to filter time entries by a specific list"), space_id: zod_1.z.string().optional().describe("Optional single space ID to filter time entries by a specific space"), include_all_users: zod_1.z.boolean().optional().describe("Optional flag to include time entries from all team members (default: false, only current user)") }, async ({ task_id, start_date, end_date, list_id, space_id, include_all_users }) => { try { // Build query parameters const params = new URLSearchParams(); if (task_id) { params.append('task_id', task_id); } if (start_date) { params.append('start_date', isoToTimestamp(start_date).toString()); } if (end_date) { params.append('end_date', isoToTimestamp(end_date).toString()); } // Add single list_id or space_id filter (not both) if (list_id) { params.append('list_id', list_id); } else if (space_id) { params.append('space_id', space_id); } // Always include location names to get list information params.append('include_location_names', 'true'); // Handle include_all_users by fetching all team members and adding them as assignees filter // Note: This only works for Workspace Owners/Admins if (include_all_users) { try { const teamMembers = await (0, utils_1.getAllTeamMembers)(); if (teamMembers.length > 0) { params.append('assignee', teamMembers.join(',')); } } catch (error) { console.error('Warning: Could not fetch all team members. This feature requires Workspace Owner/Admin permissions.'); // Continue without all users - will only show current user's entries } } 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) { throw new Error(`Error fetching time entries: ${response.status} ${response.statusText}`); } const data = await response.json(); return processTimeEntriesData(data, task_id, start_date, end_date, include_all_users); } catch (error) { console.error('Error fetching time entries:', error); return { content: [ { type: "text", text: `Error fetching time entries: ${error instanceof Error ? error.message : 'Unknown error'}`, }, ], }; } }); } /** * Process the time entries data and return formatted hierarchical output */ function processTimeEntriesData(data, task_id, start_date, end_date, include_all_users) { if (!data.data || !Array.isArray(data.data)) { const noEntriesMsg = task_id ? `No time entries found for task ${task_id}.` : 'No time entries found.'; return { content: [{ type: "text", text: noEntriesMsg }], }; } const filteredEntries = data.data; // Create hierarchical structure: List → Task → User → Individual entries const hierarchy = new Map(); let totalTimeMs = 0; filteredEntries.forEach((entry) => { const taskId = entry.task?.id || 'no-task'; // Use location names from include_location_names parameter const listId = entry.task_location?.list_id || 'no-list'; const listName = entry.task_location?.list_name || 'No List'; const taskName = entry.task?.name || 'No Task'; const userId = entry.user?.id || 'no-user'; const userName = entry.user?.username || 'Unknown User'; // Handle running timers (negative duration) let entryDurationMs = parseInt(entry.duration) || 0; const isRunningTimer = entryDurationMs < 0; if (isRunningTimer) { // For running timers, calculate current duration from start time entryDurationMs = Date.now() - parseInt(entry.start); } totalTimeMs += entryDurationMs; // Initialize list level if (!hierarchy.has(listId)) { hierarchy.set(listId, { name: listName, id: listId, totalTime: 0, tasks: new Map() }); } const listData = hierarchy.get(listId); listData.totalTime += entryDurationMs; // Initialize task level if (!listData.tasks.has(taskId)) { listData.tasks.set(taskId, { name: taskName, id: taskId, totalTime: 0, users: new Map() }); } const taskData = listData.tasks.get(taskId); taskData.totalTime += entryDurationMs; // Initialize user level if (!taskData.users.has(userId)) { taskData.users.set(userId, { name: userName, id: userId, totalTime: 0, entries: [] }); } const userData = taskData.users.get(userId); userData.totalTime += entryDurationMs; userData.entries.push(entry); }); // Count total tasks across all lists let totalTasks = 0; for (const [listId, listData] of hierarchy.entries()) { totalTasks += listData.tasks.size; } // Format the hierarchical output const outputLines = []; // Header with date range and total const dateRange = start_date && end_date ? ` (${start_date.split('T')[0]} to ${end_date.split('T')[0]})` : start_date ? ` (from ${start_date.split('T')[0]})` : end_date ? ` (until ${end_date.split('T')[0]})` : ''; outputLines.push(`Time Entries Summary${dateRange}`); outputLines.push(`Total: ${formatDuration(totalTimeMs)}`); outputLines.push(''); // Check if result is too large (>100 tasks) const TASK_LIMIT = 100; const isTruncated = totalTasks > TASK_LIMIT; if (isTruncated) { // Show only list-level summary outputLines.push(`⚠️ Large result detected (${totalTasks} tasks). Showing summary only.`); outputLines.push(`💡 Use list_id, space_id, or date filters for detailed view.`); outputLines.push(''); for (const [listId, listData] of hierarchy.entries()) { const taskCount = listData.tasks.size; outputLines.push(`📋 ${listData.name} (List: ${listId}) - ${formatDuration(listData.totalTime)} across ${taskCount} task${taskCount === 1 ? '' : 's'}`); } } else { // Show full hierarchical display for (const [listId, listData] of hierarchy.entries()) { outputLines.push(`📋 ${listData.name} (List: ${listId}) - ${formatDuration(listData.totalTime)}`); for (const [taskId, taskData] of listData.tasks.entries()) { outputLines.push(` ├─ 🎯 ${taskData.name} (Task: ${taskId}) - ${formatDuration(taskData.totalTime)}`); const userEntries = Array.from(taskData.users.entries()); for (let userIndex = 0; userIndex < userEntries.length; userIndex++) { const [userId, userData] = userEntries[userIndex]; const isLastUser = userIndex === userEntries.length - 1; const userPrefix = isLastUser ? ' └─' : ' ├─'; outputLines.push(`${userPrefix} ${userData.name}: ${formatDuration(userData.totalTime)}`); // Add individual entries userData.entries.forEach((entry, entryIndex) => { const isLastEntry = entryIndex === userData.entries.length - 1; const entryPrefix = isLastUser ? (isLastEntry ? ' └─' : ' ├─') : (isLastEntry ? ' │ └─' : ' │ ├─'); const entryStart = formatEntryTime(parseInt(entry.start)); // Handle running timers const rawDuration = parseInt(entry.duration) || 0; const isRunningTimer = rawDuration < 0; let entryDuration; if (isRunningTimer) { const currentDuration = Date.now() - parseInt(entry.start); entryDuration = `${formatDuration(currentDuration)} (running)`; } else { entryDuration = formatDuration(rawDuration); } outputLines.push(`${entryPrefix} ${entryStart} - ${entryDuration}`); }); } } outputLines.push(''); } } return { content: [ { type: "text", text: outputLines.join('\n') } ], }; } function registerTimeToolsWrite(server) { server.tool("createTimeEntry", [ "Creates a time entry (books time) on a task for the current user.", "Use decimal hours (e.g., 0.25 for 15 minutes, 0.5 for 30 minutes, 2.5 for 2.5 hours).", "IMPORTANT: Before booking time, check the task's status - booking time on tasks in 'backlog', 'closed', or similar inactive states usually doesn't make sense.", "Suggest moving the task to an active status like 'in progress' first." ].join("\n"), { task_id: zod_1.z.string().min(6).max(9).describe("The 6-9 character task ID to book time against"), hours: zod_1.z.number().min(0.01).max(24).describe("Hours to book (decimal format, e.g., 0.25 = 15min, 1.5 = 1h 30min)"), description: zod_1.z.string().optional().describe("Optional description for the time entry"), start_time: zod_1.z.string().optional().describe("Optional start time as ISO date string (e.g., '2024-10-06T09:00:00+02:00', defaults to current time)") }, async ({ task_id, hours, description, start_time }) => { try { // Convert hours to milliseconds (ClickUp API uses milliseconds) const durationMs = Math.round(hours * 60 * 60 * 1000); // Convert ISO date to timestamp if provided, otherwise use current time const startTimeMs = start_time ? isoToTimestamp(start_time) : Date.now(); const requestBody = { tid: task_id, start: startTimeMs, duration: durationMs, ...(description && { description }) }; const response = await fetch(`https://api.clickup.com/api/v2/team/${config_1.CONFIG.teamId}/time_entries`, { method: 'POST', headers: { Authorization: config_1.CONFIG.apiKey, 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(`Error creating time entry: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`); } const timeEntry = await response.json(); // Format duration for display const displayHours = Math.floor(hours); const displayMinutes = Math.round((hours - displayHours) * 60); const durationDisplay = displayHours > 0 ? `${displayHours}h ${displayMinutes}m` : `${displayMinutes}m`; return { content: [ { type: "text", text: [ `Time entry created successfully!`, `entry_id: ${timeEntry.data?.id || 'N/A'}`, `task_id: ${task_id}`, `duration: ${durationDisplay}`, `start_time: ${timestampToIso(startTimeMs)}`, ...(description ? [`description: ${description}`] : []), `user: ${timeEntry.data?.user?.username || 'Current user'}` ].join('\n') } ], }; } catch (error) { console.error('Error creating time entry:', error); return { content: [ { type: "text", text: `Error creating time entry: ${error instanceof Error ? error.message : 'Unknown error'}`, }, ], }; } }); }