UNPKG

7pace-mcp-server

Version:

🚀 AI-powered time tracking MCP server for 7pace Timetracker & Azure DevOps. Track time naturally with Claude AI using conversational commands. Zero context switching, real-time sync.

620 lines (619 loc) 28.4 kB
#!/usr/bin/env node "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SevenPaceService = exports.SevenPaceMCPServer = void 0; require("dotenv/config"); const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js"); const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js"); const types_js_1 = require("@modelcontextprotocol/sdk/types.js"); const axios_1 = __importDefault(require("axios")); function isIsoDateOnly(value) { return /^\d{4}-\d{2}-\d{2}$/.test(value); } function isPositiveNumber(value) { return typeof value === "number" && Number.isFinite(value) && value > 0; } function isPositiveInteger(value) { return isPositiveNumber(value) && Number.isInteger(value); } function isGuidLike(value) { if (!value) return false; // Strict GUID format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx return /^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/.test(value); } function computeHoursFromApiLength(length) { // Heuristic: REST list often returns seconds; create/update uses minutes. // If the number is large (>= 1000), assume seconds; else minutes. return length >= 1000 ? length / 3600 : length / 60; } class SevenPaceService { client; config; activityTypesCache = null; async listActivityTypes() { return this.fetchActivityTypes(); } constructor(config) { this.config = config; this.client = axios_1.default.create({ baseURL: config.baseUrl, headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json", }, timeout: 15000, // 15 second timeout for all requests validateStatus: (status) => status < 500, // Accept 4xx errors (don't throw) }); } async fetchActivityTypes() { // Cache for 5 minutes const now = Date.now(); if (this.activityTypesCache && now - this.activityTypesCache.timestamp < 5 * 60 * 1000) { return this.activityTypesCache.items; } const resp = await this.client.get("/api/rest/activitytypes?api-version=3.2"); const items = Array.isArray(resp.data?.data) ? resp.data.data.map((it) => ({ id: it.id ?? it.Id, name: it.name ?? it.Name, })) : Array.isArray(resp.data?.value) ? resp.data.value.map((it) => ({ id: it.Id ?? it.id, name: it.Name ?? it.name, })) : []; this.activityTypesCache = { timestamp: now, items }; return items; } async resolveActivityTypeId(input) { // Helper to resolve a name to ID via API const resolveByName = async (name) => { try { const list = await this.fetchActivityTypes(); const match = list.find((t) => t.name.toLowerCase() === name.toLowerCase()); return match?.id; } catch { return undefined; } }; // Only honor explicit input; do not read or use any defaults if (input && input.trim().length > 0) { if (isGuidLike(input)) return input; // already an ID const byName = await resolveByName(input); if (byName) return byName; return undefined; // invalid input -> omit } return undefined; // no activity type } async logTime(entry) { // Check for test credentials if (this.config.token === "test-token-replace-with-real-token") { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, "Cannot log time with test credentials. Please update .env file with real SEVENPACE_TOKEN and SEVENPACE_ORGANIZATION values."); } const activityTypeId = await this.resolveActivityTypeId(entry.activityType); // Build payload matching the working cURL (date, length seconds, billableLength seconds) const worklog = { workItemId: entry.workItemId, date: entry.date, length: Math.round(entry.hours * 3600), billableLength: Math.round(entry.hours * 3600), comment: entry.description, ...(activityTypeId ? { activityTypeId } : {}), }; try { const response = await this.client.post("/api/rest/workLogs?api-version=3.2", worklog, { timeout: Number(process.env.SEVENPACE_WRITE_TIMEOUT_MS) || 30000, }); // Check for API errors even with 2xx status if (response.status >= 400) { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `7pace API error (${response.status}): ${response.statusText} - ${JSON.stringify(response.data)}`); } // Validate that the worklog was actually created const responseData = response.data; if (!responseData) { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, "7pace API returned empty response when creating worklog"); } // If API returns error-like body, surface it if (responseData.error || responseData.success === false) { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `7pace API error body: ${JSON.stringify(responseData)}`); } return responseData; } catch (error) { // Handle axios timeout specifically if (error?.code === "ECONNABORTED") { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, "Request timed out. 7pace API may be slow or unavailable."); } if (error?.response) { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `7pace API error (${error.response.status}): ${error.response.statusText} - ${JSON.stringify(error.response.data)}`); } throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `Failed to log time: ${error instanceof Error ? error.message : "Unknown error"}`); } } async getWorklogs(workItemId, startDate, endDate) { // Check for test credentials if (this.config.token === "test-token-replace-with-real-token") { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, "Cannot retrieve worklogs with test credentials. Please update .env file with real SEVENPACE_TOKEN and SEVENPACE_ORGANIZATION values."); } try { const params = {}; if (workItemId) params.workItemId = workItemId; if (startDate) params.from = startDate; if (endDate) params.to = endDate; const response = await this.client.get("/api/rest/worklogs?api-version=3.2", { params }); const raw = response.data; let items = []; if (Array.isArray(raw?.data)) { items = raw.data; } else if (Array.isArray(raw?.value)) { // OData shape items = raw.value.map((it) => ({ id: it.Id, workItemId: it.WorkItemId, timestamp: it.Timestamp, length: it.Length, comment: it.Comment, })); } else if (Array.isArray(raw)) { items = raw; } return items; } catch (error) { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `Failed to retrieve worklogs: ${error instanceof Error ? error.message : "Unknown error"}`); } } async updateWorklog(worklogId, updates) { try { const worklog = {}; if (typeof updates.hours === "number") worklog.length = updates.hours * 3600; // update expects seconds if (updates.description) worklog.comment = updates.description; if (updates.workItemId) worklog.workItemId = updates.workItemId; const response = await this.client.put(`/api/rest/worklogs/${worklogId}?api-version=3.2`, worklog); // Check for API errors even with 2xx status if (response.status >= 400) { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `7pace API error (${response.status}): ${response.statusText} - ${JSON.stringify(response.data)}`); } // Validate the response indicates successful update const responseData = response.data; if (!responseData) { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, "7pace API returned empty response - worklog update may have failed"); } // Check for explicit error indicators in response if (responseData.error || responseData.success === false) { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `7pace API returned error: ${JSON.stringify(responseData)}`); } return responseData; } catch (error) { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `Failed to update worklog: ${error instanceof Error ? error.message : "Unknown error"}`); } } async deleteWorklog(worklogId) { try { const response = await this.client.delete(`/api/rest/worklogs/${worklogId}?api-version=3.2`); // Check for API errors even with 2xx status if (response.status >= 400) { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `7pace API error (${response.status}): ${response.statusText} - ${JSON.stringify(response.data)}`); } // For delete operations, some APIs return error information in response body const responseData = response.data; if (responseData && (responseData.error || responseData.success === false)) { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `7pace API returned error: ${JSON.stringify(responseData)}`); } } catch (error) { // Re-throw McpError instances as-is if (error instanceof types_js_1.McpError) { throw error; } throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `Failed to delete worklog: ${error instanceof Error ? error.message : "Unknown error"}`); } } async getTimeReport(startDate, endDate, userId) { try { const params = { "api-version": "3.2", from: startDate, to: endDate, }; if (userId) params.userId = userId; const response = await this.client.get("/api/rest/reports/time", { params, }); return response.data; } catch (error) { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `Failed to generate time report: ${error instanceof Error ? error.message : "Unknown error"}`); } } } exports.SevenPaceService = SevenPaceService; class SevenPaceMCPServer { server; sevenPaceService; constructor() { this.server = new index_js_1.Server({ name: "7pace-timetracker", version: "1.0.0", }, { capabilities: { tools: {}, }, }); // Initialize 7pace service with environment variables const config = { baseUrl: process.env.SEVENPACE_BASE_URL || `https://${process.env.SEVENPACE_ORGANIZATION}.timehub.7pace.com`, token: process.env.SEVENPACE_TOKEN || "", organizationName: process.env.SEVENPACE_ORGANIZATION || "", }; if (!config.token || !config.organizationName) { throw new Error("Missing required environment variables: SEVENPACE_TOKEN and SEVENPACE_ORGANIZATION"); } this.sevenPaceService = new SevenPaceService(config); this.setupToolHandlers(); } setupToolHandlers() { this.server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => { return { tools: [ { name: "log_time", description: "Log time entry to 7pace Timetracker for a specific work item", inputSchema: { type: "object", properties: { workItemId: { type: "number", description: "Azure DevOps Work Item ID", }, date: { type: "string", description: "Date in YYYY-MM-DD format", }, hours: { type: "number", description: "Number of hours worked", }, description: { type: "string", description: "Description of work performed", }, activityType: { type: "string", description: "Type of activity (name or ID, optional)", }, }, required: ["workItemId", "date", "hours", "description"], }, }, { name: "list_activity_types", description: "List available 7pace activity types (name and id)", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "get_worklogs", description: "Retrieve time logs from 7pace Timetracker", inputSchema: { type: "object", properties: { workItemId: { type: "number", description: "Filter by specific work item ID (optional)", }, startDate: { type: "string", description: "Start date in YYYY-MM-DD format (optional)", }, endDate: { type: "string", description: "End date in YYYY-MM-DD format (optional)", }, }, required: [], }, }, { name: "update_worklog", description: "Update an existing time log entry", inputSchema: { type: "object", properties: { worklogId: { type: "string", description: "ID of the worklog to update", }, workItemId: { type: "number", description: "New work item ID (optional)", }, hours: { type: "number", description: "New number of hours (optional)", }, description: { type: "string", description: "New description (optional)", }, }, required: ["worklogId"], }, }, { name: "delete_worklog", description: "Delete a time log entry", inputSchema: { type: "object", properties: { worklogId: { type: "string", description: "ID of the worklog to delete", }, }, required: ["worklogId"], }, }, { name: "generate_time_report", description: "Generate time tracking report for a date range", inputSchema: { type: "object", properties: { startDate: { type: "string", description: "Start date in YYYY-MM-DD format", }, endDate: { type: "string", description: "End date in YYYY-MM-DD format", }, userId: { type: "string", description: "Filter by specific user ID (optional)", }, }, required: ["startDate", "endDate"], }, }, ], }; }); this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => { try { switch (request.params.name) { case "log_time": return await this.handleLogTime(request.params.arguments); case "list_activity_types": return await this.handleListActivityTypes(); case "get_worklogs": return await this.handleGetWorklogs(request.params.arguments); case "update_worklog": return await this.handleUpdateWorklog(request.params.arguments); case "delete_worklog": return await this.handleDeleteWorklog(request.params.arguments); case "generate_time_report": return await this.handleGenerateTimeReport(request.params.arguments); default: throw new types_js_1.McpError(types_js_1.ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`); } } catch (error) { if (error instanceof types_js_1.McpError) { throw error; } throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`); } }); } async handleLogTime(args) { if (!isPositiveInteger(args.workItemId)) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "workItemId must be a positive integer"); } if (!isIsoDateOnly(args.date)) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "date must be in YYYY-MM-DD format"); } if (!isPositiveNumber(args.hours)) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "hours must be a positive number"); } if (typeof args.description !== "string" || args.description.trim().length === 0) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "description is required"); } const entry = { workItemId: args.workItemId, date: args.date, hours: args.hours, description: args.description, activityType: args.activityType, }; const result = await this.sevenPaceService.logTime(entry); return { content: [ { type: "text", text: `✅ Time logged successfully!\n\n` + `Work Item: #${entry.workItemId}\n` + `Date: ${entry.date}\n` + `Hours: ${entry.hours}\n` + `Description: ${entry.description}\n` + `Worklog ID: ${result.id || result.Id || "N/A"}`, }, ], }; } async handleListActivityTypes() { const items = await this.sevenPaceService.listActivityTypes(); const lines = items.map((t) => `${t.id} - ${t.name}`).join("\n"); return { content: [ { type: "text", text: lines.length ? lines : "No activity types found", }, ], }; } async handleGetWorklogs(args) { if (args.startDate && !isIsoDateOnly(args.startDate)) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "startDate must be in YYYY-MM-DD format"); } if (args.endDate && !isIsoDateOnly(args.endDate)) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "endDate must be in YYYY-MM-DD format"); } if (args.startDate && args.endDate && args.startDate > args.endDate) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "startDate must be before or equal to endDate"); } const worklogs = await this.sevenPaceService.getWorklogs(args.workItemId, args.startDate, args.endDate); const summary = worklogs .map((log) => { const id = log.id ?? log.Id; const workItemId = log.workItemId ?? log.WorkItemId; const timestamp = log.timestamp ?? log.Timestamp; const length = log.length ?? log.Length; const comment = log.comment ?? log.Comment ?? "No description"; const hours = typeof length === "number" ? computeHoursFromApiLength(length) : NaN; return (`📝 Worklog ${id}\n` + ` Work Item: #${workItemId}\n` + ` Date: ${timestamp}\n` + ` Hours: ${Number.isFinite(hours) ? hours.toFixed(2) : "N/A"}\n` + ` Description: ${comment}\n`); }) .join("\n"); return { content: [ { type: "text", text: `📊 Time Logs (${worklogs.length} entries)\n\n${summary}`, }, ], }; } async handleUpdateWorklog(args) { if (typeof args.worklogId !== "string" || args.worklogId.trim().length === 0) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "worklogId is required"); } if (typeof args.workItemId === "undefined" && typeof args.hours === "undefined" && typeof args.description === "undefined") { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "Provide at least one field to update: workItemId, hours, or description"); } if (typeof args.workItemId !== "undefined" && !isPositiveInteger(args.workItemId)) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "workItemId must be a positive integer"); } if (typeof args.hours !== "undefined" && !isPositiveNumber(args.hours)) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "hours must be a positive number"); } const updates = {}; if (typeof args.workItemId !== "undefined") updates.workItemId = args.workItemId; if (typeof args.hours !== "undefined") updates.hours = args.hours; if (typeof args.description !== "undefined") updates.description = args.description; await this.sevenPaceService.updateWorklog(args.worklogId, updates); return { content: [ { type: "text", text: `✅ Worklog ${args.worklogId} updated successfully!`, }, ], }; } async handleDeleteWorklog(args) { if (typeof args.worklogId !== "string" || args.worklogId.trim().length === 0) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "worklogId is required"); } await this.sevenPaceService.deleteWorklog(args.worklogId); return { content: [ { type: "text", text: `🗑️ Worklog ${args.worklogId} deleted successfully!`, }, ], }; } async handleGenerateTimeReport(args) { if (!isIsoDateOnly(args.startDate) || !isIsoDateOnly(args.endDate)) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "Dates must be in YYYY-MM-DD format"); } if (args.startDate > args.endDate) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "startDate must be before or equal to endDate"); } try { const report = await this.sevenPaceService.getTimeReport(args.startDate, args.endDate, args.userId); return { content: [ { type: "text", text: `📈 Time Report (${args.startDate} to ${args.endDate})\n\n` + `Total Hours: ${report.totalHours || "N/A"}\n` + `Total Entries: ${report.totalEntries || "N/A"}\n` + `Report Data: ${JSON.stringify(report, null, 2)}`, }, ], }; } catch { // Fallback: compute from worklogs const worklogs = await this.sevenPaceService.getWorklogs(undefined, args.startDate, args.endDate); const totals = worklogs.reduce((acc, wl) => { const len = wl.length ?? wl.Length; const hours = typeof len === "number" ? computeHoursFromApiLength(len) : 0; acc.totalHours += hours; acc.totalEntries += 1; return acc; }, { totalHours: 0, totalEntries: 0 }); return { content: [ { type: "text", text: `📈 Time Report (${args.startDate} to ${args.endDate}) [computed]\n\n` + `Total Hours: ${totals.totalHours.toFixed(2)}\n` + `Total Entries: ${totals.totalEntries}`, }, ], }; } } async run() { const transport = new stdio_js_1.StdioServerTransport(); await this.server.connect(transport); console.error("7pace Timetracker MCP server running on stdio"); } } exports.SevenPaceMCPServer = SevenPaceMCPServer; // Start the server if (require.main === module) { const server = new SevenPaceMCPServer(); server.run().catch(console.error); }