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
JavaScript
;
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);
}