UNPKG

@clicktime/mcp-server

Version:

ClickTime MCP Tech Demo for AI agents to interact with ClickTime API

591 lines (589 loc) 23.5 kB
// src/handlers.ts import { ExpenseHandlers } from './expense/expense-handlers.js'; import { SubmissionHandlers } from './submission/submission-handlers.js'; export class ClickTimeToolHandlers { client; expenseHandlers; submissionHandlers; constructor(client, resourceHandlers) { this.client = client; this.expenseHandlers = new ExpenseHandlers(client, resourceHandlers); this.submissionHandlers = new SubmissionHandlers(client); } async handleToolCall(request) { const { name, arguments: args } = request.params; try { switch (name) { // Time Entry Tools case 'add_time_entry': return await this.addTimeEntry(args); case 'get_recent_time_entries': return await this.getRecentTimeEntries(args); case 'update_time_entry': return await this.updateTimeEntry(args); case 'delete_time_entry': return await this.deleteTimeEntry(args); case 'submit_timesheet': return await this.submissionHandlers.submitTimesheet(args); // Project Tools case 'list_my_projects': return await this.listMyProjects(args); case 'get_project_details': return await this.getProjectDetails(args); case 'list_my_tasks': return await this.listMyTasks(args); case 'get_task_details': return await this.getTaskDetails(args); // Time Off Tools case 'create_time_off_request': return await this.createTimeOffRequest(args); case 'get_time_off': return await this.getTimeOff(args); case 'remove_time_off': return await this.removeTimeOff(args); case 'get_time_off_requests': return await this.getTimeOffRequests(args); case 'get_time_off_request_details': return await this.getTimeOffRequestDetails(args); case 'get_time_off_request_actions': return await this.getTimeOffRequestActions(args); case 'get_time_off_balance': return await this.getTimeOffBalance(args); case 'list_time_off_types': return await this.listTimeOffTypes(args); // Expense Tools - Delegated to ExpenseHandlers case 'add_expense_item': return await this.expenseHandlers.addExpenseItem(args); case 'add_expense_from_receipt': return await this.expenseHandlers.addExpenseFromReceipt(args); case 'get_receipt_access_help': return await this.expenseHandlers.getReceiptAccessHelp(args); case 'list_my_expense_types': return await this.expenseHandlers.listMyExpenseTypes(args); case 'list_my_payment_types': return await this.expenseHandlers.listMyPaymentTypes(args); case 'get_my_expense_sheets': return await this.expenseHandlers.getMyExpenseSheets(args); case 'get_my_expense_items': return await this.expenseHandlers.getMyExpenseItems(args); case 'analyze_receipt_image': return await this.expenseHandlers.analyzeReceiptImage(args); case 'create_expense_sheet': return await this.expenseHandlers.createExpenseSheet(args); case 'submit_expense_sheet': return await this.submissionHandlers.submitExpenseSheet(args); // User Tools case 'get_my_profile': return await this.getMyProfile(args); case 'get_version': return await this.getVersion(args); case 'get_submission_status': return await this.submissionHandlers.getSubmissionStatus(args); default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { console.error(`Error handling tool ${name}:`, error); return { content: [ { type: 'text', text: `Error executing ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`, }, ], }; } } async addTimeEntry(args) { const result = await this.client.createTimeEntry({ Date: args.date, Hours: args.hours, JobID: args.jobId, TaskID: args.taskId, Comment: args.comment || '', }); return { content: [ { type: 'text', text: `Time entry created successfully!\nID: ${result.ID}\nDate: ${result.Date}\nHours: ${result.Hours}\nProject: ${result.Job?.Name || 'Unknown'}\nTask: ${result.Task?.Name || 'Unknown'}`, }, ], }; } async getRecentTimeEntries(args) { const params = {}; if (args.startDate) params.StartDate = args.startDate; if (args.endDate) params.EndDate = args.endDate; if (args.limit) params.limit = args.limit; const entries = await this.client.getMyTimeEntries(params); if (!entries.length) { return { content: [ { type: 'text', text: 'No time entries found for the specified criteria.', }, ], }; } const formatted = entries .map((entry) => `Date: ${entry.Date} | Hours: ${entry.Hours} | Project: ${entry.Job?.Name || 'N/A'} | Task: ${entry.Task?.Name || 'N/A'}${entry.Comment ? ` | Comment: ${entry.Comment}` : ''}`) .join('\n'); return { content: [ { type: 'text', text: `Found ${entries.length} time entries:\n\n${formatted}`, }, ], }; } async updateTimeEntry(args) { const updateData = {}; if (args.date) updateData.Date = args.date; if (args.hours !== undefined) updateData.Hours = args.hours; if (args.jobId) updateData.JobID = args.jobId; if (args.taskId) updateData.TaskID = args.taskId; if (args.comment !== undefined) updateData.Comment = args.comment; const result = await this.client.updateTimeEntry(args.entryId, updateData); return { content: [ { type: 'text', text: `Time entry updated successfully!\nID: ${result.ID}\nDate: ${result.Date}\nHours: ${result.Hours}`, }, ], }; } async deleteTimeEntry(args) { await this.client.deleteTimeEntry(args.entryId); return { content: [ { type: 'text', text: `Time entry ${args.entryId} deleted successfully.`, }, ], }; } async listMyProjects(args) { const jobs = await this.client.getMyJobs(); const filtered = args.activeOnly !== false ? jobs.filter((job) => job.IsActive) : jobs; if (!filtered.length) { return { content: [ { type: 'text', text: 'No projects found.', }, ], }; } const formatted = filtered .map((job) => `ID: ${job.ID} | Name: ${job.Name} | Client: ${job.Client?.Name || 'N/A'} | Active: ${job.IsActive}`) .join('\n'); return { content: [ { type: 'text', text: `Found ${filtered.length} projects:\n\n${formatted}`, }, ], }; } async getProjectDetails(args) { const job = await this.client.getJobById(args.jobId); return { content: [ { type: 'text', text: `Project Details:\nID: ${job.ID}\nName: ${job.Name}\nClient: ${job.Client?.Name || 'N/A'}\nActive: ${job.IsActive}\nDescription: ${job.Description || 'N/A'}`, }, ], }; } async listMyTasks(args) { const tasks = await this.client.getMyTasks(); const filtered = args.activeOnly !== false ? tasks.filter((task) => task.IsActive) : tasks; if (!filtered.length) { return { content: [ { type: 'text', text: 'No tasks found.', }, ], }; } const formatted = filtered .map((task) => `ID: ${task.ID} | Name: ${task.Name} | Active: ${task.IsActive}`) .join('\n'); return { content: [ { type: 'text', text: `Found ${filtered.length} tasks:\n\n${formatted}`, }, ], }; } async getTaskDetails(args) { const task = await this.client.getTaskById(args.taskId); return { content: [ { type: 'text', text: `Task Details:\nID: ${task.ID}\nName: ${task.Name}\nActive: ${task.IsActive}\nDescription: ${task.Description || 'N/A'}`, }, ], }; } async createTimeOffRequest(args) { // First, get the time off type details to check if it requires approval const timeOffType = await this.client.getTimeOffBalance(args.timeOffTypeId); if (timeOffType.RequiresApproval === false) { // Use /Me/TimeOff endpoint for non-approval time off // Generate date range from start to end date const dates = this.generateDateRange(args.startDate, args.endDate); const results = []; // Create individual time off entries for each date for (const date of dates) { const result = await this.client.createTimeOff({ Date: date, Hours: args.hoursPerDay || 8, // Default to 8 hours if not specified TimeOffTypeID: args.timeOffTypeId, Notes: args.notes || '', DCAAExplanation: args.dcaaExplanation || undefined, }); results.push(result); } // Include the IDs in the response for potential deletion const entryIds = results.map(r => r.ID).filter(id => id); const idsText = entryIds.length > 0 ? `\nEntry IDs: ${entryIds.join(', ')}` : ''; return { content: [ { type: 'text', text: `Time off entries created successfully!\nDates: ${args.startDate} to ${args.endDate}\nType: ${timeOffType.Name}\nTotal entries: ${results.length}${idsText}`, }, ], }; } else { // Use /Me/TimeOffRequests endpoint for approval-required time off const dates = this.generateDateRange(args.startDate, args.endDate); const result = await this.client.createTimeOffRequest({ TimeOffTypeID: args.timeOffTypeId, Notes: args.notes || '', Dates: dates, }); return { content: [ { type: 'text', text: `Time off request created successfully!\nID: ${result.ID}\nDates: ${args.startDate} to ${args.endDate}\nType: ${timeOffType.Name}\nStatus: ${result.Status || 'Submitted'}`, }, ], }; } } async getTimeOff(args) { const params = {}; if (args.ids) params.ID = args.ids; if (args.timeOffTypeIds) params.TimeOffTypeID = args.timeOffTypeIds; if (args.fromDate) params.FromDate = args.fromDate; if (args.toDate) params.ToDate = args.toDate; if (args.dates) params.Date = args.dates; if (args.limit) params.limit = args.limit; if (args.offset) params.offset = args.offset; const entries = await this.client.getMyTimeOff(params); if (!entries.length) { return { content: [ { type: 'text', text: 'No time off entries found.', }, ], }; } const formatted = entries .map((entry) => `ID: ${entry.ID || 'N/A'} | Date: ${entry.Date} | Hours: ${entry.Hours} | Type: ${entry.TimeOffTypeID}${entry.Notes ? ` | Notes: ${entry.Notes}` : ''}${entry.TimeOffRequestID ? ` | Request ID: ${entry.TimeOffRequestID}` : ''}`) .join('\n'); return { content: [ { type: 'text', text: `Found ${entries.length} time off entries:\n\n${formatted}`, }, ], }; } async getTimeOffRequests(args) { const params = {}; if (args.startDate) params.StartDate = args.startDate; if (args.endDate) params.EndDate = args.endDate; const requests = await this.client.getMyTimeOffRequests(params); if (!requests.length) { return { content: [ { type: 'text', text: 'No time off requests found.', }, ], }; } const formatted = requests .map((req) => `ID: ${req.ID} | Type: ${req.TimeOffTypeID} | Status: ${req.Status || 'Pending'} | Notes: ${req.Notes || 'N/A'}`) .join('\n'); return { content: [ { type: 'text', text: `Found ${requests.length} time off requests:\n\n${formatted}`, }, ], }; } async getTimeOffRequestDetails(args) { const request = await this.client.getTimeOffRequestById(args.requestId); // Format dates if they're detailed objects let datesInfo = ''; if (Array.isArray(request.Dates)) { if (request.Dates.length > 0 && typeof request.Dates[0] === 'object') { // Detailed dates with hours datesInfo = request.Dates .map((d) => ` ${d.Date}: ${d.Hours} hours`) .join('\n'); } else { // Simple date strings datesInfo = ` ${request.Dates.join(', ')}`; } } // Format history if available let historyInfo = ''; if (request.History && request.History.length > 0) { historyInfo = '\n\nHistory:\n' + request.History .map((h) => ` ${h.Date} - ${h.Status} by ${h.ActionByUserName}${h.Comment ? ` (${h.Comment})` : ''}`) .join('\n'); } return { content: [ { type: 'text', text: `Time Off Request Details: ID: ${request.ID} Status: ${request.Status || 'Pending'} Type: ${request.TimeOffType?.Name || request.TimeOffTypeID} Requested By: ${request.RequestedByUser?.Name || 'N/A'} ${request.ApprovalByUser ? `Approved By: ${request.ApprovalByUser.Name}` : ''} Created: ${request.CreatedDate || 'N/A'} Notes: ${request.Notes || 'N/A'} Dates: ${datesInfo}${historyInfo}`, }, ], }; } async getTimeOffRequestActions(args) { const actions = await this.client.getTimeOffRequestActions(args.requestId); if (!actions.length) { return { content: [ { type: 'text', text: 'No available actions for this time off request.', }, ], }; } const actionsList = actions.map(a => ` - ${a.Action}`).join('\n'); return { content: [ { type: 'text', text: `Available actions for time off request ${args.requestId}:\n${actionsList}`, }, ], }; } async removeTimeOff(args) { // Smart handler that determines whether to delete an entry or cancel a request // Strategy: Try as request first (requires approval), then as entry (no approval) const { id, comment, dcaaExplanation } = args; // Try to get it as a request first (approval-required types) try { const request = await this.client.getTimeOffRequestById(id); // Found as request - check available actions try { const actions = await this.client.getTimeOffRequestActions(id); if (actions.some(a => a.Action === 'Cancel')) { // Can cancel this request const updatedRequest = await this.client.performTimeOffRequestAction(id, 'Cancel', comment); return { content: [ { type: 'text', text: `Time off request cancelled successfully!\n` + `ID: ${id}\n` + `Type: ${request.TimeOffType?.Name || request.TimeOffTypeID}\n` + `New Status: ${updatedRequest.Status}` + (comment ? `\nComment: ${comment}` : ''), }, ], }; } else { // Request exists but can't be cancelled return { content: [ { type: 'text', text: `Time off request ${id} cannot be cancelled.\n` + `Current status: ${request.Status}\n` + `Available actions: ${actions.map(a => a.Action).join(', ') || 'none'}`, }, ], }; } } catch (actionsError) { // Request exists but couldn't get actions - report the issue return { content: [ { type: 'text', text: `Found time off request ${id} but couldn't retrieve available actions.\n` + `Current status: ${request.Status}`, }, ], }; } } catch (requestError) { // Not found as request - try as entry (non-approval types) try { await this.client.deleteTimeOff(id, dcaaExplanation); return { content: [ { type: 'text', text: `Time off entry deleted successfully!\n` + `ID: ${id}` + (dcaaExplanation ? `\nDCAA Explanation: ${dcaaExplanation}` : ''), }, ], }; } catch (deleteError) { // Not found as either type return { content: [ { type: 'text', text: `Could not find time off with ID: ${id}\n` + `This ID was not found as either:\n` + `- A time off request (approval-required type)\n` + `- A time off entry (non-approval type)\n\n` + `Please verify the ID is correct.`, }, ], }; } } } async getTimeOffBalance(args) { const balance = await this.client.getTimeOffBalance(args.timeOffTypeId); return { content: [ { type: 'text', text: `Time Off Balance:\nType: ${balance.Name}\nCurrent Balance: ${balance.CurrentBalance || 0} ${balance.Unit || 'hours'}\nAccrual Rate: ${balance.AccrualRate || 0} ${balance.Unit || 'hours'} per period`, }, ], }; } async listTimeOffTypes(args) { const types = await this.client.getTimeOffTypes(); if (!types.length) { return { content: [ { type: 'text', text: 'No time off types found.', }, ], }; } const formatted = types .map((type) => `ID: ${type.ID} | Name: ${type.Name} | Balance: ${type.CurrentBalance || 0} ${type.Unit || 'hours'}`) .join('\n'); return { content: [ { type: 'text', text: `Available time off types:\n\n${formatted}`, }, ], }; } async getMyProfile(args) { const user = await this.client.getMe(); return { content: [ { type: 'text', text: `User Profile:\nID: ${user.ID}\nName: ${user.FirstName} ${user.LastName}\nEmail: ${user.Email}\nActive: ${user.IsActive}`, }, ], }; } async getVersion(args) { // Import VERSION from constants const { VERSION, SERVER_NAME } = await import('./constants.js'); return { content: [ { type: 'text', text: `ClickTime MCP Server Version Information: Server Name: ${SERVER_NAME} Version: ${VERSION} Build Date: ${new Date().toISOString()} Node.js Version: ${process.version} Platform: ${process.platform} Path: ${process.cwd()}`, }, ], }; } generateDateRange(startDate, endDate) { const dates = []; const start = new Date(startDate); const end = new Date(endDate); for (let dt = new Date(start); dt <= end; dt.setDate(dt.getDate() + 1)) { dates.push(dt.toISOString().split('T')[0]); } return dates; } }