UNPKG

@taazkareem/clickup-mcp-server

Version:

ClickUp MCP Server - Integrate ClickUp tasks with AI through Model Context Protocol

576 lines (575 loc) 21.9 kB
/** * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com> * SPDX-License-Identifier: MIT * * Task time tracking tools * * This module provides tools for time tracking operations on ClickUp tasks: * - Get time entries for a task * - Start time tracking on a task * - Stop time tracking * - Add a manual time entry * - Delete a time entry */ import { timeTrackingService } from "../../services/shared.js"; import { getTaskId } from "./utilities.js"; import { Logger } from "../../logger.js"; import { parseDueDate } from "../../utils/date-utils.js"; import { sponsorService } from "../../utils/sponsor-service.js"; // Logger instance const logger = new Logger('TimeTrackingTools'); /** * Tool definition for getting time entries */ export const getTaskTimeEntriesTool = { name: "get_task_time_entries", description: "Gets all time entries for a task with filtering options. Use taskId (preferred) or taskName + optional listName. Returns all tracked time with user info, descriptions, tags, start/end times, and durations.", inputSchema: { type: "object", properties: { taskId: { type: "string", description: "ID of the task to get time entries for. Works with both regular task IDs and custom IDs." }, taskName: { type: "string", description: "Name of the task to get time entries for. When using this parameter, it's recommended to also provide listName." }, listName: { type: "string", description: "Name of the list containing the task. Helps find the right task when using taskName." }, startDate: { type: "string", description: "Optional start date filter. Supports Unix timestamps (in milliseconds) and natural language expressions like 'yesterday', 'last week', etc." }, endDate: { type: "string", description: "Optional end date filter. Supports Unix timestamps (in milliseconds) and natural language expressions." } } } }; /** * Tool definition for starting time tracking */ export const startTimeTrackingTool = { name: "start_time_tracking", description: "Starts time tracking on a task. Use taskId (preferred) or taskName + optional listName. Optional fields: description, billable status, and tags. Only one timer can be running at a time.", inputSchema: { type: "object", properties: { taskId: { type: "string", description: "ID of the task to start tracking time on. Works with both regular task IDs and custom IDs." }, taskName: { type: "string", description: "Name of the task to start tracking time on. When using this parameter, it's recommended to also provide listName." }, listName: { type: "string", description: "Name of the list containing the task. Helps find the right task when using taskName." }, description: { type: "string", description: "Optional description for the time entry." }, billable: { type: "boolean", description: "Whether this time is billable. Default is workspace setting." }, tags: { type: "array", items: { type: "string" }, description: "Optional array of tag names to assign to the time entry." } } } }; /** * Tool definition for stopping time tracking */ export const stopTimeTrackingTool = { name: "stop_time_tracking", description: "Stops the currently running time tracker. Optional fields: description and tags. Returns the completed time entry details.", inputSchema: { type: "object", properties: { description: { type: "string", description: "Optional description to update or add to the time entry." }, tags: { type: "array", items: { type: "string" }, description: "Optional array of tag names to assign to the time entry." } } } }; /** * Tool definition for adding a manual time entry */ export const addTimeEntryTool = { name: "add_time_entry", description: "Adds a manual time entry to a task. Use taskId (preferred) or taskName + optional listName. Required: start time, duration. Optional: description, billable, tags.", inputSchema: { type: "object", properties: { taskId: { type: "string", description: "ID of the task to add time entry to. Works with both regular task IDs and custom IDs." }, taskName: { type: "string", description: "Name of the task to add time entry to. When using this parameter, it's recommended to also provide listName." }, listName: { type: "string", description: "Name of the list containing the task. Helps find the right task when using taskName." }, start: { type: "string", description: "Start time for the entry. Supports Unix timestamps (in milliseconds) and natural language expressions like '2 hours ago', 'yesterday 9am', etc." }, duration: { type: "string", description: "Duration of the time entry. Format as 'Xh Ym' (e.g., '1h 30m') or just minutes (e.g., '90m')." }, description: { type: "string", description: "Optional description for the time entry." }, billable: { type: "boolean", description: "Whether this time is billable. Default is workspace setting." }, tags: { type: "array", items: { type: "string" }, description: "Optional array of tag names to assign to the time entry." } }, required: ["start", "duration"] } }; /** * Tool definition for deleting a time entry */ export const deleteTimeEntryTool = { name: "delete_time_entry", description: "Deletes a time entry. Required: time entry ID.", inputSchema: { type: "object", properties: { timeEntryId: { type: "string", description: "ID of the time entry to delete." } }, required: ["timeEntryId"] } }; /** * Tool definition for getting current time entry */ export const getCurrentTimeEntryTool = { name: "get_current_time_entry", description: "Gets the currently running time entry, if any. No parameters needed.", inputSchema: { type: "object", properties: {} } }; /** * Handle get task time entries tool */ export async function handleGetTaskTimeEntries(params) { logger.info("Handling request to get task time entries", params); try { // Resolve task ID const taskId = await getTaskId(params.taskId, params.taskName, params.listName); if (!taskId) { return sponsorService.createErrorResponse("Task not found. Please provide a valid taskId or taskName + listName combination."); } // Parse date filters let startDate; let endDate; if (params.startDate) { startDate = parseDueDate(params.startDate); } if (params.endDate) { endDate = parseDueDate(params.endDate); } // Get time entries const result = await timeTrackingService.getTimeEntries(taskId, startDate, endDate); if (!result.success) { return sponsorService.createErrorResponse(result.error?.message || "Failed to get time entries"); } const timeEntries = result.data || []; // Format the response return sponsorService.createResponse({ success: true, count: timeEntries.length, time_entries: timeEntries.map(entry => ({ id: entry.id, description: entry.description || "", start: entry.start, end: entry.end, duration: formatDuration(entry.duration || 0), duration_ms: entry.duration || 0, billable: entry.billable || false, tags: entry.tags || [], user: entry.user ? { id: entry.user.id, username: entry.user.username } : null, task: entry.task ? { id: entry.task.id, name: entry.task.name, status: entry.task.status?.status || "Unknown" } : null })) }, true); } catch (error) { logger.error("Error getting task time entries", error); return sponsorService.createErrorResponse(error.message || "An unknown error occurred"); } } /** * Handle start time tracking tool */ export async function handleStartTimeTracking(params) { logger.info("Handling request to start time tracking", params); try { // Resolve task ID const taskId = await getTaskId(params.taskId, params.taskName, params.listName); if (!taskId) { return sponsorService.createErrorResponse("Task not found. Please provide a valid taskId or taskName + listName combination."); } // Check for currently running timer const currentTimerResult = await timeTrackingService.getCurrentTimeEntry(); if (currentTimerResult.success && currentTimerResult.data) { return sponsorService.createErrorResponse("A timer is already running. Please stop the current timer before starting a new one.", { timer: { id: currentTimerResult.data.id, task: { id: currentTimerResult.data.task.id, name: currentTimerResult.data.task.name }, start: currentTimerResult.data.start, description: currentTimerResult.data.description } }); } // Prepare request data const requestData = { tid: taskId, description: params.description, billable: params.billable, tags: params.tags }; // Start time tracking const result = await timeTrackingService.startTimeTracking(requestData); if (!result.success) { return sponsorService.createErrorResponse(result.error?.message || "Failed to start time tracking"); } const timeEntry = result.data; if (!timeEntry) { return sponsorService.createErrorResponse("No time entry data returned from API"); } // Format the response return sponsorService.createResponse({ success: true, message: "Time tracking started successfully", time_entry: { id: timeEntry.id, description: timeEntry.description, start: timeEntry.start, end: timeEntry.end, task: { id: timeEntry.task.id, name: timeEntry.task.name }, billable: timeEntry.billable, tags: timeEntry.tags } }, true); } catch (error) { logger.error("Error starting time tracking", error); return sponsorService.createErrorResponse(error.message || "An unknown error occurred"); } } /** * Handle stop time tracking tool */ export async function handleStopTimeTracking(params) { logger.info("Handling request to stop time tracking", params); try { // Check for currently running timer const currentTimerResult = await timeTrackingService.getCurrentTimeEntry(); if (currentTimerResult.success && !currentTimerResult.data) { return sponsorService.createErrorResponse("No timer is currently running. Start a timer before trying to stop it."); } // Prepare request data const requestData = { description: params.description, tags: params.tags }; // Stop time tracking const result = await timeTrackingService.stopTimeTracking(requestData); if (!result.success) { return sponsorService.createErrorResponse(result.error?.message || "Failed to stop time tracking"); } const timeEntry = result.data; if (!timeEntry) { return sponsorService.createErrorResponse("No time entry data returned from API"); } // Format the response return sponsorService.createResponse({ success: true, message: "Time tracking stopped successfully", time_entry: { id: timeEntry.id, description: timeEntry.description, start: timeEntry.start, end: timeEntry.end, duration: formatDuration(timeEntry.duration), duration_ms: timeEntry.duration, task: { id: timeEntry.task.id, name: timeEntry.task.name }, billable: timeEntry.billable, tags: timeEntry.tags } }, true); } catch (error) { logger.error("Error stopping time tracking", error); return sponsorService.createErrorResponse(error.message || "An unknown error occurred"); } } /** * Handle add time entry tool */ export async function handleAddTimeEntry(params) { logger.info("Handling request to add time entry", params); try { // Resolve task ID const taskId = await getTaskId(params.taskId, params.taskName, params.listName); if (!taskId) { return sponsorService.createErrorResponse("Task not found. Please provide a valid taskId or taskName + listName combination."); } // Parse start time const startTime = parseDueDate(params.start); if (!startTime) { return sponsorService.createErrorResponse("Invalid start time format. Use a Unix timestamp (in milliseconds) or a natural language date string."); } // Parse duration const durationMs = parseDuration(params.duration); if (durationMs === 0) { return sponsorService.createErrorResponse("Invalid duration format. Use 'Xh Ym' format (e.g., '1h 30m') or just minutes (e.g., '90m')."); } // Prepare request data const requestData = { tid: taskId, start: startTime, duration: durationMs, description: params.description, billable: params.billable, tags: params.tags }; // Add time entry const result = await timeTrackingService.addTimeEntry(requestData); if (!result.success) { return sponsorService.createErrorResponse(result.error?.message || "Failed to add time entry"); } const timeEntry = result.data; if (!timeEntry) { return sponsorService.createErrorResponse("No time entry data returned from API"); } // Format the response return sponsorService.createResponse({ success: true, message: "Time entry added successfully", time_entry: { id: timeEntry.id, description: timeEntry.description, start: timeEntry.start, end: timeEntry.end, duration: formatDuration(timeEntry.duration), duration_ms: timeEntry.duration, task: { id: timeEntry.task.id, name: timeEntry.task.name }, billable: timeEntry.billable, tags: timeEntry.tags } }, true); } catch (error) { logger.error("Error adding time entry", error); return sponsorService.createErrorResponse(error.message || "An unknown error occurred"); } } /** * Handle delete time entry tool */ export async function handleDeleteTimeEntry(params) { logger.info("Handling request to delete time entry", params); try { const { timeEntryId } = params; if (!timeEntryId) { return sponsorService.createErrorResponse("Time entry ID is required."); } // Delete time entry const result = await timeTrackingService.deleteTimeEntry(timeEntryId); if (!result.success) { return sponsorService.createErrorResponse(result.error?.message || "Failed to delete time entry"); } // Format the response return sponsorService.createResponse({ success: true, message: "Time entry deleted successfully." }, true); } catch (error) { logger.error("Error deleting time entry", error); return sponsorService.createErrorResponse(error.message || "An unknown error occurred"); } } /** * Handle get current time entry tool */ export async function handleGetCurrentTimeEntry(params) { logger.info("Handling request to get current time entry"); try { // Get current time entry const result = await timeTrackingService.getCurrentTimeEntry(); if (!result.success) { return sponsorService.createErrorResponse(result.error?.message || "Failed to get current time entry"); } const timeEntry = result.data; // If no timer is running if (!timeEntry) { return sponsorService.createResponse({ success: true, timer_running: false, message: "No timer is currently running." }, true); } // Format the response const elapsedTime = calculateElapsedTime(timeEntry.start); return sponsorService.createResponse({ success: true, timer_running: true, time_entry: { id: timeEntry.id, description: timeEntry.description, start: timeEntry.start, elapsed: formatDuration(elapsedTime), elapsed_ms: elapsedTime, task: { id: timeEntry.task.id, name: timeEntry.task.name }, billable: timeEntry.billable, tags: timeEntry.tags } }, true); } catch (error) { logger.error("Error getting current time entry", error); return sponsorService.createErrorResponse(error.message || "An unknown error occurred"); } } /** * Calculate elapsed time in milliseconds from a start time string to now */ function calculateElapsedTime(startTimeString) { const startTime = new Date(startTimeString).getTime(); const now = Date.now(); return Math.max(0, now - startTime); } /** * Format duration in milliseconds to a human-readable string */ function formatDuration(durationMs) { if (!durationMs) return "0m"; const seconds = Math.floor(durationMs / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; if (hours === 0) { return `${remainingMinutes}m`; } else if (remainingMinutes === 0) { return `${hours}h`; } else { return `${hours}h ${remainingMinutes}m`; } } /** * Parse duration string to milliseconds */ function parseDuration(durationString) { if (!durationString) return 0; // Clean the input and handle potential space issues const cleanDuration = durationString.trim().toLowerCase().replace(/\s+/g, ' '); // Handle simple minute format like "90m" if (/^\d+m$/.test(cleanDuration)) { const minutes = parseInt(cleanDuration.replace('m', ''), 10); return minutes * 60 * 1000; } // Handle simple hour format like "2h" if (/^\d+h$/.test(cleanDuration)) { const hours = parseInt(cleanDuration.replace('h', ''), 10); return hours * 60 * 60 * 1000; } // Handle combined format like "1h 30m" const combinedPattern = /^(\d+)h\s*(?:(\d+)m)?$|^(?:(\d+)h\s*)?(\d+)m$/; const match = cleanDuration.match(combinedPattern); if (match) { const hours = parseInt(match[1] || match[3] || '0', 10); const minutes = parseInt(match[2] || match[4] || '0', 10); return (hours * 60 * 60 + minutes * 60) * 1000; } // Try to parse as just a number of minutes const justMinutes = parseInt(cleanDuration, 10); if (!isNaN(justMinutes)) { return justMinutes * 60 * 1000; } return 0; } // Export all time tracking tools export const timeTrackingTools = [ getTaskTimeEntriesTool, startTimeTrackingTool, stopTimeTrackingTool, addTimeEntryTool, deleteTimeEntryTool, getCurrentTimeEntryTool ]; // Export all time tracking handlers export const timeTrackingHandlers = { get_task_time_entries: handleGetTaskTimeEntries, start_time_tracking: handleStartTimeTracking, stop_time_tracking: handleStopTimeTracking, add_time_entry: handleAddTimeEntry, delete_time_entry: handleDeleteTimeEntry, get_current_time_entry: handleGetCurrentTimeEntry };