Skip to main content
Glama
time-tools.ts15.2 kB
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { CONFIG } from "../shared/config"; import { getAllTeamMembers } from "../shared/utils"; /** * Converts ISO date string to Unix timestamp in milliseconds */ function isoToTimestamp(isoString: string): number { return new Date(isoString).getTime(); } /** * Formats timestamp to ISO string with local timezone (not UTC) */ function timestampToIso(timestamp: number): string { 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: number): string { 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: number): string { 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}`; } export function registerTimeToolsRead(server: McpServer) { 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: 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: 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: 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: z.string().optional().describe("Optional single list ID to filter time entries by a specific list"), space_id: z.string().optional().describe("Optional single space ID to filter time entries by a specific space"), include_all_users: z.boolean().optional().describe("Optional flag to include time entries from all team members (default: false, only current user)") }, { readOnlyHint: true }, 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 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.teamId}/time_entries?${params}`, { headers: { Authorization: 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: any, task_id?: string, start_date?: string, end_date?: string, include_all_users?: boolean) { 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" as const, text: noEntriesMsg }], }; } const filteredEntries = data.data; // Create hierarchical structure: List → Task → User → Individual entries const hierarchy = new Map<string, { name: string; id: string; totalTime: number; tasks: Map<string, { name: string; id: string; totalTime: number; users: Map<string, { name: string; id: string; totalTime: number; entries: any[]; }>; }>; }>(); let totalTimeMs = 0; filteredEntries.forEach((entry: any) => { 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: string[] = []; // 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: any, entryIndex: number) => { 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: string; 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" as const, text: outputLines.join('\n') } ], }; } export function registerTimeToolsWrite(server: McpServer) { 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: z.string().min(6).max(9).describe("The 6-9 character task ID to book time against"), hours: z.number().min(0.01).max(24).describe("Hours to book (decimal format, e.g., 0.25 = 15min, 1.5 = 1h 30min)"), description: z.string().optional().describe("Optional description for the time entry"), start_time: z.string().optional().describe("Optional start time as ISO date string (e.g., '2024-10-06T09:00:00+02:00', defaults to current time)") }, { readOnlyHint: false, destructiveHint: false, idempotentHint: false, }, 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.teamId}/time_entries`, { method: 'POST', headers: { Authorization: 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" as const, 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'}`, }, ], }; } } ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/hauptsacheNet/clickup-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server