@clicktime/mcp-server
Version:
ClickTime MCP Tech Demo for AI agents to interact with ClickTime API
230 lines (229 loc) • 9.52 kB
JavaScript
// src/api-clients.ts
import axios from 'axios';
import { USER_AGENT } from './constants.js';
export class ClickTimeAPIClient {
client;
readOnly;
constructor(config) {
this.readOnly = config.readOnly || false;
this.client = axios.create({
baseURL: config.baseUrl,
headers: {
Authorization: `token ${config.apiToken}`,
'Content-Type': 'application/json',
'User-Agent': USER_AGENT,
},
timeout: 30_000,
});
// Attach interceptor for live HTTP calls
this.client.interceptors.response.use((response) => response, (err) => Promise.reject(ClickTimeAPIClient.mapAxiosError(err)));
}
/** Centralised mapping from Axios errors → friendly messages. */
static mapAxiosError(err) {
const resp = err?.response;
if (!resp)
return err; // network or non-HTTP error, pass through untouched
switch (resp.status) {
case 401:
return new Error('Invalid ClickTime API token. Please check your credentials.');
case 403:
return new Error('Access denied. Please check your API token permissions.');
case 429:
return new Error('Rate limit exceeded. Please try again later.');
default: {
// Prefer server-supplied message when available
const msg = resp.data?.message || resp.statusText || 'Unknown ClickTime error';
return new Error(`ClickTime API error: ${msg}`);
}
}
}
async makeRequest(method, endpoint, data, params) {
if (this.readOnly && ['POST', 'PUT', 'DELETE'].includes(method)) {
throw new Error('Write operations are disabled in read-only mode');
}
try {
const response = await this.client.request({
method,
url: endpoint,
data,
params,
});
return response.data;
}
catch (err) {
// Fallback for mocked Axios requests where the interceptor never runs
throw ClickTimeAPIClient.mapAxiosError(err);
}
}
/* -------------------------------------------------------------------- *
* Time-entry methods
* -------------------------------------------------------------------- */
async getMyTimeEntries(params = {}) {
// Add verbose parameter to include Job and Task details
const enhancedParams = {
...params,
verbose: true
};
const response = await this.makeRequest('GET', '/Me/TimeEntries', null, enhancedParams);
return response.data ?? [];
}
async createTimeEntry(data) {
const response = await this.makeRequest('POST', '/Me/TimeEntries', data);
return response.data;
}
async updateTimeEntry(entryId, data) {
const response = await this.makeRequest('PUT', `/Me/TimeEntries/${entryId}`, data);
return response.data;
}
async deleteTimeEntry(entryId) {
await this.makeRequest('DELETE', `/Me/TimeEntries/${entryId}`);
}
/* -------------------------------------------------------------------- *
* Job / task methods
* -------------------------------------------------------------------- */
async getMyJobs() {
const response = await this.makeRequest('GET', '/Me/Jobs');
return response.data ?? [];
}
async getJobById(jobId) {
const response = await this.makeRequest('GET', `/Jobs/${jobId}`);
return response.data;
}
async getMyTasks() {
const response = await this.makeRequest('GET', '/Me/Tasks');
return response.data ?? [];
}
async getTaskById(taskId) {
const response = await this.makeRequest('GET', `/Tasks/${taskId}`);
return response.data;
}
/* -------------------------------------------------------------------- *
* Time-off methods
* -------------------------------------------------------------------- */
async getMyTimeOffRequests(params = {}) {
// Add verbose parameter to include full details
const enhancedParams = {
...params,
verbose: true
};
const response = await this.makeRequest('GET', '/Me/TimeOffRequests', null, enhancedParams);
return response.data ?? [];
}
async getTimeOffRequestById(requestId) {
const response = await this.makeRequest('GET', `/Me/TimeOffRequests/${requestId}`, null, { verbose: true });
return response.data;
}
async getTimeOffRequestActions(requestId) {
const response = await this.makeRequest('GET', `/Me/TimeOffRequests/${requestId}/Actions`);
return response.data ?? [];
}
async performTimeOffRequestAction(requestId, action, comment) {
const response = await this.makeRequest('POST', `/Me/TimeOffRequests/${requestId}/Actions`, {
Action: action,
Comment: comment
});
return response.data;
}
async createTimeOffRequest(data) {
const response = await this.makeRequest('POST', '/Me/TimeOffRequests', data);
return response.data;
}
async getMyTimeOff(params = {}) {
// Add verbose parameter to include full details
const enhancedParams = {
...params,
verbose: true
};
const response = await this.makeRequest('GET', '/Me/TimeOff', null, enhancedParams);
return response.data ?? [];
}
async createTimeOff(data) {
const response = await this.makeRequest('POST', '/Me/TimeOff', data);
return response.data;
}
async deleteTimeOff(timeOffId, dcaaExplanation) {
const params = dcaaExplanation ? { DCAAExplanation: dcaaExplanation } : undefined;
await this.makeRequest('DELETE', `/Me/TimeOff/${timeOffId}`, null, params);
}
async getTimeOffTypes() {
const response = await this.makeRequest('GET', '/Me/TimeOffTypes', null, { verbose: true });
return response.data ?? [];
}
async getTimeOffBalance(typeId) {
const response = await this.makeRequest('GET', `/Me/TimeOffTypes/${typeId}`, null, { verbose: true });
return response.data;
}
/* -------------------------------------------------------------------- *
* Expense-type & payment-type methods
* -------------------------------------------------------------------- */
async getMyExpenseTypes() {
const response = await this.makeRequest('GET', '/ExpenseTypes');
return response.data ?? [];
}
async getExpenseTypeById(expenseTypeId) {
const response = await this.makeRequest('GET', `/ExpenseTypes/${expenseTypeId}`);
return response.data;
}
async getMyPaymentTypes() {
const response = await this.makeRequest('GET', '/PaymentTypes');
return response.data ?? [];
}
async getPaymentTypeById(paymentTypeId) {
const response = await this.makeRequest('GET', `/PaymentTypes/${paymentTypeId}`);
return response.data;
}
/* -------------------------------------------------------------------- *
* Expense-sheet & item methods
* -------------------------------------------------------------------- */
async getMyExpenseSheets(params = {}) {
const response = await this.makeRequest('GET', '/Me/ExpenseSheets', null, params);
return response.data ?? [];
}
async createExpenseSheet(data) {
const response = await this.makeRequest('POST', '/Me/ExpenseSheets', data);
return response.data;
}
async getMyExpenseItems(params = {}) {
const response = await this.makeRequest('GET', '/Me/ExpenseItems', null, params);
return response.data ?? [];
}
async createExpenseItem(data) {
const response = await this.makeRequest('POST', '/Me/ExpenseItems', data);
return response.data;
}
/* -------------------------------------------------------------------- *
* User / health-check methods
* -------------------------------------------------------------------- */
async getMe() {
const response = await this.makeRequest('GET', '/Me');
return response.data;
}
async healthCheck() {
try {
await this.getMe();
return true;
}
catch {
return false;
}
}
/* -------------------------------------------------------------------- *
* Timesheet submission methods
* -------------------------------------------------------------------- */
async getMyTimesheets(params = {}) {
const response = await this.makeRequest('GET', '/Me/Timesheets', null, params);
return Array.isArray(response.data) ? response.data : [];
}
async submitTimesheetById(timesheetId, actionData) {
if (!timesheetId?.trim())
throw new Error('Timesheet ID is required');
const response = await this.makeRequest('POST', `/Me/Timesheets/${timesheetId}/Actions`, actionData);
return response.data;
}
async submitExpenseSheet(expenseSheetId, actionData) {
if (!expenseSheetId?.trim())
throw new Error('Expense sheet ID is required');
const response = await this.makeRequest('POST', `/Me/ExpenseSheets/${expenseSheetId}/Actions`, actionData);
return response.data;
}
}