hevy-mcp
Version:
A Model Context Protocol (MCP) server implementation that interfaces with the Hevy fitness tracking app and its API.
1,180 lines • 67 kB
JavaScript
#!/usr/bin/env node
// Generated with tsdown
// https://tsdown.dev
(function() {
try {
var e = "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof globalThis ? globalThis : "undefined" != typeof self ? self : {};
e.SENTRY_RELEASE = { id: "1.23.11" };
var n = new e.Error().stack;
n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "7e43dd4f-3e2c-440e-932f-e1db8d87226c", e._sentryDebugIdIdentifier = "sentry-dbid-7e43dd4f-3e2c-440e-932f-e1db8d87226c");
} catch (e) {}
})();
import * as Sentry from "@sentry/node";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import axios, { isAxiosError } from "axios";
import fetch from "@kubb/plugin-client/clients/axios";
//#region src/utils/error-handler.ts
/**
* Centralized error handling utility for MCP tools
*/
/**
* Create a standardized error response for MCP tools
*
* @param error - The error object or message
* @param context - Optional context information about where the error occurred
* @returns A formatted MCP tool response with error information
*/
function createErrorResponse(error, context) {
let errorMessage = error instanceof Error ? error.message : String(error);
if (isAxiosError(error) && error.response?.data) {
const { data } = error.response;
if (typeof data === "string") errorMessage = data;
else if (data && typeof data === "object") try {
errorMessage = JSON.stringify(data);
} catch (_e) {
errorMessage = String(data);
}
}
const errorCode = error instanceof Error && "code" in error ? error.code : void 0;
const errorType = determineErrorType(error, errorMessage);
if (errorCode) console.debug(`Error code: ${errorCode}`);
const formattedMessage = `${context ? `[${context}] ` : ""}Error: ${errorMessage}`;
console.error(`${formattedMessage} (Type: ${errorType})`, error);
return {
content: [{
type: "text",
text: formattedMessage
}],
isError: true
};
}
/**
* Determine the type of error based on error characteristics
*/
function determineErrorType(error, message) {
const messageLower = message.toLowerCase();
const nameLower = error instanceof Error ? error.name.toLowerCase() : "";
if (nameLower.includes("network") || messageLower.includes("network") || nameLower.includes("fetch") || messageLower.includes("fetch") || nameLower.includes("timeout") || messageLower.includes("timeout")) return "NETWORK_ERROR";
if (nameLower.includes("validation") || messageLower.includes("validation") || messageLower.includes("invalid") || messageLower.includes("required")) return "VALIDATION_ERROR";
if (messageLower.includes("not found") || messageLower.includes("404") || messageLower.includes("does not exist")) return "NOT_FOUND";
if (nameLower.includes("api") || messageLower.includes("api") || messageLower.includes("server error") || messageLower.includes("500")) return "API_ERROR";
return "UNKNOWN_ERROR";
}
/**
* Wrap an async function with standardized error handling
*
* This function preserves the parameter types of the wrapped function while
* providing error handling. The returned function accepts Record<string, unknown>
* (as required by MCP SDK) but internally casts to the original parameter type.
*
* @param fn - The async function to wrap
* @param context - Context information for error messages
* @returns A function that catches errors and returns standardized error responses
*/
function withErrorHandling(fn, context) {
return async (args) => {
try {
return await fn(args);
} catch (error) {
return createErrorResponse(error, context);
}
};
}
//#endregion
//#region src/utils/formatters.ts
/**
* Format a workout object for consistent presentation
*
* @param workout - The workout object from the API
* @returns A formatted workout object with standardized properties
*/
function formatWorkout(workout) {
return {
id: workout.id,
title: workout.title,
description: workout.description,
startTime: workout.start_time,
endTime: workout.end_time,
createdAt: workout.created_at,
updatedAt: workout.updated_at,
duration: calculateDuration(workout.start_time, workout.end_time),
exercises: workout.exercises?.map((exercise) => {
return {
index: exercise.index,
name: exercise.title,
exerciseTemplateId: exercise.exercise_template_id,
notes: exercise.notes,
supersetsId: exercise.supersets_id,
sets: exercise.sets?.map((set) => ({
index: set.index,
type: set.type,
weight: set.weight_kg,
reps: set.reps,
distance: set.distance_meters,
duration: set.duration_seconds,
rpe: set.rpe,
customMetric: set.custom_metric
}))
};
})
};
}
/**
* Format a routine object for consistent presentation
*
* @param routine - The routine object from the API
* @returns A formatted routine object with standardized properties
*/
function formatRoutine(routine) {
return {
id: routine.id,
title: routine.title,
folderId: routine.folder_id,
createdAt: routine.created_at,
updatedAt: routine.updated_at,
exercises: routine.exercises?.map((exercise) => {
return {
name: exercise.title,
index: exercise.index,
exerciseTemplateId: exercise.exercise_template_id,
notes: exercise.notes,
supersetId: exercise.supersets_id,
restSeconds: exercise.rest_seconds,
sets: exercise.sets?.map((set) => ({
index: set.index,
type: set.type,
weight: set.weight_kg,
reps: set.reps,
...set.rep_range !== void 0 && { repRange: set.rep_range },
distance: set.distance_meters,
duration: set.duration_seconds,
...set.rpe !== void 0 && { rpe: set.rpe },
customMetric: set.custom_metric
}))
};
})
};
}
/**
* Format a routine folder object for consistent presentation
*
* @param folder - The routine folder object from the API
* @returns A formatted routine folder object with standardized properties
*/
function formatRoutineFolder(folder) {
return {
id: folder.id,
title: folder.title,
createdAt: folder.created_at,
updatedAt: folder.updated_at
};
}
/**
* Calculate duration between two ISO timestamp strings
*
* @param startTime - The start time as ISO string or timestamp
* @param endTime - The end time as ISO string or timestamp
* @returns A formatted duration string (e.g. "1h 30m 45s") or "Unknown duration" if inputs are invalid
*/
function calculateDuration(startTime, endTime) {
if (!startTime || !endTime) return "Unknown duration";
try {
const start = new Date(startTime);
const end = new Date(endTime);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return "Unknown duration";
const durationMs = end.getTime() - start.getTime();
if (durationMs < 0) return "Invalid duration (end time before start time)";
return `${Math.floor(durationMs / (1e3 * 60 * 60))}h ${Math.floor(durationMs % (1e3 * 60 * 60) / (1e3 * 60))}m ${Math.floor(durationMs % (1e3 * 60) / 1e3)}s`;
} catch (error) {
console.error("Error calculating duration:", error);
return "Unknown duration";
}
}
/**
* Format an exercise template object for consistent presentation
*
* @param template - The exercise template object from the API
* @returns A formatted exercise template object with standardized properties
*/
function formatExerciseTemplate(template) {
return {
id: template.id,
title: template.title,
type: template.type,
primaryMuscleGroup: template.primary_muscle_group,
secondaryMuscleGroups: template.secondary_muscle_groups,
isCustom: template.is_custom
};
}
function formatExerciseHistoryEntry(entry) {
return {
workoutId: entry.workout_id,
workoutTitle: entry.workout_title,
workoutStartTime: entry.workout_start_time,
workoutEndTime: entry.workout_end_time,
exerciseTemplateId: entry.exercise_template_id,
weight: entry.weight_kg,
reps: entry.reps,
distance: entry.distance_meters,
duration: entry.duration_seconds,
rpe: entry.rpe,
customMetric: entry.custom_metric,
setType: entry.set_type
};
}
function formatBodyMeasurement(measurement) {
return {
date: measurement.date,
weightKg: measurement.weight_kg ?? null,
leanMassKg: measurement.lean_mass_kg ?? null,
fatPercent: measurement.fat_percent ?? null,
neckCm: measurement.neck_cm ?? null,
shoulderCm: measurement.shoulder_cm ?? null,
chestCm: measurement.chest_cm ?? null,
leftBicepCm: measurement.left_bicep_cm ?? null,
rightBicepCm: measurement.right_bicep_cm ?? null,
leftForearmCm: measurement.left_forearm_cm ?? null,
rightForearmCm: measurement.right_forearm_cm ?? null,
abdomen: measurement.abdomen ?? null,
waist: measurement.waist ?? null,
hips: measurement.hips ?? null,
leftThigh: measurement.left_thigh ?? null,
rightThigh: measurement.right_thigh ?? null,
leftCalf: measurement.left_calf ?? null,
rightCalf: measurement.right_calf ?? null
};
}
//#endregion
//#region src/utils/response-formatter.ts
/**
* Create a standardized success response with JSON data
*
* @param data - The data to include in the response
* @param options - Formatting options
* @returns A formatted MCP tool response with the data as JSON
*/
function createJsonResponse(data, options = {
pretty: true,
indent: 2
}) {
return { content: [{
type: "text",
text: options.pretty ? JSON.stringify(data, null, options.indent) : JSON.stringify(data)
}] };
}
/**
* Create a standardized success response with text data
*
* @param message - The text message to include in the response
* @returns A formatted MCP tool response with the text message
*/
function createTextResponse(message) {
return { content: [{
type: "text",
text: message
}] };
}
/**
* Create a standardized success response for empty or null results
*
* @param message - Optional message to include (default: "No data found")
* @returns A formatted MCP tool response for empty results
*/
function createEmptyResponse(message = "No data found") {
return { content: [{
type: "text",
text: message
}] };
}
//#endregion
//#region src/tools/body-measurements.ts
const zNullableNumber = z.number().nullable().optional();
const bodyMeasurementFieldsSchema = {
weightKg: zNullableNumber.describe("Body weight in kilograms"),
leanMassKg: zNullableNumber.describe("Lean body mass in kilograms"),
fatPercent: zNullableNumber.describe("Body fat percentage"),
neckCm: zNullableNumber.describe("Neck circumference in centimeters"),
shoulderCm: zNullableNumber.describe("Shoulder circumference in centimeters"),
chestCm: zNullableNumber.describe("Chest circumference in centimeters"),
leftBicepCm: zNullableNumber.describe("Left bicep circumference in centimeters"),
rightBicepCm: zNullableNumber.describe("Right bicep circumference in centimeters"),
leftForearmCm: zNullableNumber.describe("Left forearm circumference in centimeters"),
rightForearmCm: zNullableNumber.describe("Right forearm circumference in centimeters"),
abdomen: zNullableNumber.describe("Abdomen circumference in centimeters"),
waist: zNullableNumber.describe("Waist circumference in centimeters"),
hips: zNullableNumber.describe("Hips circumference in centimeters"),
leftThigh: zNullableNumber.describe("Left thigh circumference in centimeters"),
rightThigh: zNullableNumber.describe("Right thigh circumference in centimeters"),
leftCalf: zNullableNumber.describe("Left calf circumference in centimeters"),
rightCalf: zNullableNumber.describe("Right calf circumference in centimeters")
};
function buildMeasurementPayload(args) {
return {
weight_kg: args.weightKg ?? null,
lean_mass_kg: args.leanMassKg ?? null,
fat_percent: args.fatPercent ?? null,
neck_cm: args.neckCm ?? null,
shoulder_cm: args.shoulderCm ?? null,
chest_cm: args.chestCm ?? null,
left_bicep_cm: args.leftBicepCm ?? null,
right_bicep_cm: args.rightBicepCm ?? null,
left_forearm_cm: args.leftForearmCm ?? null,
right_forearm_cm: args.rightForearmCm ?? null,
abdomen: args.abdomen ?? null,
waist: args.waist ?? null,
hips: args.hips ?? null,
left_thigh: args.leftThigh ?? null,
right_thigh: args.rightThigh ?? null,
left_calf: args.leftCalf ?? null,
right_calf: args.rightCalf ?? null
};
}
function registerBodyMeasurementTools(server, hevyClient) {
const getBodyMeasurementsSchema = {
page: z.coerce.number().int().gte(1).default(1),
pageSize: z.coerce.number().int().gte(1).lte(10).default(10)
};
server.tool("get-body-measurements", "Get a paginated list of body measurements for the authenticated user. Returns measurements including weight, body fat, and various circumference measurements.", getBodyMeasurementsSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { page, pageSize } = args;
const measurements = (await hevyClient.getBodyMeasurements({
page,
pageSize
}))?.body_measurements?.map((measurement) => formatBodyMeasurement(measurement)) || [];
if (measurements.length === 0) return createEmptyResponse("No body measurements found for the specified parameters");
return createJsonResponse(measurements);
}, "get-body-measurements"));
const getBodyMeasurementSchema = { date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").describe("The date of the body measurement (YYYY-MM-DD)") };
server.tool("get-body-measurement", "Get a single body measurement by date. Returns all measurement fields for the specified date.", getBodyMeasurementSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { date } = args;
const data = await hevyClient.getBodyMeasurement(date);
if (!data) return createEmptyResponse(`No body measurement found for date ${date}`);
return createJsonResponse(formatBodyMeasurement(data));
}, "get-body-measurement"));
const createBodyMeasurementSchema = {
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").describe("The date of the body measurement (YYYY-MM-DD). Must be unique — returns 409 if an entry already exists for this date."),
...bodyMeasurementFieldsSchema
};
server.tool("create-body-measurement", "Create a body measurement entry for a given date. All measurement fields are optional. Returns 409 if an entry already exists for that date — use update-body-measurement instead.", createBodyMeasurementSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { date, ...fields } = args;
await hevyClient.createBodyMeasurement({
date,
...buildMeasurementPayload(fields)
});
return createTextResponse(`Body measurement for ${date} created successfully.`);
}, "create-body-measurement"));
const updateBodyMeasurementSchema = {
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").describe("The date of the body measurement to update (YYYY-MM-DD). Must already exist — returns 404 otherwise."),
...bodyMeasurementFieldsSchema
};
server.tool("update-body-measurement", "Update an existing body measurement entry for a given date. All fields are overwritten — omitted fields are set to null. Returns 404 if no entry exists for the date.", updateBodyMeasurementSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { date, ...fields } = args;
await hevyClient.updateBodyMeasurement(date, buildMeasurementPayload(fields));
return createTextResponse(`Body measurement for ${date} updated successfully.`);
}, "update-body-measurement"));
}
//#endregion
//#region src/tools/folders.ts
/**
* Register all routine folder-related tools with the MCP server
*/
function registerFolderTools(server, hevyClient) {
const getRoutineFoldersSchema = {
page: z.coerce.number().int().gte(1).default(1),
pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
};
server.tool("get-routine-folders", "Get a paginated list of your routine folders, including both default and custom folders. Useful for organizing and browsing your workout routines.", getRoutineFoldersSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { page, pageSize } = args;
const folders = (await hevyClient.getRoutineFolders({
page,
pageSize
}))?.routine_folders?.map((folder) => formatRoutineFolder(folder)) || [];
if (folders.length === 0) return createEmptyResponse("No routine folders found for the specified parameters");
return createJsonResponse(folders);
}, "get-routine-folders"));
const getRoutineFolderSchema = { folderId: z.string().min(1) };
server.tool("get-routine-folder", "Get complete details of a specific routine folder by its ID, including name, creation date, and associated routines.", getRoutineFolderSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { folderId } = args;
const data = await hevyClient.getRoutineFolder(folderId);
if (!data) return createEmptyResponse(`Routine folder with ID ${folderId} not found`);
return createJsonResponse(formatRoutineFolder(data));
}, "get-routine-folder"));
const createRoutineFolderSchema = { name: z.string().min(1) };
server.tool("create-routine-folder", "Create a new routine folder in your Hevy account. Requires a name for the folder. Returns the full folder details including the new folder ID.", createRoutineFolderSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { name } = args;
const data = await hevyClient.createRoutineFolder({ routine_folder: { title: name } });
if (!data) return createEmptyResponse("Failed to create routine folder: Server returned no data");
return createJsonResponse(formatRoutineFolder(data), {
pretty: true,
indent: 2
});
}, "create-routine-folder"));
}
//#endregion
//#region src/utils/json-parser.ts
/**
* Preprocessor to handle MCP clients that send JSON-stringified arrays
* instead of native arrays for complex parameters.
*
* This is used with Zod's z.preprocess to handle cases where MCP clients
* serialize complex nested structures as JSON strings.
*
* @param val - The value to potentially parse
* @returns The parsed array if val is a valid JSON string, otherwise returns val unchanged
*/
function parseJsonArray(val) {
if (typeof val === "string") try {
return JSON.parse(val);
} catch {
return val;
}
return val;
}
//#endregion
//#region src/tools/routines.ts
function coerceNullishNumberInput(value) {
if (value === null || value === void 0) return value;
if (typeof value !== "string") return value;
const trimmed = value.trim();
if (trimmed === "") return;
const lowered = trimmed.toLowerCase();
if (lowered === "null") return null;
if (lowered === "undefined") return;
const asNumber = Number(trimmed);
if (Number.isNaN(asNumber)) return value;
return asNumber;
}
const zNullableInt = z.preprocess(coerceNullishNumberInput, z.number().int().nullable().optional());
const zOptionalRepRange = z.preprocess((value) => value === null ? void 0 : value, z.object({
start: zNullableInt,
end: zNullableInt
}).optional());
function buildRepRange(repRange) {
if (!repRange) return null;
const start = repRange.start ?? void 0;
const end = repRange.end ?? void 0;
if (start === void 0 && end === void 0) return null;
return {
start,
end
};
}
/**
* Returns a fixed rep count when `repRange` is a fixed range (start and end are
* both non-null and equal). Otherwise returns null.
*/
function getFixedRepsFromRepRange(repRange) {
if (!repRange) return null;
const start = repRange.start ?? null;
const end = repRange.end ?? null;
if (start === null || end === null) return null;
if (start !== end) return null;
return start;
}
const repRangeDisplayWarningText = "Note: Hevy's public API stores rep ranges (rep_range), but the Hevy apps may not display them because they rely on an internal-only exercise field (input_modifier). See https://github.com/chrisdoc/hevy-mcp/issues/261 for details/workarounds.";
/**
* Register all routine-related tools with the MCP server
*/
function registerRoutineTools(server, hevyClient) {
const getRoutinesSchema = {
page: z.coerce.number().int().gte(1).default(1),
pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
};
server.tool("get-routines", "Get a paginated list of your workout routines, including custom and default routines. Useful for browsing or searching your available routines.", getRoutinesSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { page, pageSize } = args;
const routines = (await hevyClient.getRoutines({
page,
pageSize
}))?.routines?.map((routine) => formatRoutine(routine)) || [];
if (routines.length === 0) return createEmptyResponse("No routines found for the specified parameters");
return createJsonResponse(routines);
}, "get-routines"));
const getRoutineSchema = { routineId: z.string().min(1) };
server.tool("get-routine", "Get a routine by its ID using the direct endpoint. Returns all details for the specified routine.", getRoutineSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { routineId } = args;
const data = await hevyClient.getRoutineById(String(routineId));
if (!data || !data.routine) return createEmptyResponse(`Routine with ID ${routineId} not found`);
return createJsonResponse(formatRoutine(data.routine));
}, "get-routine"));
const createRoutineSchema = {
title: z.string().min(1),
folderId: z.coerce.number().nullable().optional(),
notes: z.string().optional(),
exercises: z.preprocess(parseJsonArray, z.array(z.object({
exerciseTemplateId: z.string().min(1),
supersetId: z.coerce.number().nullable().optional(),
restSeconds: z.coerce.number().int().min(0).optional(),
notes: z.string().optional(),
sets: z.array(z.object({
type: z.enum([
"warmup",
"normal",
"failure",
"dropset"
]).default("normal"),
weight: z.coerce.number().optional(),
weightKg: z.coerce.number().optional(),
reps: zNullableInt,
distance: z.coerce.number().int().optional(),
distanceMeters: z.coerce.number().int().optional(),
duration: z.coerce.number().int().optional(),
durationSeconds: z.coerce.number().int().optional(),
customMetric: z.coerce.number().optional(),
repRange: zOptionalRepRange
}))
})))
};
server.tool("create-routine", "Create a new workout routine in your Hevy account. Requires a title and at least one exercise with sets. Optionally assign to a folder. Returns the full routine details including the new routine ID.", createRoutineSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { title, folderId, notes, exercises } = args;
let usesRepRanges = false;
const data = await hevyClient.createRoutine({ routine: {
title,
folder_id: folderId ?? null,
notes: notes ?? "",
exercises: exercises.map((exercise) => {
const sets = exercise.sets.map((set) => {
const repRange = buildRepRange(set.repRange);
const fixedReps = getFixedRepsFromRepRange(repRange);
const reps = typeof set.reps === "number" ? set.reps : fixedReps;
return {
type: set.type,
weight_kg: set.weight ?? set.weightKg ?? null,
reps: reps ?? null,
distance_meters: set.distance ?? set.distanceMeters ?? null,
duration_seconds: set.duration ?? set.durationSeconds ?? null,
custom_metric: set.customMetric ?? null,
rep_range: repRange
};
});
if (sets.some((set) => set.rep_range != null && getFixedRepsFromRepRange(set.rep_range) === null)) usesRepRanges = true;
return {
exercise_template_id: exercise.exerciseTemplateId,
superset_id: exercise.supersetId ?? null,
rest_seconds: exercise.restSeconds ?? null,
notes: exercise.notes ?? null,
sets
};
})
} });
if (!data) return createEmptyResponse("Failed to create routine: Server returned no data");
const response = createJsonResponse(formatRoutine(data), {
pretty: true,
indent: 2
});
if (usesRepRanges) response.content.push({
type: "text",
text: repRangeDisplayWarningText
});
return response;
}, "create-routine"));
const updateRoutineSchema = {
routineId: z.string().min(1),
title: z.string().min(1),
notes: z.string().optional(),
exercises: z.preprocess(parseJsonArray, z.array(z.object({
exerciseTemplateId: z.string().min(1),
supersetId: z.coerce.number().nullable().optional(),
restSeconds: z.coerce.number().int().min(0).optional(),
notes: z.string().optional(),
sets: z.array(z.object({
type: z.enum([
"warmup",
"normal",
"failure",
"dropset"
]).default("normal"),
weight: z.coerce.number().optional(),
weightKg: z.coerce.number().optional(),
reps: zNullableInt,
distance: z.coerce.number().int().optional(),
distanceMeters: z.coerce.number().int().optional(),
duration: z.coerce.number().int().optional(),
durationSeconds: z.coerce.number().int().optional(),
customMetric: z.coerce.number().optional(),
repRange: zOptionalRepRange
}))
})))
};
server.tool("update-routine", "Update an existing routine by ID. You can modify the title, notes, and exercise configurations. Returns the updated routine with all changes applied.", updateRoutineSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { routineId, title, notes, exercises } = args;
let usesRepRanges = false;
const data = await hevyClient.updateRoutine(routineId, { routine: {
title,
notes: notes ?? null,
exercises: exercises.map((exercise) => {
const sets = exercise.sets.map((set) => {
const repRange = buildRepRange(set.repRange);
const fixedReps = getFixedRepsFromRepRange(repRange);
const reps = typeof set.reps === "number" ? set.reps : fixedReps;
return {
type: set.type,
weight_kg: set.weight ?? set.weightKg ?? null,
reps: reps ?? null,
distance_meters: set.distance ?? set.distanceMeters ?? null,
duration_seconds: set.duration ?? set.durationSeconds ?? null,
custom_metric: set.customMetric ?? null,
rep_range: repRange
};
});
if (sets.some((set) => set.rep_range != null && getFixedRepsFromRepRange(set.rep_range) === null)) usesRepRanges = true;
return {
exercise_template_id: exercise.exerciseTemplateId,
superset_id: exercise.supersetId ?? null,
rest_seconds: exercise.restSeconds ?? null,
notes: exercise.notes ?? null,
sets
};
})
} });
if (!data) return createEmptyResponse(`Failed to update routine with ID ${routineId}`);
const response = createJsonResponse(formatRoutine(data), {
pretty: true,
indent: 2
});
if (usesRepRanges) response.content.push({
type: "text",
text: repRangeDisplayWarningText
});
return response;
}, "update-routine"));
}
//#endregion
//#region src/tools/templates.ts
const MUSCLE_GROUPS = [
"abdominals",
"shoulders",
"biceps",
"triceps",
"forearms",
"quadriceps",
"hamstrings",
"calves",
"glutes",
"abductors",
"adductors",
"lats",
"upper_back",
"traps",
"lower_back",
"chest",
"cardio",
"neck",
"full_body",
"other"
];
let exerciseTemplateCache = null;
let exerciseTemplateFetch = null;
/**
* Register all exercise template-related tools with the MCP server
*/
function registerTemplateTools(server, hevyClient) {
const getExerciseTemplatesSchema = {
page: z.coerce.number().int().gte(1).default(1),
pageSize: z.coerce.number().int().gte(1).lte(100).default(5)
};
server.tool("get-exercise-templates", "Get a paginated list of exercise templates (default and custom) with details like name, category, equipment, and muscle groups. Useful for browsing or searching available exercises.", getExerciseTemplatesSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { page, pageSize } = args;
const templates = (await hevyClient.getExerciseTemplates({
page,
pageSize
}))?.exercise_templates?.map((template) => formatExerciseTemplate(template)) || [];
if (templates.length === 0) return createEmptyResponse("No exercise templates found for the specified parameters");
return createJsonResponse(templates);
}, "get-exercise-templates"));
const getExerciseTemplateSchema = { exerciseTemplateId: z.string().min(1) };
server.tool("get-exercise-template", "Get complete details of a specific exercise template by its ID, including name, category, equipment, muscle groups, and notes.", getExerciseTemplateSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { exerciseTemplateId } = args;
const data = await hevyClient.getExerciseTemplate(exerciseTemplateId);
if (!data) return createEmptyResponse(`Exercise template with ID ${exerciseTemplateId} not found`);
return createJsonResponse(formatExerciseTemplate(data));
}, "get-exercise-template"));
const getExerciseHistorySchema = {
exerciseTemplateId: z.string().min(1),
startDate: z.string().datetime({ offset: true }).describe("ISO 8601 start date for filtering history").optional(),
endDate: z.string().datetime({ offset: true }).describe("ISO 8601 end date for filtering history").optional()
};
server.tool("get-exercise-history", "Get past sets for a specific exercise template, optionally filtered by start and end dates.", getExerciseHistorySchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { exerciseTemplateId, startDate, endDate } = args;
const history = (await hevyClient.getExerciseHistory(exerciseTemplateId, {
...startDate ? { start_date: startDate } : {},
...endDate ? { end_date: endDate } : {}
}))?.exercise_history?.map((entry) => formatExerciseHistoryEntry(entry)) || [];
if (history.length === 0) return createEmptyResponse(`No exercise history found for template ${exerciseTemplateId}`);
return createJsonResponse(history);
}, "get-exercise-history"));
const createExerciseTemplateSchema = {
title: z.string().min(1),
exerciseType: z.enum([
"weight_reps",
"reps_only",
"bodyweight_reps",
"bodyweight_assisted_reps",
"duration",
"weight_duration",
"distance_duration",
"short_distance_weight"
]),
equipmentCategory: z.enum([
"none",
"barbell",
"dumbbell",
"kettlebell",
"machine",
"plate",
"resistance_band",
"suspension",
"other"
]),
muscleGroup: z.enum(MUSCLE_GROUPS),
otherMuscles: z.array(z.enum(MUSCLE_GROUPS)).default([])
};
server.tool("create-exercise-template", "Create a custom exercise template with title, type, equipment, and muscle groups.", createExerciseTemplateSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { title, exerciseType, equipmentCategory, muscleGroup, otherMuscles } = args;
return createJsonResponse({
id: (await hevyClient.createExerciseTemplate({ exercise: {
title,
exercise_type: exerciseType,
equipment_category: equipmentCategory,
muscle_group: muscleGroup,
other_muscles: otherMuscles
} }))?.id,
message: "Exercise template created successfully"
});
}, "create-exercise-template"));
const searchExerciseTemplatesSchema = {
query: z.string().min(1).describe("Case-insensitive substring to match against exercise template titles"),
primaryMuscleGroup: z.enum(MUSCLE_GROUPS).optional().describe("Optional filter to restrict results to a specific primary muscle group"),
refresh: z.boolean().optional().default(false).describe("Set to true to bust the in-memory cache and re-fetch all templates from the API")
};
server.tool("search-exercise-templates", "Search exercise templates by name with optional muscle group filter. Fetches all templates from the Hevy API on first call and caches them in memory for subsequent searches. Use refresh:true to force a re-fetch.", searchExerciseTemplatesSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { query, primaryMuscleGroup, refresh } = args;
if (exerciseTemplateCache === null || refresh) {
if (refresh) exerciseTemplateFetch = null;
if (exerciseTemplateFetch === null) exerciseTemplateFetch = (async () => {
const allTemplates = [];
let page = 1;
let pageCount = 1;
do {
const data = await hevyClient.getExerciseTemplates({
page,
pageSize: 100
});
const templates = data?.exercise_templates ?? [];
allTemplates.push(...templates);
pageCount = data?.page_count ?? 1;
page++;
} while (page <= pageCount);
exerciseTemplateCache = allTemplates;
exerciseTemplateFetch = null;
return allTemplates;
})();
await exerciseTemplateFetch;
}
const queryLower = query.toLowerCase();
if (exerciseTemplateCache === null) throw new Error("Failed to populate exercise template cache.");
let results = exerciseTemplateCache.filter((t) => (t.title ?? "").toLowerCase().includes(queryLower));
if (primaryMuscleGroup !== void 0) results = results.filter((t) => t.primary_muscle_group === primaryMuscleGroup);
if (results.length === 0) return createEmptyResponse(`No exercise templates found matching "${query}"${primaryMuscleGroup ? ` with primary muscle group "${primaryMuscleGroup}"` : ""}`);
return createJsonResponse(results.map(formatExerciseTemplate));
}, "search-exercise-templates"));
}
//#endregion
//#region src/tools/webhooks.ts
const webhookUrlSchema = z.string().url().refine((url) => {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" || parsed.protocol === "http:";
} catch {
return false;
}
}, { message: "Webhook URL must be a valid HTTP or HTTPS URL" }).refine((url) => {
try {
const parsed = new URL(url);
return parsed.hostname !== "localhost" && !parsed.hostname.startsWith("127.");
} catch {
return false;
}
}, { message: "Webhook URL cannot be localhost or loopback address" });
function registerWebhookTools(server, hevyClient) {
server.tool("get-webhook-subscription", "Get the current webhook subscription for this account. Returns the webhook URL and auth token if a subscription exists.", {}, withErrorHandling(async (_args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
if (!hevyClient.getWebhookSubscription) throw new Error("Webhook subscription API not available. Please regenerate the client from the updated OpenAPI spec.");
const data = await hevyClient.getWebhookSubscription();
if (!data) return createEmptyResponse("No webhook subscription found for this account");
return createJsonResponse(data);
}, "get-webhook-subscription"));
const createWebhookSubscriptionSchema = {
url: webhookUrlSchema.describe("The webhook URL that will receive POST requests when workouts are created"),
authToken: z.string().optional().describe("Optional auth token that will be sent as Authorization header in webhook requests")
};
server.tool("create-webhook-subscription", "Create a new webhook subscription for this account. The webhook will receive POST requests when workouts are created. Your endpoint must respond with 200 OK within 5 seconds.", createWebhookSubscriptionSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { url, authToken } = args;
if (!hevyClient.createWebhookSubscription) throw new Error("Webhook subscription API not available. Please regenerate the client from the updated OpenAPI spec.");
const data = await hevyClient.createWebhookSubscription({ webhook: {
url,
authToken: authToken || null
} });
if (!data) return createEmptyResponse("Failed to create webhook subscription - please check your URL and try again");
return createJsonResponse(data);
}, "create-webhook-subscription"));
server.tool("delete-webhook-subscription", "Delete the current webhook subscription for this account. This will stop all webhook notifications.", {}, withErrorHandling(async (_args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
if (!hevyClient.deleteWebhookSubscription) throw new Error("Webhook subscription API not available. Please regenerate the client from the updated OpenAPI spec.");
const data = await hevyClient.deleteWebhookSubscription();
if (!data) return createEmptyResponse("Failed to delete webhook subscription - no subscription may exist or there was a server error");
return createJsonResponse(data);
}, "delete-webhook-subscription"));
}
//#endregion
//#region src/tools/workouts.ts
/**
* Register all workout-related tools with the MCP server
*/
function registerWorkoutTools(server, hevyClient) {
const getWorkoutsSchema = {
page: z.coerce.number().gte(1).default(1),
pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
};
server.tool("get-workouts", "Get a paginated list of workouts. Returns workout details including title, description, start/end times, and exercises performed. Results are ordered from newest to oldest.", getWorkoutsSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { page, pageSize } = args;
const workouts = (await hevyClient.getWorkouts({
page,
pageSize
}))?.workouts?.map((workout) => formatWorkout(workout)) || [];
if (workouts.length === 0) return createEmptyResponse("No workouts found for the specified parameters");
return createJsonResponse(workouts);
}, "get-workouts"));
const getWorkoutSchema = { workoutId: z.string().min(1) };
server.tool("get-workout", "Get complete details of a specific workout by ID. Returns all workout information including title, description, start/end times, and detailed exercise data.", getWorkoutSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { workoutId } = args;
const data = await hevyClient.getWorkout(workoutId);
if (!data) return createEmptyResponse(`Workout with ID ${workoutId} not found`);
return createJsonResponse(formatWorkout(data));
}, "get-workout"));
server.tool("get-workout-count", "Get the total number of workouts on the account. Useful for pagination or statistics.", {}, withErrorHandling(async () => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
return createJsonResponse({ count: (await hevyClient.getWorkoutCount())?.workout_count ?? 0 });
}, "get-workout-count"));
const getWorkoutEventsSchema = {
page: z.coerce.number().int().gte(1).default(1),
pageSize: z.coerce.number().int().gte(1).lte(10).default(5),
since: z.string().default("1970-01-01T00:00:00Z")
};
server.tool("get-workout-events", "Retrieve a paged list of workout events (updates or deletes) since a given date. Events are ordered from newest to oldest. The intention is to allow clients to keep their local cache of workouts up to date without having to fetch the entire list of workouts.", getWorkoutEventsSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { page, pageSize, since } = args;
const events = (await hevyClient.getWorkoutEvents({
page,
pageSize,
since
}))?.events || [];
if (events.length === 0) return createEmptyResponse(`No workout events found for the specified parameters since ${since}`);
return createJsonResponse(events);
}, "get-workout-events"));
const createWorkoutSchema = {
title: z.string().min(1),
description: z.string().optional().nullable(),
startTime: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/),
endTime: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/),
isPrivate: z.boolean().default(false),
exercises: z.preprocess(parseJsonArray, z.array(z.object({
exerciseTemplateId: z.string().min(1),
supersetId: z.coerce.number().nullable().optional(),
notes: z.string().optional().nullable(),
sets: z.array(z.object({
type: z.enum([
"warmup",
"normal",
"failure",
"dropset"
]).default("normal"),
weight: z.coerce.number().optional().nullable(),
weightKg: z.coerce.number().optional().nullable(),
reps: z.coerce.number().int().optional().nullable(),
distance: z.coerce.number().int().optional().nullable(),
distanceMeters: z.coerce.number().int().optional().nullable(),
duration: z.coerce.number().int().optional().nullable(),
durationSeconds: z.coerce.number().int().optional().nullable(),
rpe: z.coerce.number().optional().nullable(),
customMetric: z.coerce.number().optional().nullable()
}))
})))
};
server.tool("create-workout", "Create a new workout in your Hevy account. Requires title, start/end times, and at least one exercise with sets. Returns the complete workout details upon successful creation including the newly assigned workout ID.", createWorkoutSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { title, description, startTime, endTime, isPrivate, exercises } = args;
const requestBody = { workout: {
title,
description: description ?? null,
start_time: startTime,
end_time: endTime,
is_private: isPrivate,
exercises: exercises.map((exercise) => ({
exercise_template_id: exercise.exerciseTemplateId,
superset_id: exercise.supersetId ?? null,
notes: exercise.notes ?? null,
sets: exercise.sets.map((set) => ({
type: set.type,
weight_kg: set.weight ?? set.weightKg ?? null,
reps: set.reps ?? null,
distance_meters: set.distance ?? set.distanceMeters ?? null,
duration_seconds: set.duration ?? set.durationSeconds ?? null,
rpe: set.rpe ?? null,
custom_metric: set.customMetric ?? null
}))
}))
} };
const data = await hevyClient.createWorkout(requestBody);
if (!data) return createEmptyResponse("Failed to create workout: Server returned no data");
return createJsonResponse(formatWorkout(data), {
pretty: true,
indent: 2
});
}, "create-workout"));
const updateWorkoutSchema = {
workoutId: z.string().min(1),
title: z.string().min(1),
description: z.string().optional().nullable(),
startTime: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/),
endTime: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/),
isPrivate: z.boolean().default(false),
exercises: z.preprocess(parseJsonArray, z.array(z.object({
exerciseTemplateId: z.string().min(1),
supersetId: z.coerce.number().nullable().optional(),
notes: z.string().optional().nullable(),
sets: z.array(z.object({
type: z.enum([
"warmup",
"normal",
"failure",
"dropset"
]).default("normal"),
weight: z.coerce.number().optional().nullable(),
weightKg: z.coerce.number().optional().nullable(),
reps: z.coerce.number().int().optional().nullable(),
distance: z.coerce.number().int().optional().nullable(),
distanceMeters: z.coerce.number().int().optional().nullable(),
duration: z.coerce.number().int().optional().nullable(),
durationSeconds: z.coerce.number().int().optional().nullable(),
rpe: z.coerce.number().optional().nullable(),
customMetric: z.coerce.number().optional().nullable()
}))
})))
};
server.tool("update-workout", "Update an existing workout by ID. You can modify the title, description, start/end times, privacy setting, and exercise data. Returns the updated workout with all changes applied.", updateWorkoutSchema, withErrorHandling(async (args) => {
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
const { workoutId, title, description, startTime, endTime, isPrivate, exercises } = args;
const requestBody = { workout: {
title,
description: description ?? null,
start_time: startTime,
end_time: endTime,
is_private: isPrivate,
exercises: exercises.map((exercise) => ({
exercise_template_id: exercise.exerciseTemplateId,
superset_id: exercise.supersetId ?? null,
notes: exercise.notes ?? null,
sets: exercise.sets.map((set) => ({
type: set.type,
weight_kg: set.weight ?? set.weightKg ?? null,
reps: set.reps ?? null,
distance_meters: set.distance ?? set.distanceMeters ?? null,
duration_seconds: set.duration ?? set.durationSeconds ?? null,
rpe: set.rpe ?? null,
custom_metric: set.customMetric ?? null
}))
}))
} };
const data = await hevyClient.updateWorkout(workoutId, requestBody);
if (!data) return createEmptyResponse(`Failed to update workout with ID ${workoutId}`);
return createJsonResponse(formatWorkout(data), {
pretty: true,
indent: 2
});
}, "update-workout-operation"));
}
//#endregion
//#region src/utils/config.ts
/**
* Parse CLI arguments and environment to derive configuration.
* Priority order for API key: CLI flag forms > environment variable.
* Supported CLI arg forms:
* --hevy-api-key=KEY
* --hevyApiKey=KEY
* hevy-api-key=KEY (bare, e.g. when passed after npm start -- )
*/
function parseConfig(argv, env) {
let apiKey = "";
const apiKeyArgPatterns = [
/^--hevy-api-key=(.+)$/i,
/^--hevyApiKey=(.+)$/i,
/^hevy-api-key=(.+)$/i
];
for (const raw of argv) {
for (const pattern of apiKeyArgPatterns) {
const m = raw.match(pattern);
if (m) {
apiKey = m[1];
break;
}
}
if (apiKey) break;
}
if (!apiKey) apiKey = env.HEVY_API_KEY || "";
return { apiKey };
}
function assertApiKey(apiKey) {
if (!apiKey) {
console.error("Hevy API key is required. Provide it via the HEVY_API_KEY environment variable or the --hevy-api-key=YOUR_KEY command argument.");
process.exit(1);
}
}
//#endregion
//#region src/generated/client/api/getV1BodyMeasurements.ts
/**
* Generated by Kubb (https://kubb.dev/).
* Do not edit manually.
*/
function getGetV1BodyMeasurementsUrl() {
return {
method: "GET",
url: `/v1/body_measurements`
};
}
/**
* @summary Get a paginated list of body measurements for the authenticated user
* {@link /v1/body_measurements}
*/
async function getV1BodyMeasurements(headers, params, config = {}) {
const { client: request = fetch, ...requestConfig } = config;
return (await request({
method: "GET",
url: getGetV1BodyMeasurementsUrl().url.toString(),
params,
...requestConfig,
headers: {
...headers,
...requestConfig.headers
}
})).data;
}
//#endregion
//#region src/generated/client/api/getV1BodyMeasurementsDate.ts
/**
* Generated by Kubb (https://kubb.dev/).
* Do not edit manually.
*/
function getGetV1BodyMeasurementsDateUrl(date) {
return {
method: "GET",
url: `/v1/body_measurements/${date}`
};
}
/**
* @summary Get a single body measurement by date
* {@link /v1/body_measurements/:date}
*/
async function getV1BodyMeasurementsDate(date, headers, config = {}) {
const { client: request = fetch, ...requestConfig } = config;
return (await request({
method: "GET",
url: getGetV1BodyMeasurementsDateUrl(date).url.toString(),
...requestConfig,
headers: {
...headers,
...requestConfig.headers
}
})).data;
}
//#endregion
//#region src/generated/client/api/getV1ExerciseHistoryExercisetemplateid.ts
/**
* Generated by Kubb (https://kubb.dev/).
* Do not edit manually.
*/
function getGetV1ExerciseHistoryExercisetemplateidUrl(exerciseTemplateId) {
return {
method: "GET",
url: `/v1/exercise_history/${exerciseTemplateId}`
};
}
/**
* @summary Get exercise history for a specific exercise template
* {@link /v1/exercise_history/:exerciseTemplateId}
*/
async function getV1ExerciseHistoryExercisetemplateid(exerciseTemplateId, headers, params, config = {}) {
const { client: request = fetch, ...requestConfig } = config;
return (await request({
method: "GET",
url: getGetV1ExerciseHistoryExercisetemplateidUrl(exerciseTemplateId).url.toString(),
params,
...requestConfig,
headers: {
...headers,
...requestConfig.headers
}
})).data;
}
//#endregion
//#region src/generated/client/api/getV1ExerciseTemplates.ts
/**
* Generated by Kubb (https://kubb.dev/).
* Do not edit manually.
*/
function getGetV1ExerciseTemplatesUrl() {
return {
method: "GET",
url: `/v1/exercise_templates`
};
}
/**
* @summary Get a paginated list of exercise templates available on the account.
* {@link /v1/exercise_templates}
*/
async function getV1ExerciseTemplates(headers, params, config = {}) {
const { client: request = fetch, ...requestConfig } = config;
return (await request({
method: "GET",
url: getGetV1ExerciseTemplatesUrl().url.toString(),
params,
...requestConfig,
headers: {
...headers,
...requestConfig.headers
}
})).data;
}
//#endregion
//#region src/generated/client/api/getV1ExerciseTemplatesExercisetemplateid.ts
/**
* Generated by Kubb (https://kubb.dev/).
* Do not edit manually.
*/
function getGetV1ExerciseTemplatesExercisetemplateidUrl(exerciseTemplateId) {
return {
method: "GET",
url: `/v1/exercise_templates/${exerciseTemplateId}`
};
}
/**
* @summary Get a single exerci