Skip to main content
Glama

Tempo Filler MCP Server

Official
by TRANZACT
tempo-client.ts16.6 kB
import axios, { AxiosInstance, AxiosResponse } from "axios"; import { JiraIssue, TempoWorklogResponse, TempoWorklogCreatePayload, TempoClientConfig, IssueCache, TempoApiError } from "./types/index.js"; export class TempoClient { private axiosInstance: AxiosInstance; private issueCache: IssueCache = {}; private config: TempoClientConfig; private currentUser: string | null = null; // Cache for the authenticated user constructor(config: TempoClientConfig) { this.config = config; // Create axios instance with PAT authentication this.axiosInstance = axios.create({ baseURL: config.baseUrl, timeout: config.timeout || 30000, headers: { 'Authorization': `Bearer ${config.personalAccessToken}`, 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'TempoFiller-MCP/1.0.0' } }); // Add request interceptor for debugging this.axiosInstance.interceptors.request.use( (config) => { console.error(`DEBUG: Making ${config.method?.toUpperCase()} request to ${config.baseURL}${config.url}`); console.error(`DEBUG: Headers:`, JSON.stringify(config.headers, null, 2)); if (config.data) { console.error(`DEBUG: Request body:`, JSON.stringify(config.data, null, 2)); } return config; }, (error) => { console.error(`DEBUG: Request error:`, error); return Promise.reject(error); } ); // Add response interceptor for error handling this.axiosInstance.interceptors.response.use( (response) => { console.error(`DEBUG: Response ${response.status} from ${response.config.url}`); return response; }, (error) => { console.error(`DEBUG: Response error ${error.response?.status} from ${error.config?.url}`); console.error(`DEBUG: Error response:`, error.response?.data); if (error.response?.status === 401) { throw new Error('Authentication failed. Please check your Personal Access Token.'); } if (error.response?.status === 403) { throw new Error('Access forbidden. Please check your permissions in JIRA/Tempo.'); } if (error.response?.status === 429) { throw new Error('Rate limit exceeded. Please try again later.'); } const apiError: TempoApiError = error.response?.data; if (apiError?.message) { throw new Error(`Tempo API Error: ${apiError.message}`); } throw error; } ); } /** * Get the current authenticated user from JIRA * Caches the result to avoid repeated API calls */ private async getCurrentUser(): Promise<string> { if (this.currentUser) { return this.currentUser; } try { const response = await this.axiosInstance.get('/rest/api/latest/myself'); // Use the username (name field) for Tempo API this.currentUser = response.data.key; console.error(`🔐 AUTHENTICATED USER: ${this.currentUser}`); if (!this.currentUser) { throw new Error('Unable to determine current user from API response'); } return this.currentUser; } catch (error) { throw new Error(`Failed to get current user: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get JIRA issue details by issue key * Implements caching to avoid repeated API calls */ async getIssueById(issueKey: string): Promise<JiraIssue> { // Check cache first const cached = this.issueCache[issueKey]; if (cached && (Date.now() - cached.cached.getTime()) < 300000) { // 5 minute cache return { id: cached.id, key: issueKey, fields: { summary: cached.summary } }; } try { const response: AxiosResponse<JiraIssue> = await this.axiosInstance.get( `/rest/api/latest/issue/${issueKey}` ); const issue = response.data; // Cache the result this.issueCache[issueKey] = { id: issue.id, summary: issue.fields.summary, cached: new Date() }; return issue; } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 404) { throw new Error(`Issue ${issueKey} not found. Please check the issue key.`); } throw error; } } /** * Get worklogs using Tempo API search endpoint * Automatically filters by the authenticated user * Uses the working /rest/tempo-timesheets/4/worklogs/search endpoint */ async getWorklogs(params: { from?: string; // YYYY-MM-DD to?: string; // YYYY-MM-DD issueKey?: string; }): Promise<TempoWorklogResponse[]> { // Get the current authenticated user const currentUser = await this.getCurrentUser(); console.error(`🔍 WORKLOG SEARCH: Processing request for params:`, JSON.stringify(params)); console.error(`👤 USER: Using authenticated user ${currentUser}`); try { // Since POST search has parameter format issues, let's try a different approach // First, let's try getting worklogs from a specific issue we know exists if (params.issueKey) { console.error(`📋 ISSUE-SPECIFIC: Getting worklogs for issue ${params.issueKey}`); const issue = await this.getIssueById(params.issueKey); console.error(`✅ ISSUE RESOLVED: ${issue.key} - ${issue.fields.summary}`); // Get worklogs from JIRA API instead of Tempo search const response = await this.axiosInstance.get( `/rest/api/latest/issue/${params.issueKey}/worklog` ); console.error(`📊 JIRA RESPONSE: Found ${response.data?.worklogs?.length || 0} worklogs`); // Convert JIRA worklog format to Tempo format const jiraWorklogs = response.data?.worklogs || []; // Filter by current user const filteredWorklogs = jiraWorklogs.filter((worklog: any) => worklog.author?.name === currentUser || worklog.author?.accountId === currentUser || worklog.author?.emailAddress === currentUser ); const convertedWorklogs = filteredWorklogs.map((worklog: any) => ({ id: worklog.id, timeSpentSeconds: worklog.timeSpentSeconds, billableSeconds: worklog.timeSpentSeconds, // Assume all time is billable for now timeSpent: worklog.timeSpent, issue: { id: issue.id, key: params.issueKey!, summary: issue.fields.summary }, started: worklog.started, worker: { displayName: worklog.author?.displayName || 'Unknown', accountId: worklog.author?.accountId || 'unknown' }, attributes: {} })); console.error(`🎯 CONVERTED: Returning ${convertedWorklogs.length} worklogs for user ${currentUser}`); return convertedWorklogs; } // For date-based queries without specific issue, we need to use Tempo search console.error(`📅 DATE-BASED: Attempting Tempo search for date range`); const searchParams: any = { from: params.from || '2025-07-01', to: params.to || '2025-07-31' }; // Add current user as worker for server-side filtering searchParams.worker = [currentUser]; console.error(`👤 WORKER FILTER: Adding server-side worker filter for ${currentUser}`); console.error(`🔍 TEMPO SEARCH: Sending request with:`, JSON.stringify(searchParams)); const response = await this.axiosInstance.post( `/rest/tempo-timesheets/4/worklogs/search`, searchParams ); console.error(`📊 TEMPO RESPONSE: Received ${Array.isArray(response.data) ? response.data.length : 'non-array'} results`); const results = Array.isArray(response.data) ? response.data : []; return results; } catch (error) { console.error(`❌ ERROR in getWorklogs:`, error); if (axios.isAxiosError(error)) { const status = error.response?.status; const url = error.config?.url; const method = error.config?.method?.toUpperCase(); const responseData = error.response?.data; throw new Error(`Failed to retrieve worklogs: ${method} ${url} returned ${status}. ${responseData?.message || JSON.stringify(responseData)}`); } throw new Error(`Failed to retrieve worklogs: ${error instanceof Error ? error.message : String(error)}`); } } /** * Test basic connectivity to JIRA */ private async testConnection(): Promise<void> { try { const response = await this.axiosInstance.get('/rest/api/2/myself'); console.error(`Connection test successful. Authenticated as: ${response.data.displayName || response.data.name}`); } catch (error) { if (axios.isAxiosError(error)) { const debugInfo = ` URL: ${error.config?.baseURL}${error.config?.url} Status: ${error.response?.status} Method: ${error.config?.method} Headers: ${JSON.stringify(error.config?.headers)} Response: ${JSON.stringify(error.response?.data)} `; throw new Error(`Authentication test failed: ${error.response?.status} ${error.response?.statusText}. Debug info: ${debugInfo}`); } throw error; } } /** * Get worklogs for a specific issue using JIRA API */ private async getWorklogsForIssue( issueKey: string, from?: string, to?: string, worker?: string ): Promise<TempoWorklogResponse[]> { try { // Get all worklogs for the issue using JIRA API const response = await this.axiosInstance.get( `/rest/api/latest/issue/${issueKey}/worklog` ); const jiraWorklogs = response.data.worklogs || []; // Convert JIRA worklogs to Tempo format and apply filters const tempoWorklogs: TempoWorklogResponse[] = jiraWorklogs .filter((worklog: any) => { // Filter by date if specified if (from || to) { const worklogDate = worklog.started ? worklog.started.split('T')[0] : null; if (from && worklogDate && worklogDate < from) return false; if (to && worklogDate && worklogDate > to) return false; } // Filter by worker if specified if (worker && worklog.author?.name !== worker) return false; return true; }) .map((worklog: any) => ({ id: worklog.id, billableSeconds: worklog.timeSpentSeconds, // Assume all time is billable for now timeSpentSeconds: worklog.timeSpentSeconds, timeSpent: worklog.timeSpent, issue: { id: worklog.issueId || 'unknown', key: issueKey, summary: 'Issue summary not available from worklog API' }, started: worklog.started, worker: { accountId: worklog.author?.accountId || 'unknown', displayName: worklog.author?.displayName || worklog.author?.name || 'Unknown' }, attributes: { description: worklog.comment || '' } })); return tempoWorklogs; } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 404) { throw new Error(`Issue ${issueKey} not found or you don't have permission to view its worklogs.`); } throw error; } } /** * Create a new worklog entry * Follows the pattern from the C# implementation * Note: API returns an array with a single worklog object */ async createWorklog(payload: TempoWorklogCreatePayload): Promise<TempoWorklogResponse> { try { const response: AxiosResponse<TempoWorklogResponse[]> = await this.axiosInstance.post( '/rest/tempo-timesheets/4/worklogs/', payload ); // API returns an array with a single worklog object const worklogs = response.data; if (!Array.isArray(worklogs) || worklogs.length === 0) { throw new Error('Unexpected response format from Tempo API'); } return worklogs[0]; } catch (error) { if (axios.isAxiosError(error) && error.response?.data) { const apiError: TempoApiError = error.response.data; throw new Error(`Failed to create worklog: ${apiError.message || error.message}`); } throw new Error(`Failed to create worklog: ${error instanceof Error ? error.message : String(error)}`); } } /** * Delete a worklog entry */ async deleteWorklog(worklogId: string): Promise<void> { try { await this.axiosInstance.delete(`/rest/tempo-timesheets/4/worklogs/${worklogId}`); } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 404) { throw new Error(`Worklog ${worklogId} not found.`); } throw new Error(`Failed to delete worklog: ${error instanceof Error ? error.message : String(error)}`); } } /** * Helper method to convert hours to seconds */ hoursToSeconds(hours: number): number { return Math.round(hours * 3600); } /** * Helper method to convert seconds to hours */ secondsToHours(seconds: number): number { return Math.round((seconds / 3600) * 100) / 100; // Round to 2 decimal places } /** * Create worklog payload from simplified parameters * Implements the same pattern as the C# PostTime method * Automatically uses the authenticated user as the worker */ async createWorklogPayload(params: { issueKey: string; hours: number; startDate: string; // YYYY-MM-DD endDate?: string; // YYYY-MM-DD billable?: boolean; description?: string; }): Promise<TempoWorklogCreatePayload> { // Resolve issue key to numerical ID const issue = await this.getIssueById(params.issueKey); // Get the current authenticated user const currentUser = await this.getCurrentUser(); const timeInSeconds = this.hoursToSeconds(params.hours); const startDate = params.startDate; const endDate = params.endDate || params.startDate; // Build attributes object - keep it empty to match working payload const attributes: Record<string, any> = {}; // Build the payload using the authenticated user as worker const payload: TempoWorklogCreatePayload = { attributes, billableSeconds: params.billable !== false ? timeInSeconds : 0, timeSpentSeconds: timeInSeconds, worker: currentUser, // Always use the authenticated user started: `${startDate}T00:00:00.000`, originTaskId: issue.id, remainingEstimate: null, endDate: `${endDate}T00:00:00.000`, comment: params.description || undefined }; return payload; } /** * Batch create multiple worklogs * Automatically uses the authenticated user as the worker * Uses Promise.all() for concurrent processing like the C# Task.WhenAll pattern */ async createWorklogsBatch(worklogParams: Array<{ issueKey: string; hours: number; startDate: string; endDate?: string; billable?: boolean; description?: string; }>): Promise<Array<{ success: boolean; worklog?: TempoWorklogResponse; error?: string; originalParams: typeof worklogParams[0]; }>> { // Create all payloads first (this will cache issue resolutions) const payloadPromises = worklogParams.map(async (params) => ({ params, payload: await this.createWorklogPayload(params) })); const payloadResults = await Promise.all(payloadPromises); // Now create all worklogs concurrently const createPromises = payloadResults.map(async ({ params, payload }) => { try { const worklog = await this.createWorklog(payload); return { success: true, worklog, originalParams: params }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), originalParams: params }; } }); return Promise.all(createPromises); } /** * Clear the issue cache (useful for testing or when issues are updated) */ clearIssueCache(): void { this.issueCache = {}; } /** * Get cached issue count (for monitoring/debugging) */ getCachedIssueCount(): number { return Object.keys(this.issueCache).length; } }

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/TRANZACT/tempo-filler-mcp-server'

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