hevy-mcp
Version:
A Model Context Protocol (MCP) server implementation that interfaces with the Hevy fitness tracking app and its API.
1 lines • 151 kB
Source Map (JSON)
{"version":3,"file":"src-FgRvXrfB.mjs","names":["createClient","api.getV1Workouts","api.getV1WorkoutsWorkoutid","api.postV1Workouts","api.putV1WorkoutsWorkoutid","api.getV1WorkoutsCount","api.getV1WorkoutsEvents","api.getV1Routines","api.getV1RoutinesRoutineid","api.postV1Routines","api.putV1RoutinesRoutineid","api.getV1ExerciseTemplates","api.getV1ExerciseTemplatesExercisetemplateid","api.getV1ExerciseHistoryExercisetemplateid","api.postV1ExerciseTemplates","api.getV1RoutineFolders","api.postV1RoutineFolders","api.getV1RoutineFoldersFolderid","api.getV1BodyMeasurements","api.getV1BodyMeasurementsDate","api.postV1BodyMeasurements","api.putV1BodyMeasurementsDate","createKubbClient"],"sources":["../src/utils/error-handler.ts","../src/utils/formatters.ts","../src/utils/response-formatter.ts","../src/tools/body-measurements.ts","../src/tools/folders.ts","../src/utils/json-parser.ts","../src/tools/routines.ts","../src/tools/templates.ts","../src/tools/webhooks.ts","../src/tools/workouts.ts","../src/utils/config.ts","../src/generated/client/api/getV1BodyMeasurements.ts","../src/generated/client/api/getV1BodyMeasurementsDate.ts","../src/generated/client/api/getV1ExerciseHistoryExercisetemplateid.ts","../src/generated/client/api/getV1ExerciseTemplates.ts","../src/generated/client/api/getV1ExerciseTemplatesExercisetemplateid.ts","../src/generated/client/api/getV1RoutineFolders.ts","../src/generated/client/api/getV1RoutineFoldersFolderid.ts","../src/generated/client/api/getV1Routines.ts","../src/generated/client/api/getV1RoutinesRoutineid.ts","../src/generated/client/api/getV1Workouts.ts","../src/generated/client/api/getV1WorkoutsCount.ts","../src/generated/client/api/getV1WorkoutsEvents.ts","../src/generated/client/api/getV1WorkoutsWorkoutid.ts","../src/generated/client/api/postV1BodyMeasurements.ts","../src/generated/client/api/postV1ExerciseTemplates.ts","../src/generated/client/api/postV1RoutineFolders.ts","../src/generated/client/api/postV1Routines.ts","../src/generated/client/api/postV1Workouts.ts","../src/generated/client/api/putV1BodyMeasurementsDate.ts","../src/generated/client/api/putV1RoutinesRoutineid.ts","../src/generated/client/api/putV1WorkoutsWorkoutid.ts","../src/utils/hevyClientKubb.ts","../src/utils/hevyClient.ts","../src/index.ts"],"sourcesContent":["/**\n * Centralized error handling utility for MCP tools\n */\n\n// Import the McpToolResponse type from response-formatter to ensure consistency\nimport { isAxiosError } from \"axios\";\nimport type { McpToolResponse } from \"./response-formatter.js\";\n\n/**\n * Standard error response interface\n */\nexport interface ErrorResponse {\n\tmessage: string;\n\tcode?: string;\n\tdetails?: unknown;\n}\n\n/**\n * Specific error types for better categorization\n */\nexport enum ErrorType {\n\tAPI_ERROR = \"API_ERROR\",\n\tVALIDATION_ERROR = \"VALIDATION_ERROR\",\n\tNOT_FOUND = \"NOT_FOUND\",\n\tNETWORK_ERROR = \"NETWORK_ERROR\",\n\tUNKNOWN_ERROR = \"UNKNOWN_ERROR\",\n}\n\n/**\n * Enhanced error response with type categorization\n */\nexport interface EnhancedErrorResponse extends ErrorResponse {\n\ttype: ErrorType;\n}\n\n/**\n * Create a standardized error response for MCP tools\n *\n * @param error - The error object or message\n * @param context - Optional context information about where the error occurred\n * @returns A formatted MCP tool response with error information\n */\nexport function createErrorResponse(\n\terror: unknown,\n\tcontext?: string,\n): McpToolResponse {\n\t// Extract axios response data if available\n\tlet errorMessage = error instanceof Error ? error.message : String(error);\n\n\t// Check for axios error with response data\n\tif (isAxiosError(error) && error.response?.data) {\n\t\tconst { data } = error.response;\n\t\tif (typeof data === \"string\") {\n\t\t\terrorMessage = data;\n\t\t} else if (data && typeof data === \"object\") {\n\t\t\ttry {\n\t\t\t\terrorMessage = JSON.stringify(data);\n\t\t\t} catch (_e) {\n\t\t\t\terrorMessage = String(data);\n\t\t\t}\n\t\t}\n\t}\n\n\t// Extract error code if available (for logging purposes)\n\tconst errorCode =\n\t\terror instanceof Error && \"code\" in error\n\t\t\t? (error as { code?: string }).code\n\t\t\t: undefined;\n\n\t// Determine error type based on error characteristics\n\tconst errorType = determineErrorType(error, errorMessage);\n\n\t// Include error code in logs if available\n\tif (errorCode) {\n\t\tconsole.debug(`Error code: ${errorCode}`);\n\t}\n\n\tconst contextPrefix = context ? `[${context}] ` : \"\";\n\tconst formattedMessage = `${contextPrefix}Error: ${errorMessage}`;\n\n\t// Log the error for server-side debugging with type information\n\tconsole.error(`${formattedMessage} (Type: ${errorType})`, error);\n\n\treturn {\n\t\tcontent: [\n\t\t\t{\n\t\t\t\ttype: \"text\" as const,\n\t\t\t\ttext: formattedMessage,\n\t\t\t},\n\t\t],\n\t\tisError: true,\n\t};\n}\n\n/**\n * Determine the type of error based on error characteristics\n */\nfunction determineErrorType(error: unknown, message: string): ErrorType {\n\tconst messageLower = message.toLowerCase();\n\tconst nameLower = error instanceof Error ? error.name.toLowerCase() : \"\";\n\n\tif (\n\t\tnameLower.includes(\"network\") ||\n\t\tmessageLower.includes(\"network\") ||\n\t\tnameLower.includes(\"fetch\") ||\n\t\tmessageLower.includes(\"fetch\") ||\n\t\tnameLower.includes(\"timeout\") ||\n\t\tmessageLower.includes(\"timeout\")\n\t) {\n\t\treturn ErrorType.NETWORK_ERROR;\n\t}\n\n\tif (\n\t\tnameLower.includes(\"validation\") ||\n\t\tmessageLower.includes(\"validation\") ||\n\t\tmessageLower.includes(\"invalid\") ||\n\t\tmessageLower.includes(\"required\")\n\t) {\n\t\treturn ErrorType.VALIDATION_ERROR;\n\t}\n\n\tif (\n\t\tmessageLower.includes(\"not found\") ||\n\t\tmessageLower.includes(\"404\") ||\n\t\tmessageLower.includes(\"does not exist\")\n\t) {\n\t\treturn ErrorType.NOT_FOUND;\n\t}\n\n\tif (\n\t\tnameLower.includes(\"api\") ||\n\t\tmessageLower.includes(\"api\") ||\n\t\tmessageLower.includes(\"server error\") ||\n\t\tmessageLower.includes(\"500\")\n\t) {\n\t\treturn ErrorType.API_ERROR;\n\t}\n\n\treturn ErrorType.UNKNOWN_ERROR;\n}\n\n/**\n * Wrap an async function with standardized error handling\n *\n * This function preserves the parameter types of the wrapped function while\n * providing error handling. The returned function accepts Record<string, unknown>\n * (as required by MCP SDK) but internally casts to the original parameter type.\n *\n * @param fn - The async function to wrap\n * @param context - Context information for error messages\n * @returns A function that catches errors and returns standardized error responses\n */\nexport function withErrorHandling<TParams extends Record<string, unknown>>(\n\tfn: (args: TParams) => Promise<McpToolResponse>,\n\tcontext: string,\n): (args: Record<string, unknown>) => Promise<McpToolResponse> {\n\treturn async (args: Record<string, unknown>) => {\n\t\ttry {\n\t\t\treturn await fn(args as TParams);\n\t\t} catch (error) {\n\t\t\treturn createErrorResponse(error, context);\n\t\t}\n\t};\n}\n","import type {\n\tBodyMeasurement,\n\tExerciseHistoryEntry,\n\tExerciseTemplate,\n\tRoutine,\n\tRoutineFolder,\n\tWorkout,\n} from \"../generated/client/types/index.js\";\n\n/**\n * Formatted workout set interface\n */\nexport interface FormattedWorkoutSet {\n\tindex: number | undefined;\n\ttype: string | undefined;\n\tweight: number | undefined | null;\n\treps: number | undefined | null;\n\tdistance: number | undefined | null;\n\tduration: number | undefined | null;\n\trpe: number | undefined | null;\n\tcustomMetric: number | undefined | null;\n}\n\n/**\n * Formatted workout exercise interface\n */\nexport interface FormattedWorkoutExercise {\n\tindex: number | undefined;\n\tname: string | undefined;\n\texerciseTemplateId: string | undefined;\n\tnotes: string | undefined | null;\n\tsupersetsId: number | undefined | null;\n\tsets: FormattedWorkoutSet[] | undefined;\n}\n\n/**\n * Formatted workout interface\n */\nexport interface FormattedWorkout {\n\tid: string | undefined;\n\ttitle: string | undefined;\n\tdescription: string | undefined | null;\n\tstartTime: string | number | undefined;\n\tendTime: string | number | undefined;\n\tcreatedAt: string | undefined;\n\tupdatedAt: string | undefined;\n\tduration: string;\n\texercises: FormattedWorkoutExercise[] | undefined;\n}\n\n/**\n * Formatted routine set interface\n */\nexport interface FormattedRoutineSet {\n\tindex: number | undefined;\n\ttype: string | undefined;\n\tweight: number | undefined | null;\n\treps: number | undefined | null;\n\tdistance: number | undefined | null;\n\tduration: number | undefined | null;\n\tcustomMetric: number | undefined | null;\n\trepRange?: { start?: number | null; end?: number | null } | undefined | null;\n\trpe?: number | undefined | null;\n}\n\n/**\n * Formatted routine exercise interface\n */\nexport interface FormattedRoutineExercise {\n\tname: string | undefined;\n\tindex: number | undefined;\n\texerciseTemplateId: string | undefined;\n\tnotes: string | undefined | null;\n\tsupersetId: number | undefined | null;\n\trestSeconds: string | undefined;\n\tsets: FormattedRoutineSet[] | undefined;\n}\n\n/**\n * Formatted routine interface\n */\nexport interface FormattedRoutine {\n\tid: string | undefined;\n\ttitle: string | undefined;\n\tfolderId: number | undefined | null;\n\tcreatedAt: string | undefined;\n\tupdatedAt: string | undefined;\n\texercises: FormattedRoutineExercise[] | undefined;\n}\n\n/**\n * Formatted routine folder interface\n */\nexport interface FormattedRoutineFolder {\n\tid: number | undefined;\n\ttitle: string | undefined;\n\tcreatedAt: string | undefined;\n\tupdatedAt: string | undefined;\n}\n\n/**\n * Formatted exercise template interface\n */\nexport interface FormattedExerciseTemplate {\n\tid: string | undefined;\n\ttitle: string | undefined;\n\ttype: string | undefined;\n\tprimaryMuscleGroup: string | undefined;\n\tsecondaryMuscleGroups: string[] | undefined;\n\tisCustom: boolean | undefined;\n}\n\nexport interface FormattedExerciseHistoryEntry {\n\tworkoutId: string | undefined;\n\tworkoutTitle: string | undefined;\n\tworkoutStartTime: string | undefined;\n\tworkoutEndTime: string | undefined;\n\texerciseTemplateId: string | undefined;\n\tweight: number | undefined | null;\n\treps: number | undefined | null;\n\tdistance: number | undefined | null;\n\tduration: number | undefined | null;\n\trpe: number | undefined | null;\n\tcustomMetric: number | undefined | null;\n\tsetType: string | undefined;\n}\n\n/**\n * Format a workout object for consistent presentation\n *\n * @param workout - The workout object from the API\n * @returns A formatted workout object with standardized properties\n */\nexport function formatWorkout(workout: Workout): FormattedWorkout {\n\treturn {\n\t\tid: workout.id,\n\t\ttitle: workout.title,\n\t\tdescription: workout.description,\n\t\tstartTime: workout.start_time,\n\t\tendTime: workout.end_time,\n\t\tcreatedAt: workout.created_at,\n\t\tupdatedAt: workout.updated_at,\n\t\tduration: calculateDuration(workout.start_time, workout.end_time),\n\t\texercises: workout.exercises?.map((exercise) => {\n\t\t\treturn {\n\t\t\t\tindex: exercise.index,\n\t\t\t\tname: exercise.title,\n\t\t\t\texerciseTemplateId: exercise.exercise_template_id,\n\t\t\t\tnotes: exercise.notes,\n\t\t\t\tsupersetsId: exercise.supersets_id,\n\t\t\t\tsets: exercise.sets?.map((set) => ({\n\t\t\t\t\tindex: set.index,\n\t\t\t\t\ttype: set.type,\n\t\t\t\t\tweight: set.weight_kg,\n\t\t\t\t\treps: set.reps,\n\t\t\t\t\tdistance: set.distance_meters,\n\t\t\t\t\tduration: set.duration_seconds,\n\t\t\t\t\trpe: set.rpe,\n\t\t\t\t\tcustomMetric: set.custom_metric,\n\t\t\t\t})),\n\t\t\t};\n\t\t}),\n\t};\n}\n\n/**\n * Format a routine object for consistent presentation\n *\n * @param routine - The routine object from the API\n * @returns A formatted routine object with standardized properties\n */\nexport function formatRoutine(routine: Routine): FormattedRoutine {\n\treturn {\n\t\tid: routine.id,\n\t\ttitle: routine.title,\n\t\tfolderId: routine.folder_id,\n\t\tcreatedAt: routine.created_at,\n\t\tupdatedAt: routine.updated_at,\n\t\texercises: routine.exercises?.map((exercise) => {\n\t\t\treturn {\n\t\t\t\tname: exercise.title,\n\t\t\t\tindex: exercise.index,\n\t\t\t\texerciseTemplateId: exercise.exercise_template_id,\n\t\t\t\tnotes: exercise.notes,\n\t\t\t\tsupersetId: exercise.supersets_id,\n\t\t\t\trestSeconds: exercise.rest_seconds,\n\t\t\t\tsets: exercise.sets?.map((set) => ({\n\t\t\t\t\tindex: set.index,\n\t\t\t\t\ttype: set.type,\n\t\t\t\t\tweight: set.weight_kg,\n\t\t\t\t\treps: set.reps,\n\t\t\t\t\t...(set.rep_range !== undefined && { repRange: set.rep_range }),\n\t\t\t\t\tdistance: set.distance_meters,\n\t\t\t\t\tduration: set.duration_seconds,\n\t\t\t\t\t...(set.rpe !== undefined && { rpe: set.rpe }),\n\t\t\t\t\tcustomMetric: set.custom_metric,\n\t\t\t\t})),\n\t\t\t};\n\t\t}),\n\t};\n}\n\n/**\n * Format a routine folder object for consistent presentation\n *\n * @param folder - The routine folder object from the API\n * @returns A formatted routine folder object with standardized properties\n */\nexport function formatRoutineFolder(\n\tfolder: RoutineFolder,\n): FormattedRoutineFolder {\n\treturn {\n\t\tid: folder.id,\n\t\ttitle: folder.title,\n\t\tcreatedAt: folder.created_at,\n\t\tupdatedAt: folder.updated_at,\n\t};\n}\n\n/**\n * Calculate duration between two ISO timestamp strings\n *\n * @param startTime - The start time as ISO string or timestamp\n * @param endTime - The end time as ISO string or timestamp\n * @returns A formatted duration string (e.g. \"1h 30m 45s\") or \"Unknown duration\" if inputs are invalid\n */\nexport function calculateDuration(\n\tstartTime: string | number | null | undefined,\n\tendTime: string | number | null | undefined,\n): string {\n\tif (!startTime || !endTime) return \"Unknown duration\";\n\n\ttry {\n\t\tconst start = new Date(startTime);\n\t\tconst end = new Date(endTime);\n\n\t\t// Validate dates\n\t\tif (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {\n\t\t\treturn \"Unknown duration\";\n\t\t}\n\n\t\tconst durationMs = end.getTime() - start.getTime();\n\n\t\t// Handle negative durations\n\t\tif (durationMs < 0) {\n\t\t\treturn \"Invalid duration (end time before start time)\";\n\t\t}\n\n\t\tconst hours = Math.floor(durationMs / (1000 * 60 * 60));\n\t\tconst minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));\n\t\tconst seconds = Math.floor((durationMs % (1000 * 60)) / 1000);\n\n\t\treturn `${hours}h ${minutes}m ${seconds}s`;\n\t} catch (error) {\n\t\tconsole.error(\"Error calculating duration:\", error);\n\t\treturn \"Unknown duration\";\n\t}\n}\n\n/**\n * Format an exercise template object for consistent presentation\n *\n * @param template - The exercise template object from the API\n * @returns A formatted exercise template object with standardized properties\n */\nexport function formatExerciseTemplate(\n\ttemplate: ExerciseTemplate,\n): FormattedExerciseTemplate {\n\treturn {\n\t\tid: template.id,\n\t\ttitle: template.title,\n\t\ttype: template.type,\n\t\tprimaryMuscleGroup: template.primary_muscle_group,\n\t\tsecondaryMuscleGroups: template.secondary_muscle_groups,\n\t\tisCustom: template.is_custom,\n\t};\n}\n\nexport function formatExerciseHistoryEntry(\n\tentry: ExerciseHistoryEntry,\n): FormattedExerciseHistoryEntry {\n\treturn {\n\t\tworkoutId: entry.workout_id,\n\t\tworkoutTitle: entry.workout_title,\n\t\tworkoutStartTime: entry.workout_start_time,\n\t\tworkoutEndTime: entry.workout_end_time,\n\t\texerciseTemplateId: entry.exercise_template_id,\n\t\tweight: entry.weight_kg,\n\t\treps: entry.reps,\n\t\tdistance: entry.distance_meters,\n\t\tduration: entry.duration_seconds,\n\t\trpe: entry.rpe,\n\t\tcustomMetric: entry.custom_metric,\n\t\tsetType: entry.set_type,\n\t};\n}\n\nexport interface FormattedBodyMeasurement {\n\tdate: string;\n\tweightKg: number | null;\n\tleanMassKg: number | null;\n\tfatPercent: number | null;\n\tneckCm: number | null;\n\tshoulderCm: number | null;\n\tchestCm: number | null;\n\tleftBicepCm: number | null;\n\trightBicepCm: number | null;\n\tleftForearmCm: number | null;\n\trightForearmCm: number | null;\n\tabdomen: number | null;\n\twaist: number | null;\n\thips: number | null;\n\tleftThigh: number | null;\n\trightThigh: number | null;\n\tleftCalf: number | null;\n\trightCalf: number | null;\n}\n\nexport function formatBodyMeasurement(\n\tmeasurement: BodyMeasurement,\n): FormattedBodyMeasurement {\n\treturn {\n\t\tdate: measurement.date,\n\t\tweightKg: measurement.weight_kg ?? null,\n\t\tleanMassKg: measurement.lean_mass_kg ?? null,\n\t\tfatPercent: measurement.fat_percent ?? null,\n\t\tneckCm: measurement.neck_cm ?? null,\n\t\tshoulderCm: measurement.shoulder_cm ?? null,\n\t\tchestCm: measurement.chest_cm ?? null,\n\t\tleftBicepCm: measurement.left_bicep_cm ?? null,\n\t\trightBicepCm: measurement.right_bicep_cm ?? null,\n\t\tleftForearmCm: measurement.left_forearm_cm ?? null,\n\t\trightForearmCm: measurement.right_forearm_cm ?? null,\n\t\tabdomen: measurement.abdomen ?? null,\n\t\twaist: measurement.waist ?? null,\n\t\thips: measurement.hips ?? null,\n\t\tleftThigh: measurement.left_thigh ?? null,\n\t\trightThigh: measurement.right_thigh ?? null,\n\t\tleftCalf: measurement.left_calf ?? null,\n\t\trightCalf: measurement.right_calf ?? null,\n\t};\n}\n","/**\n * MCP Tool Response type\n */\nexport interface McpToolResponse {\n\t[x: string]: unknown;\n\tcontent: Array<{\n\t\ttype: \"text\";\n\t\ttext: string;\n\t}>;\n}\n\n/**\n * Format options for JSON responses\n */\nexport interface JsonFormatOptions {\n\t/** Whether to pretty-print the JSON with indentation */\n\tpretty?: boolean;\n\t/** Indentation spaces for pretty-printing (default: 2) */\n\tindent?: number;\n}\n\n/**\n * Create a standardized success response with JSON data\n *\n * @param data - The data to include in the response\n * @param options - Formatting options\n * @returns A formatted MCP tool response with the data as JSON\n */\nexport function createJsonResponse(\n\tdata: unknown,\n\toptions: JsonFormatOptions = { pretty: true, indent: 2 },\n): McpToolResponse {\n\tconst jsonString = options.pretty\n\t\t? JSON.stringify(data, null, options.indent)\n\t\t: JSON.stringify(data);\n\n\treturn {\n\t\tcontent: [\n\t\t\t{\n\t\t\t\ttype: \"text\" as const,\n\t\t\t\ttext: jsonString,\n\t\t\t},\n\t\t],\n\t};\n}\n\n/**\n * Create a standardized success response with text data\n *\n * @param message - The text message to include in the response\n * @returns A formatted MCP tool response with the text message\n */\nexport function createTextResponse(message: string): McpToolResponse {\n\treturn {\n\t\tcontent: [\n\t\t\t{\n\t\t\t\ttype: \"text\" as const,\n\t\t\t\ttext: message,\n\t\t\t},\n\t\t],\n\t};\n}\n\n/**\n * Create a standardized success response for empty or null results\n *\n * @param message - Optional message to include (default: \"No data found\")\n * @returns A formatted MCP tool response for empty results\n */\nexport function createEmptyResponse(\n\tmessage = \"No data found\",\n): McpToolResponse {\n\treturn {\n\t\tcontent: [\n\t\t\t{\n\t\t\t\ttype: \"text\" as const,\n\t\t\t\ttext: message,\n\t\t\t},\n\t\t],\n\t};\n}\n","import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport type {\n\tBodyMeasurement,\n\tGetV1BodyMeasurements200,\n\tGetV1BodyMeasurementsDate200,\n} from \"../generated/client/types/index.js\";\nimport { withErrorHandling } from \"../utils/error-handler.js\";\nimport { formatBodyMeasurement } from \"../utils/formatters.js\";\nimport {\n\tcreateEmptyResponse,\n\tcreateJsonResponse,\n\tcreateTextResponse,\n} from \"../utils/response-formatter.js\";\nimport type { InferToolParams } from \"../utils/tool-helpers.js\";\n\ntype HevyClient = ReturnType<\n\ttypeof import(\"../utils/hevyClientKubb.js\").createClient\n>;\n\nconst zNullableNumber = z.number().nullable().optional();\n\nconst bodyMeasurementFieldsSchema = {\n\tweightKg: zNullableNumber.describe(\"Body weight in kilograms\"),\n\tleanMassKg: zNullableNumber.describe(\"Lean body mass in kilograms\"),\n\tfatPercent: zNullableNumber.describe(\"Body fat percentage\"),\n\tneckCm: zNullableNumber.describe(\"Neck circumference in centimeters\"),\n\tshoulderCm: zNullableNumber.describe(\"Shoulder circumference in centimeters\"),\n\tchestCm: zNullableNumber.describe(\"Chest circumference in centimeters\"),\n\tleftBicepCm: zNullableNumber.describe(\n\t\t\"Left bicep circumference in centimeters\",\n\t),\n\trightBicepCm: zNullableNumber.describe(\n\t\t\"Right bicep circumference in centimeters\",\n\t),\n\tleftForearmCm: zNullableNumber.describe(\n\t\t\"Left forearm circumference in centimeters\",\n\t),\n\trightForearmCm: zNullableNumber.describe(\n\t\t\"Right forearm circumference in centimeters\",\n\t),\n\tabdomen: zNullableNumber.describe(\"Abdomen circumference in centimeters\"),\n\twaist: zNullableNumber.describe(\"Waist circumference in centimeters\"),\n\thips: zNullableNumber.describe(\"Hips circumference in centimeters\"),\n\tleftThigh: zNullableNumber.describe(\n\t\t\"Left thigh circumference in centimeters\",\n\t),\n\trightThigh: zNullableNumber.describe(\n\t\t\"Right thigh circumference in centimeters\",\n\t),\n\tleftCalf: zNullableNumber.describe(\"Left calf circumference in centimeters\"),\n\trightCalf: zNullableNumber.describe(\n\t\t\"Right calf circumference in centimeters\",\n\t),\n} as const;\n\nfunction buildMeasurementPayload(args: {\n\tweightKg?: number | null;\n\tleanMassKg?: number | null;\n\tfatPercent?: number | null;\n\tneckCm?: number | null;\n\tshoulderCm?: number | null;\n\tchestCm?: number | null;\n\tleftBicepCm?: number | null;\n\trightBicepCm?: number | null;\n\tleftForearmCm?: number | null;\n\trightForearmCm?: number | null;\n\tabdomen?: number | null;\n\twaist?: number | null;\n\thips?: number | null;\n\tleftThigh?: number | null;\n\trightThigh?: number | null;\n\tleftCalf?: number | null;\n\trightCalf?: number | null;\n}) {\n\treturn {\n\t\tweight_kg: args.weightKg ?? null,\n\t\tlean_mass_kg: args.leanMassKg ?? null,\n\t\tfat_percent: args.fatPercent ?? null,\n\t\tneck_cm: args.neckCm ?? null,\n\t\tshoulder_cm: args.shoulderCm ?? null,\n\t\tchest_cm: args.chestCm ?? null,\n\t\tleft_bicep_cm: args.leftBicepCm ?? null,\n\t\tright_bicep_cm: args.rightBicepCm ?? null,\n\t\tleft_forearm_cm: args.leftForearmCm ?? null,\n\t\tright_forearm_cm: args.rightForearmCm ?? null,\n\t\tabdomen: args.abdomen ?? null,\n\t\twaist: args.waist ?? null,\n\t\thips: args.hips ?? null,\n\t\tleft_thigh: args.leftThigh ?? null,\n\t\tright_thigh: args.rightThigh ?? null,\n\t\tleft_calf: args.leftCalf ?? null,\n\t\tright_calf: args.rightCalf ?? null,\n\t};\n}\n\nexport function registerBodyMeasurementTools(\n\tserver: McpServer,\n\thevyClient: HevyClient | null,\n) {\n\t// Get body measurements (paginated list)\n\tconst getBodyMeasurementsSchema = {\n\t\tpage: z.coerce.number().int().gte(1).default(1),\n\t\tpageSize: z.coerce.number().int().gte(1).lte(10).default(10),\n\t} as const;\n\ttype GetBodyMeasurementsParams = InferToolParams<\n\t\ttypeof getBodyMeasurementsSchema\n\t>;\n\n\tserver.tool(\n\t\t\"get-body-measurements\",\n\t\t\"Get a paginated list of body measurements for the authenticated user. Returns measurements including weight, body fat, and various circumference measurements.\",\n\t\tgetBodyMeasurementsSchema,\n\t\twithErrorHandling(async (args: GetBodyMeasurementsParams) => {\n\t\t\tif (!hevyClient) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"API client not initialized. Please provide HEVY_API_KEY.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst { page, pageSize } = args;\n\t\t\tconst data: GetV1BodyMeasurements200 =\n\t\t\t\tawait hevyClient.getBodyMeasurements({\n\t\t\t\t\tpage,\n\t\t\t\t\tpageSize,\n\t\t\t\t});\n\n\t\t\tconst measurements =\n\t\t\t\tdata?.body_measurements?.map((measurement: BodyMeasurement) =>\n\t\t\t\t\tformatBodyMeasurement(measurement),\n\t\t\t\t) || [];\n\n\t\t\tif (measurements.length === 0) {\n\t\t\t\treturn createEmptyResponse(\n\t\t\t\t\t\"No body measurements found for the specified parameters\",\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn createJsonResponse(measurements);\n\t\t}, \"get-body-measurements\"),\n\t);\n\n\t// Get single body measurement by date\n\tconst getBodyMeasurementSchema = {\n\t\tdate: z\n\t\t\t.string()\n\t\t\t.regex(/^\\d{4}-\\d{2}-\\d{2}$/, \"Date must be in YYYY-MM-DD format\")\n\t\t\t.describe(\"The date of the body measurement (YYYY-MM-DD)\"),\n\t} as const;\n\ttype GetBodyMeasurementParams = InferToolParams<\n\t\ttypeof getBodyMeasurementSchema\n\t>;\n\n\tserver.tool(\n\t\t\"get-body-measurement\",\n\t\t\"Get a single body measurement by date. Returns all measurement fields for the specified date.\",\n\t\tgetBodyMeasurementSchema,\n\t\twithErrorHandling(async (args: GetBodyMeasurementParams) => {\n\t\t\tif (!hevyClient) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"API client not initialized. Please provide HEVY_API_KEY.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst { date } = args;\n\t\t\tconst data: GetV1BodyMeasurementsDate200 =\n\t\t\t\tawait hevyClient.getBodyMeasurement(date);\n\n\t\t\tif (!data) {\n\t\t\t\treturn createEmptyResponse(\n\t\t\t\t\t`No body measurement found for date ${date}`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn createJsonResponse(formatBodyMeasurement(data));\n\t\t}, \"get-body-measurement\"),\n\t);\n\n\t// Create body measurement\n\tconst createBodyMeasurementSchema = {\n\t\tdate: z\n\t\t\t.string()\n\t\t\t.regex(/^\\d{4}-\\d{2}-\\d{2}$/, \"Date must be in YYYY-MM-DD format\")\n\t\t\t.describe(\n\t\t\t\t\"The date of the body measurement (YYYY-MM-DD). Must be unique — returns 409 if an entry already exists for this date.\",\n\t\t\t),\n\t\t...bodyMeasurementFieldsSchema,\n\t} as const;\n\ttype CreateBodyMeasurementParams = InferToolParams<\n\t\ttypeof createBodyMeasurementSchema\n\t>;\n\n\tserver.tool(\n\t\t\"create-body-measurement\",\n\t\t\"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.\",\n\t\tcreateBodyMeasurementSchema,\n\t\twithErrorHandling(async (args: CreateBodyMeasurementParams) => {\n\t\t\tif (!hevyClient) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"API client not initialized. Please provide HEVY_API_KEY.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst { date, ...fields } = args;\n\t\t\tawait hevyClient.createBodyMeasurement({\n\t\t\t\tdate,\n\t\t\t\t...buildMeasurementPayload(fields),\n\t\t\t});\n\n\t\t\treturn createTextResponse(\n\t\t\t\t`Body measurement for ${date} created successfully.`,\n\t\t\t);\n\t\t}, \"create-body-measurement\"),\n\t);\n\n\t// Update body measurement\n\tconst updateBodyMeasurementSchema = {\n\t\tdate: z\n\t\t\t.string()\n\t\t\t.regex(/^\\d{4}-\\d{2}-\\d{2}$/, \"Date must be in YYYY-MM-DD format\")\n\t\t\t.describe(\n\t\t\t\t\"The date of the body measurement to update (YYYY-MM-DD). Must already exist — returns 404 otherwise.\",\n\t\t\t),\n\t\t...bodyMeasurementFieldsSchema,\n\t} as const;\n\ttype UpdateBodyMeasurementParams = InferToolParams<\n\t\ttypeof updateBodyMeasurementSchema\n\t>;\n\n\tserver.tool(\n\t\t\"update-body-measurement\",\n\t\t\"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.\",\n\t\tupdateBodyMeasurementSchema,\n\t\twithErrorHandling(async (args: UpdateBodyMeasurementParams) => {\n\t\t\tif (!hevyClient) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"API client not initialized. Please provide HEVY_API_KEY.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst { date, ...fields } = args;\n\t\t\tawait hevyClient.updateBodyMeasurement(\n\t\t\t\tdate,\n\t\t\t\tbuildMeasurementPayload(fields),\n\t\t\t);\n\n\t\t\treturn createTextResponse(\n\t\t\t\t`Body measurement for ${date} updated successfully.`,\n\t\t\t);\n\t\t}, \"update-body-measurement\"),\n\t);\n}\n","import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\n// Import types from generated client\nimport type {\n\tGetV1RoutineFolders200,\n\tGetV1RoutineFoldersFolderid200,\n\tPostV1RoutineFolders201,\n\tRoutineFolder,\n} from \"../generated/client/types/index.js\";\nimport { withErrorHandling } from \"../utils/error-handler.js\";\nimport { formatRoutineFolder } from \"../utils/formatters.js\";\nimport {\n\tcreateEmptyResponse,\n\tcreateJsonResponse,\n} from \"../utils/response-formatter.js\";\nimport type { InferToolParams } from \"../utils/tool-helpers.js\";\n\n// Type definitions for the folder operations\ntype HevyClient = ReturnType<\n\ttypeof import(\"../utils/hevyClientKubb.js\").createClient\n>;\n\n/**\n * Register all routine folder-related tools with the MCP server\n */\nexport function registerFolderTools(\n\tserver: McpServer,\n\thevyClient: HevyClient | null,\n) {\n\t// Get routine folders\n\tconst getRoutineFoldersSchema = {\n\t\tpage: z.coerce.number().int().gte(1).default(1),\n\t\tpageSize: z.coerce.number().int().gte(1).lte(10).default(5),\n\t} as const;\n\ttype GetRoutineFoldersParams = InferToolParams<\n\t\ttypeof getRoutineFoldersSchema\n\t>;\n\n\tserver.tool(\n\t\t\"get-routine-folders\",\n\t\t\"Get a paginated list of your routine folders, including both default and custom folders. Useful for organizing and browsing your workout routines.\",\n\t\tgetRoutineFoldersSchema,\n\t\twithErrorHandling(async (args: GetRoutineFoldersParams) => {\n\t\t\tif (!hevyClient) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"API client not initialized. Please provide HEVY_API_KEY.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst { page, pageSize } = args;\n\t\t\tconst data: GetV1RoutineFolders200 = await hevyClient.getRoutineFolders({\n\t\t\t\tpage,\n\t\t\t\tpageSize,\n\t\t\t});\n\n\t\t\t// Process routine folders to extract relevant information\n\t\t\tconst folders =\n\t\t\t\tdata?.routine_folders?.map((folder: RoutineFolder) =>\n\t\t\t\t\tformatRoutineFolder(folder),\n\t\t\t\t) || [];\n\n\t\t\tif (folders.length === 0) {\n\t\t\t\treturn createEmptyResponse(\n\t\t\t\t\t\"No routine folders found for the specified parameters\",\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn createJsonResponse(folders);\n\t\t}, \"get-routine-folders\"),\n\t);\n\n\t// Get single routine folder by ID\n\tconst getRoutineFolderSchema = {\n\t\tfolderId: z.string().min(1),\n\t} as const;\n\ttype GetRoutineFolderParams = InferToolParams<typeof getRoutineFolderSchema>;\n\n\tserver.tool(\n\t\t\"get-routine-folder\",\n\t\t\"Get complete details of a specific routine folder by its ID, including name, creation date, and associated routines.\",\n\t\tgetRoutineFolderSchema,\n\t\twithErrorHandling(async (args: GetRoutineFolderParams) => {\n\t\t\tif (!hevyClient) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"API client not initialized. Please provide HEVY_API_KEY.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst { folderId } = args;\n\t\t\tconst data: GetV1RoutineFoldersFolderid200 =\n\t\t\t\tawait hevyClient.getRoutineFolder(folderId);\n\n\t\t\tif (!data) {\n\t\t\t\treturn createEmptyResponse(\n\t\t\t\t\t`Routine folder with ID ${folderId} not found`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tconst folder = formatRoutineFolder(data);\n\t\t\treturn createJsonResponse(folder);\n\t\t}, \"get-routine-folder\"),\n\t);\n\n\t// Create new routine folder\n\tconst createRoutineFolderSchema = {\n\t\tname: z.string().min(1),\n\t} as const;\n\ttype CreateRoutineFolderParams = InferToolParams<\n\t\ttypeof createRoutineFolderSchema\n\t>;\n\n\tserver.tool(\n\t\t\"create-routine-folder\",\n\t\t\"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.\",\n\t\tcreateRoutineFolderSchema,\n\t\twithErrorHandling(async (args: CreateRoutineFolderParams) => {\n\t\t\tif (!hevyClient) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"API client not initialized. Please provide HEVY_API_KEY.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst { name } = args;\n\t\t\tconst data: PostV1RoutineFolders201 =\n\t\t\t\tawait hevyClient.createRoutineFolder({\n\t\t\t\t\troutine_folder: {\n\t\t\t\t\t\ttitle: name,\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\tif (!data) {\n\t\t\t\treturn createEmptyResponse(\n\t\t\t\t\t\"Failed to create routine folder: Server returned no data\",\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tconst folder = formatRoutineFolder(data);\n\t\t\treturn createJsonResponse(folder, {\n\t\t\t\tpretty: true,\n\t\t\t\tindent: 2,\n\t\t\t});\n\t\t}, \"create-routine-folder\"),\n\t);\n}\n","/**\n * Preprocessor to handle MCP clients that send JSON-stringified arrays\n * instead of native arrays for complex parameters.\n *\n * This is used with Zod's z.preprocess to handle cases where MCP clients\n * serialize complex nested structures as JSON strings.\n *\n * @param val - The value to potentially parse\n * @returns The parsed array if val is a valid JSON string, otherwise returns val unchanged\n */\nexport function parseJsonArray(val: unknown): unknown {\n\t// Handle case where MCP client sends JSON string instead of array\n\tif (typeof val === \"string\") {\n\t\ttry {\n\t\t\treturn JSON.parse(val);\n\t\t} catch {\n\t\t\t// Let Zod validation handle the error\n\t\t\treturn val;\n\t\t}\n\t}\n\treturn val;\n}\n","import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\n// Import types from generated client\nimport type {\n\tGetV1Routines200,\n\tGetV1RoutinesRoutineid200,\n\tPostRoutinesRequestExercise,\n\tPostRoutinesRequestSet,\n\tPostRoutinesRequestSetTypeEnumKey,\n\tPostV1Routines201,\n\tPutRoutinesRequestExercise,\n\tPutRoutinesRequestSet,\n\tPutRoutinesRequestSetTypeEnumKey,\n\tPutV1RoutinesRoutineid200,\n\tRoutine,\n} from \"../generated/client/types/index.js\";\nimport { withErrorHandling } from \"../utils/error-handler.js\";\nimport { formatRoutine } from \"../utils/formatters.js\";\nimport { parseJsonArray } from \"../utils/json-parser.js\";\nimport {\n\tcreateEmptyResponse,\n\tcreateJsonResponse,\n} from \"../utils/response-formatter.js\";\nimport type { InferToolParams } from \"../utils/tool-helpers.js\";\n\n// Type definitions for the routine operations\ntype HevyClient = ReturnType<\n\ttypeof import(\"../utils/hevyClientKubb.js\").createClient\n>;\n\nfunction coerceNullishNumberInput(value: unknown): unknown {\n\tif (value === null || value === undefined) {\n\t\treturn value;\n\t}\n\n\tif (typeof value !== \"string\") {\n\t\treturn value;\n\t}\n\n\tconst trimmed = value.trim();\n\tif (trimmed === \"\") {\n\t\treturn undefined;\n\t}\n\n\tconst lowered = trimmed.toLowerCase();\n\tif (lowered === \"null\") {\n\t\treturn null;\n\t}\n\tif (lowered === \"undefined\") {\n\t\treturn undefined;\n\t}\n\n\tconst asNumber = Number(trimmed);\n\tif (Number.isNaN(asNumber)) {\n\t\treturn value;\n\t}\n\n\treturn asNumber;\n}\n\nconst zNullableInt = z.preprocess(\n\tcoerceNullishNumberInput,\n\tz.number().int().nullable().optional(),\n);\n\nconst zOptionalRepRange = z.preprocess(\n\t(value) => (value === null ? undefined : value),\n\tz\n\t\t.object({\n\t\t\tstart: zNullableInt,\n\t\t\tend: zNullableInt,\n\t\t})\n\t\t.optional(),\n);\n\nfunction buildRepRange(repRange?: {\n\tstart?: number | null;\n\tend?: number | null;\n}): { start?: number; end?: number } | null {\n\tif (!repRange) {\n\t\treturn null;\n\t}\n\n\tconst start = repRange.start ?? undefined;\n\tconst end = repRange.end ?? undefined;\n\tif (start === undefined && end === undefined) {\n\t\treturn null;\n\t}\n\n\treturn { start, end };\n}\n\n/**\n * Returns a fixed rep count when `repRange` is a fixed range (start and end are\n * both non-null and equal). Otherwise returns null.\n */\nfunction getFixedRepsFromRepRange(\n\trepRange:\n\t\t| {\n\t\t\t\tstart?: number | null;\n\t\t\t\tend?: number | null;\n\t\t }\n\t\t| null\n\t\t| undefined,\n): number | null {\n\tif (!repRange) {\n\t\treturn null;\n\t}\n\n\tconst start = repRange.start ?? null;\n\tconst end = repRange.end ?? null;\n\tif (start === null || end === null) {\n\t\treturn null;\n\t}\n\tif (start !== end) {\n\t\treturn null;\n\t}\n\n\treturn start;\n}\n\nconst repRangeDisplayWarningText =\n\t\"Note: Hevy's public API stores rep ranges (rep_range), but the Hevy apps may \" +\n\t\"not display them because they rely on an internal-only exercise field \" +\n\t\"(input_modifier). See https://github.com/chrisdoc/hevy-mcp/issues/261 for \" +\n\t\"details/workarounds.\";\n\n/**\n * Register all routine-related tools with the MCP server\n */\nexport function registerRoutineTools(\n\tserver: McpServer,\n\thevyClient: HevyClient | null,\n) {\n\t// Get routines\n\tconst getRoutinesSchema = {\n\t\tpage: z.coerce.number().int().gte(1).default(1),\n\t\tpageSize: z.coerce.number().int().gte(1).lte(10).default(5),\n\t} as const;\n\ttype GetRoutinesParams = InferToolParams<typeof getRoutinesSchema>;\n\n\tserver.tool(\n\t\t\"get-routines\",\n\t\t\"Get a paginated list of your workout routines, including custom and default routines. Useful for browsing or searching your available routines.\",\n\t\tgetRoutinesSchema,\n\t\twithErrorHandling(async (args: GetRoutinesParams) => {\n\t\t\tif (!hevyClient) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"API client not initialized. Please provide HEVY_API_KEY.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst { page, pageSize } = args;\n\t\t\tconst data: GetV1Routines200 = await hevyClient.getRoutines({\n\t\t\t\tpage,\n\t\t\t\tpageSize,\n\t\t\t});\n\n\t\t\t// Process routines to extract relevant information\n\t\t\tconst routines =\n\t\t\t\tdata?.routines?.map((routine: Routine) => formatRoutine(routine)) || [];\n\n\t\t\tif (routines.length === 0) {\n\t\t\t\treturn createEmptyResponse(\n\t\t\t\t\t\"No routines found for the specified parameters\",\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn createJsonResponse(routines);\n\t\t}, \"get-routines\"),\n\t);\n\n\t// Get single routine by ID (new, direct endpoint)\n\tconst getRoutineSchema = {\n\t\troutineId: z.string().min(1),\n\t} as const;\n\ttype GetRoutineParams = InferToolParams<typeof getRoutineSchema>;\n\n\tserver.tool(\n\t\t\"get-routine\",\n\t\t\"Get a routine by its ID using the direct endpoint. Returns all details for the specified routine.\",\n\t\tgetRoutineSchema,\n\t\twithErrorHandling(async (args: GetRoutineParams) => {\n\t\t\tif (!hevyClient) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"API client not initialized. Please provide HEVY_API_KEY.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst { routineId } = args;\n\t\t\tconst data: GetV1RoutinesRoutineid200 = await hevyClient.getRoutineById(\n\t\t\t\tString(routineId),\n\t\t\t);\n\t\t\tif (!data || !data.routine) {\n\t\t\t\treturn createEmptyResponse(`Routine with ID ${routineId} not found`);\n\t\t\t}\n\t\t\tconst routine = formatRoutine(data.routine);\n\t\t\treturn createJsonResponse(routine);\n\t\t}, \"get-routine\"),\n\t);\n\n\t// Create new routine\n\tconst createRoutineSchema = {\n\t\ttitle: z.string().min(1),\n\t\tfolderId: z.coerce.number().nullable().optional(),\n\t\tnotes: z.string().optional(),\n\t\texercises: z.preprocess(\n\t\t\tparseJsonArray,\n\t\t\tz.array(\n\t\t\t\tz.object({\n\t\t\t\t\texerciseTemplateId: z.string().min(1),\n\t\t\t\t\tsupersetId: z.coerce.number().nullable().optional(),\n\t\t\t\t\trestSeconds: z.coerce.number().int().min(0).optional(),\n\t\t\t\t\tnotes: z.string().optional(),\n\t\t\t\t\tsets: z.array(\n\t\t\t\t\t\tz.object({\n\t\t\t\t\t\t\ttype: z\n\t\t\t\t\t\t\t\t.enum([\"warmup\", \"normal\", \"failure\", \"dropset\"])\n\t\t\t\t\t\t\t\t.default(\"normal\"),\n\t\t\t\t\t\t\tweight: z.coerce.number().optional(),\n\t\t\t\t\t\t\tweightKg: z.coerce.number().optional(),\n\t\t\t\t\t\t\treps: zNullableInt,\n\t\t\t\t\t\t\tdistance: z.coerce.number().int().optional(),\n\t\t\t\t\t\t\tdistanceMeters: z.coerce.number().int().optional(),\n\t\t\t\t\t\t\tduration: z.coerce.number().int().optional(),\n\t\t\t\t\t\t\tdurationSeconds: z.coerce.number().int().optional(),\n\t\t\t\t\t\t\tcustomMetric: z.coerce.number().optional(),\n\t\t\t\t\t\t\trepRange: zOptionalRepRange,\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t}),\n\t\t\t),\n\t\t),\n\t} as const;\n\ttype CreateRoutineParams = InferToolParams<typeof createRoutineSchema>;\n\n\tserver.tool(\n\t\t\"create-routine\",\n\t\t\"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.\",\n\t\tcreateRoutineSchema,\n\t\twithErrorHandling(async (args: CreateRoutineParams) => {\n\t\t\tif (!hevyClient) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"API client not initialized. Please provide HEVY_API_KEY.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst { title, folderId, notes, exercises } = args;\n\t\t\tlet usesRepRanges = false;\n\t\t\tconst data: PostV1Routines201 = await hevyClient.createRoutine({\n\t\t\t\troutine: {\n\t\t\t\t\ttitle,\n\t\t\t\t\tfolder_id: folderId ?? null,\n\t\t\t\t\tnotes: notes ?? \"\",\n\t\t\t\t\texercises: exercises.map((exercise): PostRoutinesRequestExercise => {\n\t\t\t\t\t\tconst sets = exercise.sets.map((set): PostRoutinesRequestSet => {\n\t\t\t\t\t\t\tconst repRange = buildRepRange(set.repRange);\n\t\t\t\t\t\t\tconst fixedReps = getFixedRepsFromRepRange(repRange);\n\t\t\t\t\t\t\tconst reps = typeof set.reps === \"number\" ? set.reps : fixedReps;\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\ttype: set.type as PostRoutinesRequestSetTypeEnumKey,\n\t\t\t\t\t\t\t\tweight_kg: set.weight ?? set.weightKg ?? null,\n\t\t\t\t\t\t\t\treps: reps ?? null,\n\t\t\t\t\t\t\t\tdistance_meters: set.distance ?? set.distanceMeters ?? null,\n\t\t\t\t\t\t\t\tduration_seconds: set.duration ?? set.durationSeconds ?? null,\n\t\t\t\t\t\t\t\tcustom_metric: set.customMetric ?? null,\n\t\t\t\t\t\t\t\trep_range: repRange,\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\tsets.some(\n\t\t\t\t\t\t\t\t(set) =>\n\t\t\t\t\t\t\t\t\tset.rep_range != null &&\n\t\t\t\t\t\t\t\t\tgetFixedRepsFromRepRange(set.rep_range) === null,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tusesRepRanges = true;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\texercise_template_id: exercise.exerciseTemplateId,\n\t\t\t\t\t\t\tsuperset_id: exercise.supersetId ?? null,\n\t\t\t\t\t\t\trest_seconds: exercise.restSeconds ?? null,\n\t\t\t\t\t\t\tnotes: exercise.notes ?? null,\n\t\t\t\t\t\t\tsets,\n\t\t\t\t\t\t};\n\t\t\t\t\t}),\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tif (!data) {\n\t\t\t\treturn createEmptyResponse(\n\t\t\t\t\t\"Failed to create routine: Server returned no data\",\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tconst routine = formatRoutine(data);\n\t\t\tconst response = createJsonResponse(routine, {\n\t\t\t\tpretty: true,\n\t\t\t\tindent: 2,\n\t\t\t});\n\n\t\t\tif (usesRepRanges) {\n\t\t\t\tresponse.content.push({\n\t\t\t\t\ttype: \"text\",\n\t\t\t\t\ttext: repRangeDisplayWarningText,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\treturn response;\n\t\t}, \"create-routine\"),\n\t);\n\n\t// Update existing routine\n\tconst updateRoutineSchema = {\n\t\troutineId: z.string().min(1),\n\t\ttitle: z.string().min(1),\n\t\tnotes: z.string().optional(),\n\t\texercises: z.preprocess(\n\t\t\tparseJsonArray,\n\t\t\tz.array(\n\t\t\t\tz.object({\n\t\t\t\t\texerciseTemplateId: z.string().min(1),\n\t\t\t\t\tsupersetId: z.coerce.number().nullable().optional(),\n\t\t\t\t\trestSeconds: z.coerce.number().int().min(0).optional(),\n\t\t\t\t\tnotes: z.string().optional(),\n\t\t\t\t\tsets: z.array(\n\t\t\t\t\t\tz.object({\n\t\t\t\t\t\t\ttype: z\n\t\t\t\t\t\t\t\t.enum([\"warmup\", \"normal\", \"failure\", \"dropset\"])\n\t\t\t\t\t\t\t\t.default(\"normal\"),\n\t\t\t\t\t\t\tweight: z.coerce.number().optional(),\n\t\t\t\t\t\t\tweightKg: z.coerce.number().optional(),\n\t\t\t\t\t\t\treps: zNullableInt,\n\t\t\t\t\t\t\tdistance: z.coerce.number().int().optional(),\n\t\t\t\t\t\t\tdistanceMeters: z.coerce.number().int().optional(),\n\t\t\t\t\t\t\tduration: z.coerce.number().int().optional(),\n\t\t\t\t\t\t\tdurationSeconds: z.coerce.number().int().optional(),\n\t\t\t\t\t\t\tcustomMetric: z.coerce.number().optional(),\n\t\t\t\t\t\t\trepRange: zOptionalRepRange,\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t}),\n\t\t\t),\n\t\t),\n\t} as const;\n\ttype UpdateRoutineParams = InferToolParams<typeof updateRoutineSchema>;\n\n\tserver.tool(\n\t\t\"update-routine\",\n\t\t\"Update an existing routine by ID. You can modify the title, notes, and exercise configurations. Returns the updated routine with all changes applied.\",\n\t\tupdateRoutineSchema,\n\t\twithErrorHandling(async (args: UpdateRoutineParams) => {\n\t\t\tif (!hevyClient) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"API client not initialized. Please provide HEVY_API_KEY.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst { routineId, title, notes, exercises } = args;\n\t\t\tlet usesRepRanges = false;\n\t\t\tconst data: PutV1RoutinesRoutineid200 = await hevyClient.updateRoutine(\n\t\t\t\troutineId,\n\t\t\t\t{\n\t\t\t\t\troutine: {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tnotes: notes ?? null,\n\t\t\t\t\t\texercises: exercises.map((exercise): PutRoutinesRequestExercise => {\n\t\t\t\t\t\t\tconst sets = exercise.sets.map((set): PutRoutinesRequestSet => {\n\t\t\t\t\t\t\t\tconst repRange = buildRepRange(set.repRange);\n\t\t\t\t\t\t\t\tconst fixedReps = getFixedRepsFromRepRange(repRange);\n\t\t\t\t\t\t\t\tconst reps =\n\t\t\t\t\t\t\t\t\ttypeof set.reps === \"number\" ? set.reps : fixedReps;\n\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\ttype: set.type as PutRoutinesRequestSetTypeEnumKey,\n\t\t\t\t\t\t\t\t\tweight_kg: set.weight ?? set.weightKg ?? null,\n\t\t\t\t\t\t\t\t\treps: reps ?? null,\n\t\t\t\t\t\t\t\t\tdistance_meters: set.distance ?? set.distanceMeters ?? null,\n\t\t\t\t\t\t\t\t\tduration_seconds: set.duration ?? set.durationSeconds ?? null,\n\t\t\t\t\t\t\t\t\tcustom_metric: set.customMetric ?? null,\n\t\t\t\t\t\t\t\t\trep_range: repRange,\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t\tsets.some(\n\t\t\t\t\t\t\t\t\t(set) =>\n\t\t\t\t\t\t\t\t\t\tset.rep_range != null &&\n\t\t\t\t\t\t\t\t\t\tgetFixedRepsFromRepRange(set.rep_range) === null,\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t) {\n\t\t\t\t\t\t\t\tusesRepRanges = true;\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\texercise_template_id: exercise.exerciseTemplateId,\n\t\t\t\t\t\t\t\tsuperset_id: exercise.supersetId ?? null,\n\t\t\t\t\t\t\t\trest_seconds: exercise.restSeconds ?? null,\n\t\t\t\t\t\t\t\tnotes: exercise.notes ?? null,\n\t\t\t\t\t\t\t\tsets,\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t}),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t);\n\n\t\t\tif (!data) {\n\t\t\t\treturn createEmptyResponse(\n\t\t\t\t\t`Failed to update routine with ID ${routineId}`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tconst routine = formatRoutine(data);\n\t\t\tconst response = createJsonResponse(routine, {\n\t\t\t\tpretty: true,\n\t\t\t\tindent: 2,\n\t\t\t});\n\n\t\t\tif (usesRepRanges) {\n\t\t\t\tresponse.content.push({\n\t\t\t\t\ttype: \"text\",\n\t\t\t\t\ttext: repRangeDisplayWarningText,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\treturn response;\n\t\t}, \"update-routine\"),\n\t);\n}\n","import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\n// Import types from generated client\nimport type {\n\tExerciseTemplate,\n\tGetV1ExerciseHistoryExercisetemplateid200,\n\tGetV1ExerciseTemplates200,\n\tGetV1ExerciseTemplatesExercisetemplateid200,\n\tPostV1ExerciseTemplates200,\n} from \"../generated/client/types/index.js\";\nimport { withErrorHandling } from \"../utils/error-handler.js\";\nimport {\n\tformatExerciseHistoryEntry,\n\tformatExerciseTemplate,\n} from \"../utils/formatters.js\";\nimport {\n\tcreateEmptyResponse,\n\tcreateJsonResponse,\n} from \"../utils/response-formatter.js\";\nimport type { InferToolParams } from \"../utils/tool-helpers.js\";\n\n// Type definitions for the template operations\ntype HevyClient = ReturnType<\n\ttypeof import(\"../utils/hevyClientKubb.js\").createClient\n>;\n\n// Shared muscle group values used by both create and search tools\nconst MUSCLE_GROUPS = [\n\t\"abdominals\",\n\t\"shoulders\",\n\t\"biceps\",\n\t\"triceps\",\n\t\"forearms\",\n\t\"quadriceps\",\n\t\"hamstrings\",\n\t\"calves\",\n\t\"glutes\",\n\t\"abductors\",\n\t\"adductors\",\n\t\"lats\",\n\t\"upper_back\",\n\t\"traps\",\n\t\"lower_back\",\n\t\"chest\",\n\t\"cardio\",\n\t\"neck\",\n\t\"full_body\",\n\t\"other\",\n] as const;\n\n// Module-level cache for all exercise templates\nlet exerciseTemplateCache: ExerciseTemplate[] | null = null;\n// In-flight promise to prevent concurrent duplicate fetches\nlet exerciseTemplateFetch: Promise<ExerciseTemplate[]> | null = null;\n\n/** Reset the exercise template cache (exposed for testing). */\nexport function resetExerciseTemplateCache(): void {\n\texerciseTemplateCache = null;\n\texerciseTemplateFetch = null;\n}\n\n/**\n * Register all exercise template-related tools with the MCP server\n */\nexport function registerTemplateTools(\n\tserver: McpServer,\n\thevyClient: HevyClient | null,\n) {\n\t// Get exercise templates\n\tconst getExerciseTemplatesSchema = {\n\t\tpage: z.coerce.number().int().gte(1).default(1),\n\t\tpageSize: z.coerce.number().int().gte(1).lte(100).default(5),\n\t} as const;\n\ttype GetExerciseTemplatesParams = InferToolParams<\n\t\ttypeof getExerciseTemplatesSchema\n\t>;\n\n\tserver.tool(\n\t\t\"get-exercise-templates\",\n\t\t\"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.\",\n\t\tgetExerciseTemplatesSchema,\n\t\twithErrorHandling(async (args: GetExerciseTemplatesParams) => {\n\t\t\tif (!hevyClient) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"API client not initialized. Please provide HEVY_API_KEY.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst { page, pageSize } = args;\n\t\t\tconst data: GetV1ExerciseTemplates200 =\n\t\t\t\tawait hevyClient.getExerciseTemplates({\n\t\t\t\t\tpage,\n\t\t\t\t\tpageSize,\n\t\t\t\t});\n\n\t\t\t// Process exercise templa