@clicktime/mcp-server
Version:
ClickTime MCP Tech Demo for AI agents to interact with ClickTime API
591 lines (589 loc) • 23.5 kB
JavaScript
// 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;
}
}