hevy-mcp 1.26.0 → 1.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,9 @@
4
4
  (function() {
5
5
  try {
6
6
  var e = "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof globalThis ? globalThis : "undefined" != typeof self ? self : {};
7
- e.SENTRY_RELEASE = { id: "hevy-mcp@1.26.0" };
7
+ e.SENTRY_RELEASE = { id: "hevy-mcp@1.26.2" };
8
8
  var n = new e.Error().stack;
9
- n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "b6fef1cd-aaf1-497b-af9e-7d50fa405061", e._sentryDebugIdIdentifier = "sentry-dbid-b6fef1cd-aaf1-497b-af9e-7d50fa405061");
9
+ n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "425564da-031b-406e-b160-907df2faf2eb", e._sentryDebugIdIdentifier = "sentry-dbid-425564da-031b-406e-b160-907df2faf2eb");
10
10
  } catch (e) {}
11
11
  })();
12
12
  import * as Sentry from "@sentry/node";
@@ -36,10 +36,10 @@ import { deserializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
36
36
  * OTel Collector → Honeycomb: performance traces, metrics
37
37
  */
38
38
  const name$1 = "hevy-mcp";
39
- const version$1 = "1.26.0";
39
+ const version$1 = "1.26.2";
40
40
  const collectorToken = process.env.OTEL_COLLECTOR_TOKEN ?? "";
41
41
  const COLLECTOR_ENDPOINT = "https://otel.chrisdoc.dev/v1";
42
- const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@1.26.0`;
42
+ const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@1.26.2`;
43
43
  const resource = resourceFromAttributes({
44
44
  "service.name": name$1,
45
45
  "service.version": version$1
@@ -127,6 +127,48 @@ const serverStartups = meter.createCounter("mcp.server.startups", { description:
127
127
  /**
128
128
  * Centralized error handling utility for MCP tools
129
129
  */
130
+ function normalizeHeaderValue$1(value) {
131
+ if (typeof value === "string") {
132
+ const trimmed = value.trim();
133
+ return trimmed.length > 0 ? trimmed : void 0;
134
+ }
135
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
136
+ if (Array.isArray(value) && value.length > 0) return normalizeHeaderValue$1(value[0]);
137
+ }
138
+ function getHeaderValue$1(headers, key) {
139
+ if (!headers || typeof headers !== "object") return;
140
+ if ("get" in headers && typeof headers.get === "function") return normalizeHeaderValue$1(headers.get(key));
141
+ const headerRecord = headers;
142
+ return normalizeHeaderValue$1(headerRecord[key] ?? headerRecord[key.toLowerCase()] ?? headerRecord[key.toUpperCase()]);
143
+ }
144
+ function formatSecondsLabel(seconds) {
145
+ const roundedSeconds = Math.max(0, Math.round(seconds));
146
+ return `${roundedSeconds} second${roundedSeconds === 1 ? "" : "s"}`;
147
+ }
148
+ function getRateLimitMessage(error) {
149
+ if (!isAxiosError(error)) return "Rate limited by Hevy. Please wait and retry.";
150
+ const retryAfterHeader = getHeaderValue$1(error.response?.headers, "retry-after");
151
+ if (!retryAfterHeader) return "Rate limited by Hevy (HTTP 429). Please wait and retry your request.";
152
+ const seconds = Number(retryAfterHeader);
153
+ if (Number.isFinite(seconds) && seconds >= 0) return `Rate limited by Hevy (HTTP 429). Please wait about ${formatSecondsLabel(seconds)} before retrying.`;
154
+ const retryAtMillis = Date.parse(retryAfterHeader);
155
+ if (!Number.isNaN(retryAtMillis)) return `Rate limited by Hevy (HTTP 429). Please wait about ${formatSecondsLabel(Math.ceil(Math.max(0, retryAtMillis - Date.now()) / 1e3))} before retrying.`;
156
+ return "Rate limited by Hevy (HTTP 429). Please wait and retry your request.";
157
+ }
158
+ function isRetryExhaustedError(error) {
159
+ return !!error && typeof error === "object" && error.hevyRetryExhausted === true;
160
+ }
161
+ function getRetryExhaustedMessage(error) {
162
+ const retryCount = typeof error === "object" && error !== null ? error.hevyRetryCount : void 0;
163
+ const attemptCount = typeof retryCount === "number" && Number.isFinite(retryCount) ? retryCount + 1 : void 0;
164
+ if (attemptCount) return `Unable to complete the request after ${attemptCount} attempts to the Hevy API due to transient failures. Please try again shortly.`;
165
+ return "Unable to complete the request after multiple attempts to the Hevy API due to transient failures. Please try again shortly.";
166
+ }
167
+ function getUserFacingMessage(error, defaultMessage) {
168
+ if (isRetryExhaustedError(error)) return getRetryExhaustedMessage(error);
169
+ if (isAxiosError(error) && error.response?.status === 429) return getRateLimitMessage(error);
170
+ return defaultMessage;
171
+ }
130
172
  /**
131
173
  * Create a standardized error response for MCP tools
132
174
  *
@@ -135,18 +177,22 @@ const serverStartups = meter.createCounter("mcp.server.startups", { description:
135
177
  * @returns A formatted MCP tool response with error information
136
178
  */
137
179
  function createErrorResponse(error, context) {
138
- let errorMessage = error instanceof Error ? error.message : String(error);
139
- if (isAxiosError(error) && error.response?.data) {
140
- const { data } = error.response;
141
- if (typeof data === "string") errorMessage = data;
142
- else if (data && typeof data === "object") try {
143
- errorMessage = JSON.stringify(data);
144
- } catch (_e) {
145
- errorMessage = String(data);
146
- }
147
- }
180
+ const originalErrorMessage = extractErrorMessage(error);
181
+ let errorMessage = originalErrorMessage;
182
+ const axiosErrorContext = extractAxiosErrorContext(error);
183
+ const mappedHevyErrorMessage = mapHevyErrorMessageByStatus(axiosErrorContext?.status);
184
+ if (mappedHevyErrorMessage) errorMessage = mappedHevyErrorMessage;
185
+ if (!mappedHevyErrorMessage && axiosErrorContext?.data) errorMessage = stringifyErrorData(axiosErrorContext.data);
186
+ errorMessage = getUserFacingMessage(error, errorMessage);
148
187
  const errorCode = error instanceof Error && "code" in error ? error.code : void 0;
149
188
  const errorType = determineErrorType(error, errorMessage);
189
+ const errorContext = {
190
+ sourceContext: context,
191
+ originalErrorMessage,
192
+ errorCode,
193
+ errorType,
194
+ axios: axiosErrorContext ?? void 0
195
+ };
150
196
  const formattedMessage = `${context ? `[${context}] ` : ""}Error: ${errorMessage}`;
151
197
  const errorCodeSuffix = errorCode ? `, Code: ${errorCode}` : "";
152
198
  console.error(`${formattedMessage} (Type: ${errorType}${errorCodeSuffix})`, error);
@@ -155,13 +201,55 @@ function createErrorResponse(error, context) {
155
201
  type: "text",
156
202
  text: formattedMessage
157
203
  }],
158
- isError: true
204
+ isError: true,
205
+ errorContext
159
206
  };
160
207
  }
208
+ function extractErrorMessage(error) {
209
+ if (error instanceof Error) return error.message;
210
+ if (typeof error === "string") return error;
211
+ if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
212
+ if (error && typeof error === "object") try {
213
+ return JSON.stringify(error);
214
+ } catch (_e) {
215
+ return "Unknown error object";
216
+ }
217
+ return String(error);
218
+ }
219
+ function extractAxiosErrorContext(error) {
220
+ if (!isAxiosError(error)) return null;
221
+ return {
222
+ status: error.response?.status,
223
+ statusText: error.response?.statusText,
224
+ data: error.response?.data,
225
+ method: error.config?.method,
226
+ url: error.config?.url
227
+ };
228
+ }
229
+ function mapHevyErrorMessageByStatus(status) {
230
+ if (status === 401 || status === 403) return "The Hevy API key is invalid or has expired. Check HEVY_API_KEY.";
231
+ if (status === 404) return "The requested resource was not found in Hevy.";
232
+ if (status === 409) return "A conflict occurred (e.g., a body measurement already exists for this date). Use the update tool instead.";
233
+ if (status === 422) return "The request failed Hevy validation. Check the field values and try again.";
234
+ if (status === 429) return "Rate limited by Hevy. Please wait and retry.";
235
+ if (status && status >= 500 && status <= 599) return "Hevy API experienced an error. Please retry later.";
236
+ return null;
237
+ }
238
+ function stringifyErrorData(data) {
239
+ if (typeof data === "string") return data;
240
+ if (data && typeof data === "object") try {
241
+ return JSON.stringify(data);
242
+ } catch (_e) {
243
+ return "Unable to serialize error response data";
244
+ }
245
+ return String(data);
246
+ }
161
247
  /**
162
248
  * Determine the type of error based on error characteristics
163
249
  */
164
250
  function determineErrorType(error, message) {
251
+ if (isRetryExhaustedError(error)) return "NETWORK_ERROR";
252
+ if (isAxiosError(error) && error.response?.status === 429) return "RATE_LIMIT";
165
253
  const messageLower = message.toLowerCase();
166
254
  const nameLower = error instanceof Error ? error.name.toLowerCase() : "";
167
255
  if (nameLower.includes("network") || messageLower.includes("network") || nameLower.includes("fetch") || messageLower.includes("fetch") || nameLower.includes("timeout") || messageLower.includes("timeout")) return "NETWORK_ERROR";
@@ -405,7 +493,7 @@ function createJsonResponse(data, options = {
405
493
  }) {
406
494
  return { content: [{
407
495
  type: "text",
408
- text: options.pretty ? JSON.stringify(data, null, options.indent) : JSON.stringify(data)
496
+ text: (options.pretty ? JSON.stringify(data, null, options.indent) : JSON.stringify(data)) ?? "null"
409
497
  }] };
410
498
  }
411
499
  /**
@@ -427,10 +515,7 @@ function createTextResponse(message) {
427
515
  * @returns A formatted MCP tool response for empty results
428
516
  */
429
517
  function createEmptyResponse(message = "No data found") {
430
- return { content: [{
431
- type: "text",
432
- text: message
433
- }] };
518
+ return createTextResponse(message);
434
519
  }
435
520
  //#endregion
436
521
  //#region src/utils/tool-annotations.ts
@@ -466,8 +551,86 @@ function updateAnnotations(title) {
466
551
  };
467
552
  }
468
553
  //#endregion
469
- //#region src/tools/body-measurements.ts
554
+ //#region src/utils/tool-helpers.ts
555
+ const HEVY_CLIENT_NOT_INITIALIZED_ERROR = "API client not initialized. Please provide HEVY_API_KEY.";
556
+ /**
557
+ * Assert that the Hevy API client is initialized before running a tool.
558
+ */
559
+ function requireClient(client) {
560
+ if (!client) throw new Error(HEVY_CLIENT_NOT_INITIALIZED_ERROR);
561
+ return client;
562
+ }
563
+ //#endregion
564
+ //#region src/utils/schemas.ts
565
+ function coerceNullishNumberInput(value) {
566
+ if (value === null || value === void 0) return value;
567
+ if (typeof value !== "string") return value;
568
+ const trimmed = value.trim();
569
+ if (trimmed === "") return;
570
+ const lowered = trimmed.toLowerCase();
571
+ if (lowered === "null") return null;
572
+ if (lowered === "undefined") return;
573
+ const asNumber = Number(trimmed);
574
+ if (Number.isNaN(asNumber)) return value;
575
+ return asNumber;
576
+ }
577
+ const zNullableInt = z.preprocess(coerceNullishNumberInput, z.number().int().nullable().optional());
470
578
  const zNullableNumber = z.preprocess((value) => value === "" ? void 0 : value, z.coerce.number().nullable().optional());
579
+ const zOptionalRepRange = z.preprocess((value) => value === null ? void 0 : value, z.object({
580
+ start: zNullableInt,
581
+ end: zNullableInt
582
+ }).optional());
583
+ const setTypeEnum = z.enum([
584
+ "warmup",
585
+ "normal",
586
+ "failure",
587
+ "dropset"
588
+ ]).default("normal");
589
+ const muscleGroupEnum = z.enum([
590
+ "abdominals",
591
+ "shoulders",
592
+ "biceps",
593
+ "triceps",
594
+ "forearms",
595
+ "quadriceps",
596
+ "hamstrings",
597
+ "calves",
598
+ "glutes",
599
+ "abductors",
600
+ "adductors",
601
+ "lats",
602
+ "upper_back",
603
+ "traps",
604
+ "lower_back",
605
+ "chest",
606
+ "cardio",
607
+ "neck",
608
+ "full_body",
609
+ "other"
610
+ ]);
611
+ const exerciseTypeEnum = z.enum([
612
+ "weight_reps",
613
+ "reps_only",
614
+ "bodyweight_reps",
615
+ "bodyweight_assisted_reps",
616
+ "duration",
617
+ "weight_duration",
618
+ "distance_duration",
619
+ "short_distance_weight"
620
+ ]);
621
+ const equipmentCategoryEnum = z.enum([
622
+ "none",
623
+ "barbell",
624
+ "dumbbell",
625
+ "kettlebell",
626
+ "machine",
627
+ "plate",
628
+ "resistance_band",
629
+ "suspension",
630
+ "other"
631
+ ]);
632
+ //#endregion
633
+ //#region src/tools/body-measurements.ts
471
634
  const bodyMeasurementFieldsSchema = {
472
635
  weightKg: zNullableNumber.describe("Body weight in kilograms"),
473
636
  leanMassKg: zNullableNumber.describe("Lean body mass in kilograms"),
@@ -520,9 +683,9 @@ function registerBodyMeasurementTools(server, hevyClient) {
520
683
  pageSize: z.coerce.number().int().gte(1).lte(10).default(10)
521
684
  };
522
685
  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, readOnlyAnnotations("Get Body Measurements"), withErrorHandling(async (args) => {
523
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
686
+ const client = requireClient(hevyClient);
524
687
  const { page, pageSize } = args;
525
- const measurements = (await hevyClient.getBodyMeasurements({
688
+ const measurements = (await client.getBodyMeasurements({
526
689
  page,
527
690
  pageSize
528
691
  }))?.body_measurements?.map((measurement) => formatBodyMeasurement(measurement)) || [];
@@ -531,9 +694,9 @@ function registerBodyMeasurementTools(server, hevyClient) {
531
694
  }, "get-body-measurements"));
532
695
  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)") };
533
696
  server.tool("get-body-measurement", "Get a single body measurement by date. Returns all measurement fields for the specified date.", getBodyMeasurementSchema, readOnlyAnnotations("Get Body Measurement"), withErrorHandling(async (args) => {
534
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
697
+ const client = requireClient(hevyClient);
535
698
  const { date } = args;
536
- const data = await hevyClient.getBodyMeasurement(date);
699
+ const data = await client.getBodyMeasurement(date);
537
700
  if (!data) return createEmptyResponse(`No body measurement found for date ${date}`);
538
701
  return createJsonResponse(formatBodyMeasurement(data));
539
702
  }, "get-body-measurement"));
@@ -542,9 +705,9 @@ function registerBodyMeasurementTools(server, hevyClient) {
542
705
  ...bodyMeasurementFieldsSchema
543
706
  };
544
707
  server.tool("create-body-measurement", "Create a body measurement entry for a given date. All measurement fields are optional; null values are treated as omitted, since the Hevy API does not support clearing individual fields. Returns 409 if an entry already exists for that date — use update-body-measurement instead.", createBodyMeasurementSchema, createAnnotations("Create Body Measurement"), withErrorHandling(async (args) => {
545
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
708
+ const client = requireClient(hevyClient);
546
709
  const { date, ...fields } = args;
547
- await hevyClient.createBodyMeasurement({
710
+ await client.createBodyMeasurement({
548
711
  date,
549
712
  ...buildMeasurementPayload(fields)
550
713
  });
@@ -555,11 +718,11 @@ function registerBodyMeasurementTools(server, hevyClient) {
555
718
  ...bodyMeasurementFieldsSchema
556
719
  };
557
720
  server.tool("update-body-measurement", "Update an existing body measurement entry for a given date. Only the fields you provide are sent and updated; null values are treated as omitted, since the Hevy API does not support clearing individual fields. Requires at least one measurement field. Returns 404 if no entry exists for the date.", updateBodyMeasurementSchema, updateAnnotations("Update Body Measurement"), withErrorHandling(async (args) => {
558
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
721
+ const client = requireClient(hevyClient);
559
722
  const { date, ...fields } = args;
560
723
  const payload = buildMeasurementPayload(fields);
561
724
  if (Object.keys(payload).length === 0) throw new Error("No measurement fields provided. Include at least one numeric measurement field (e.g. weightKg) to update.");
562
- await hevyClient.updateBodyMeasurement(date, payload);
725
+ await client.updateBodyMeasurement(date, payload);
563
726
  return createTextResponse(`Body measurement for ${date} updated successfully.`);
564
727
  }, "update-body-measurement"));
565
728
  }
@@ -574,9 +737,9 @@ function registerFolderTools(server, hevyClient) {
574
737
  pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
575
738
  };
576
739
  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, readOnlyAnnotations("Get Routine Folders"), withErrorHandling(async (args) => {
577
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
740
+ const client = requireClient(hevyClient);
578
741
  const { page, pageSize } = args;
579
- const folders = (await hevyClient.getRoutineFolders({
742
+ const folders = (await client.getRoutineFolders({
580
743
  page,
581
744
  pageSize
582
745
  }))?.routine_folders?.map((folder) => formatRoutineFolder(folder)) || [];
@@ -585,17 +748,17 @@ function registerFolderTools(server, hevyClient) {
585
748
  }, "get-routine-folders"));
586
749
  const getRoutineFolderSchema = { folderId: z.string().min(1) };
587
750
  server.tool("get-routine-folder", "Get complete details of a specific routine folder by its ID, including name, creation date, and associated routines.", getRoutineFolderSchema, readOnlyAnnotations("Get Routine Folder"), withErrorHandling(async (args) => {
588
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
751
+ const client = requireClient(hevyClient);
589
752
  const { folderId } = args;
590
- const data = await hevyClient.getRoutineFolder(folderId);
753
+ const data = await client.getRoutineFolder(folderId);
591
754
  if (!data) return createEmptyResponse(`Routine folder with ID ${folderId} not found`);
592
755
  return createJsonResponse(formatRoutineFolder(data));
593
756
  }, "get-routine-folder"));
594
757
  const createRoutineFolderSchema = { name: z.string().min(1) };
595
758
  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, createAnnotations("Create Routine Folder"), withErrorHandling(async (args) => {
596
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
759
+ const client = requireClient(hevyClient);
597
760
  const { name } = args;
598
- const data = await hevyClient.createRoutineFolder({ routine_folder: { title: name } });
761
+ const data = await client.createRoutineFolder({ routine_folder: { title: name } });
599
762
  if (!data) return createEmptyResponse("Failed to create routine folder: Server returned no data");
600
763
  return createJsonResponse(formatRoutineFolder(data), {
601
764
  pretty: true,
@@ -625,23 +788,6 @@ function parseJsonArray(val) {
625
788
  }
626
789
  //#endregion
627
790
  //#region src/tools/routines.ts
628
- function coerceNullishNumberInput(value) {
629
- if (value === null || value === void 0) return value;
630
- if (typeof value !== "string") return value;
631
- const trimmed = value.trim();
632
- if (trimmed === "") return;
633
- const lowered = trimmed.toLowerCase();
634
- if (lowered === "null") return null;
635
- if (lowered === "undefined") return;
636
- const asNumber = Number(trimmed);
637
- if (Number.isNaN(asNumber)) return value;
638
- return asNumber;
639
- }
640
- const zNullableInt = z.preprocess(coerceNullishNumberInput, z.number().int().nullable().optional());
641
- const zOptionalRepRange = z.preprocess((value) => value === null ? void 0 : value, z.object({
642
- start: zNullableInt,
643
- end: zNullableInt
644
- }).optional());
645
791
  function buildRepRange(repRange) {
646
792
  if (!repRange) return null;
647
793
  const start = repRange.start ?? void 0;
@@ -674,9 +820,9 @@ function registerRoutineTools(server, hevyClient) {
674
820
  pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
675
821
  };
676
822
  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, readOnlyAnnotations("Get Routines"), withErrorHandling(async (args) => {
677
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
823
+ const client = requireClient(hevyClient);
678
824
  const { page, pageSize } = args;
679
- const routines = (await hevyClient.getRoutines({
825
+ const routines = (await client.getRoutines({
680
826
  page,
681
827
  pageSize
682
828
  }))?.routines?.map((routine) => formatRoutine(routine)) || [];
@@ -685,9 +831,9 @@ function registerRoutineTools(server, hevyClient) {
685
831
  }, "get-routines"));
686
832
  const getRoutineSchema = { routineId: z.string().min(1) };
687
833
  server.tool("get-routine", "Get a routine by its ID using the direct endpoint. Returns all details for the specified routine.", getRoutineSchema, readOnlyAnnotations("Get Routine"), withErrorHandling(async (args) => {
688
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
834
+ const client = requireClient(hevyClient);
689
835
  const { routineId } = args;
690
- const data = await hevyClient.getRoutineById(String(routineId));
836
+ const data = await client.getRoutineById(String(routineId));
691
837
  if (!data || !data.routine) return createEmptyResponse(`Routine with ID ${routineId} not found`);
692
838
  return createJsonResponse(formatRoutine(data.routine));
693
839
  }, "get-routine"));
@@ -701,12 +847,7 @@ function registerRoutineTools(server, hevyClient) {
701
847
  restSeconds: z.coerce.number().int().min(0).optional(),
702
848
  notes: z.string().optional(),
703
849
  sets: z.array(z.object({
704
- type: z.enum([
705
- "warmup",
706
- "normal",
707
- "failure",
708
- "dropset"
709
- ]).default("normal"),
850
+ type: setTypeEnum,
710
851
  weight: z.coerce.number().optional(),
711
852
  weightKg: z.coerce.number().optional(),
712
853
  reps: zNullableInt,
@@ -720,10 +861,10 @@ function registerRoutineTools(server, hevyClient) {
720
861
  })))
721
862
  };
722
863
  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, createAnnotations("Create Routine"), withErrorHandling(async (args) => {
723
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
864
+ const client = requireClient(hevyClient);
724
865
  const { title, folderId, notes, exercises } = args;
725
866
  let usesRepRanges = false;
726
- const data = await hevyClient.createRoutine({ routine: {
867
+ const data = await client.createRoutine({ routine: {
727
868
  title,
728
869
  folder_id: folderId ?? null,
729
870
  notes: notes ?? "",
@@ -773,12 +914,7 @@ function registerRoutineTools(server, hevyClient) {
773
914
  restSeconds: z.coerce.number().int().min(0).optional(),
774
915
  notes: z.string().optional(),
775
916
  sets: z.array(z.object({
776
- type: z.enum([
777
- "warmup",
778
- "normal",
779
- "failure",
780
- "dropset"
781
- ]).default("normal"),
917
+ type: setTypeEnum,
782
918
  weight: z.coerce.number().optional(),
783
919
  weightKg: z.coerce.number().optional(),
784
920
  reps: zNullableInt,
@@ -792,10 +928,10 @@ function registerRoutineTools(server, hevyClient) {
792
928
  })))
793
929
  };
794
930
  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, updateAnnotations("Update Routine"), withErrorHandling(async (args) => {
795
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
931
+ const client = requireClient(hevyClient);
796
932
  const { routineId, title, notes, exercises } = args;
797
933
  let usesRepRanges = false;
798
- const data = await hevyClient.updateRoutine(routineId, { routine: {
934
+ const data = await client.updateRoutine(routineId, { routine: {
799
935
  title,
800
936
  notes: notes ?? null,
801
937
  exercises: exercises.map((exercise) => {
@@ -836,31 +972,114 @@ function registerRoutineTools(server, hevyClient) {
836
972
  }, "update-routine"));
837
973
  }
838
974
  //#endregion
975
+ //#region src/utils/cache.ts
976
+ /**
977
+ * Shared in-memory cache for async fetches with TTL, LRU eviction,
978
+ * in-flight de-duplication, and explicit refresh/invalidation support.
979
+ */
980
+ var AsyncTtlCache = class {
981
+ ttlMs;
982
+ maxSize;
983
+ now;
984
+ entries = /* @__PURE__ */ new Map();
985
+ inFlight = /* @__PURE__ */ new Map();
986
+ requestCounter = 0;
987
+ constructor(options, now = Date.now) {
988
+ const { ttlMs, maxSize } = options;
989
+ if (ttlMs <= 0) throw new Error("Cache ttlMs must be greater than 0.");
990
+ if (maxSize <= 0) throw new Error("Cache maxSize must be greater than 0.");
991
+ this.ttlMs = ttlMs;
992
+ this.maxSize = maxSize;
993
+ this.now = now;
994
+ }
995
+ async getOrFetch(key, fetcher, options = {}) {
996
+ const { refresh = false } = options;
997
+ if (refresh) this.invalidate(key);
998
+ else {
999
+ const cachedEntry = this.entries.get(key);
1000
+ if (cachedEntry !== void 0) {
1001
+ if (cachedEntry.expiresAt > this.now()) {
1002
+ this.markAsRecentlyUsed(key, cachedEntry);
1003
+ return cachedEntry.value;
1004
+ }
1005
+ this.entries.delete(key);
1006
+ }
1007
+ const inFlightEntry = this.inFlight.get(key);
1008
+ if (inFlightEntry !== void 0) return inFlightEntry.promise;
1009
+ }
1010
+ const requestId = ++this.requestCounter;
1011
+ const request = (async () => {
1012
+ try {
1013
+ const value = await fetcher();
1014
+ if (this.isCurrentRequest(key, requestId)) this.setValue(key, value);
1015
+ return value;
1016
+ } finally {
1017
+ if (this.inFlight.get(key)?.requestId === requestId) this.inFlight.delete(key);
1018
+ }
1019
+ })();
1020
+ this.inFlight.set(key, {
1021
+ promise: request,
1022
+ requestId
1023
+ });
1024
+ return request;
1025
+ }
1026
+ invalidate(key) {
1027
+ this.entries.delete(key);
1028
+ this.inFlight.delete(key);
1029
+ }
1030
+ clear() {
1031
+ this.entries.clear();
1032
+ this.inFlight.clear();
1033
+ }
1034
+ get size() {
1035
+ return this.entries.size;
1036
+ }
1037
+ isCurrentRequest(key, requestId) {
1038
+ return this.inFlight.get(key)?.requestId === requestId;
1039
+ }
1040
+ markAsRecentlyUsed(key, entry) {
1041
+ this.entries.delete(key);
1042
+ this.entries.set(key, entry);
1043
+ }
1044
+ setValue(key, value) {
1045
+ this.entries.delete(key);
1046
+ this.entries.set(key, {
1047
+ value,
1048
+ expiresAt: this.now() + this.ttlMs
1049
+ });
1050
+ this.evictLeastRecentlyUsed();
1051
+ }
1052
+ evictLeastRecentlyUsed() {
1053
+ while (this.entries.size > this.maxSize) {
1054
+ const oldestKey = this.entries.keys().next().value;
1055
+ if (oldestKey === void 0) return;
1056
+ this.entries.delete(oldestKey);
1057
+ }
1058
+ }
1059
+ };
1060
+ //#endregion
839
1061
  //#region src/tools/templates.ts
840
- const MUSCLE_GROUPS = [
841
- "abdominals",
842
- "shoulders",
843
- "biceps",
844
- "triceps",
845
- "forearms",
846
- "quadriceps",
847
- "hamstrings",
848
- "calves",
849
- "glutes",
850
- "abductors",
851
- "adductors",
852
- "lats",
853
- "upper_back",
854
- "traps",
855
- "lower_back",
856
- "chest",
857
- "cardio",
858
- "neck",
859
- "full_body",
860
- "other"
861
- ];
862
- let exerciseTemplateCache = null;
863
- let exerciseTemplateFetch = null;
1062
+ const EXERCISE_TEMPLATE_CATALOG_CACHE_KEY = "exercise-template-catalog";
1063
+ const exerciseTemplateCatalogCache = new AsyncTtlCache({
1064
+ ttlMs: 300 * 1e3,
1065
+ maxSize: 1
1066
+ });
1067
+ async function fetchExerciseTemplateCatalog(hevyClient) {
1068
+ const allTemplates = [];
1069
+ let page = 1;
1070
+ let pageCount = 1;
1071
+ do {
1072
+ const data = await hevyClient.getExerciseTemplates({
1073
+ page,
1074
+ pageSize: 100
1075
+ });
1076
+ const templates = data?.exercise_templates ?? [];
1077
+ allTemplates.push(...templates);
1078
+ pageCount = data?.page_count ?? 1;
1079
+ page++;
1080
+ } while (page <= pageCount);
1081
+ return allTemplates;
1082
+ }
864
1083
  /**
865
1084
  * Register all exercise template-related tools with the MCP server
866
1085
  */
@@ -870,9 +1089,9 @@ function registerTemplateTools(server, hevyClient) {
870
1089
  pageSize: z.coerce.number().int().gte(1).lte(100).default(5)
871
1090
  };
872
1091
  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, readOnlyAnnotations("Get Exercise Templates"), withErrorHandling(async (args) => {
873
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
1092
+ const client = requireClient(hevyClient);
874
1093
  const { page, pageSize } = args;
875
- const templates = (await hevyClient.getExerciseTemplates({
1094
+ const templates = (await client.getExerciseTemplates({
876
1095
  page,
877
1096
  pageSize
878
1097
  }))?.exercise_templates?.map((template) => formatExerciseTemplate(template)) || [];
@@ -881,9 +1100,9 @@ function registerTemplateTools(server, hevyClient) {
881
1100
  }, "get-exercise-templates"));
882
1101
  const getExerciseTemplateSchema = { exerciseTemplateId: z.string().min(1) };
883
1102
  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, readOnlyAnnotations("Get Exercise Template"), withErrorHandling(async (args) => {
884
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
1103
+ const client = requireClient(hevyClient);
885
1104
  const { exerciseTemplateId } = args;
886
- const data = await hevyClient.getExerciseTemplate(exerciseTemplateId);
1105
+ const data = await client.getExerciseTemplate(exerciseTemplateId);
887
1106
  if (!data) return createEmptyResponse(`Exercise template with ID ${exerciseTemplateId} not found`);
888
1107
  return createJsonResponse(formatExerciseTemplate(data));
889
1108
  }, "get-exercise-template"));
@@ -893,9 +1112,9 @@ function registerTemplateTools(server, hevyClient) {
893
1112
  endDate: z.string().datetime({ offset: true }).describe("ISO 8601 end date for filtering history").optional()
894
1113
  };
895
1114
  server.tool("get-exercise-history", "Get past sets for a specific exercise template, optionally filtered by start and end dates.", getExerciseHistorySchema, readOnlyAnnotations("Get Exercise History"), withErrorHandling(async (args) => {
896
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
1115
+ const client = requireClient(hevyClient);
897
1116
  const { exerciseTemplateId, startDate, endDate } = args;
898
- const history = (await hevyClient.getExerciseHistory(exerciseTemplateId, {
1117
+ const history = (await client.getExerciseHistory(exerciseTemplateId, {
899
1118
  ...startDate ? { start_date: startDate } : {},
900
1119
  ...endDate ? { end_date: endDate } : {}
901
1120
  }))?.exercise_history?.map((entry) => formatExerciseHistoryEntry(entry)) || [];
@@ -904,35 +1123,16 @@ function registerTemplateTools(server, hevyClient) {
904
1123
  }, "get-exercise-history"));
905
1124
  const createExerciseTemplateSchema = {
906
1125
  title: z.string().min(1),
907
- exerciseType: z.enum([
908
- "weight_reps",
909
- "reps_only",
910
- "bodyweight_reps",
911
- "bodyweight_assisted_reps",
912
- "duration",
913
- "weight_duration",
914
- "distance_duration",
915
- "short_distance_weight"
916
- ]),
917
- equipmentCategory: z.enum([
918
- "none",
919
- "barbell",
920
- "dumbbell",
921
- "kettlebell",
922
- "machine",
923
- "plate",
924
- "resistance_band",
925
- "suspension",
926
- "other"
927
- ]),
928
- muscleGroup: z.enum(MUSCLE_GROUPS),
929
- otherMuscles: z.array(z.enum(MUSCLE_GROUPS)).default([])
1126
+ exerciseType: exerciseTypeEnum,
1127
+ equipmentCategory: equipmentCategoryEnum,
1128
+ muscleGroup: muscleGroupEnum,
1129
+ otherMuscles: z.array(muscleGroupEnum).default([])
930
1130
  };
931
1131
  server.tool("create-exercise-template", "Create a custom exercise template with title, type, equipment, and muscle groups.", createExerciseTemplateSchema, createAnnotations("Create Exercise Template"), withErrorHandling(async (args) => {
932
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
1132
+ const client = requireClient(hevyClient);
933
1133
  const { title, exerciseType, equipmentCategory, muscleGroup, otherMuscles } = args;
934
1134
  return createJsonResponse({
935
- id: (await hevyClient.createExerciseTemplate({ exercise: {
1135
+ id: (await client.createExerciseTemplate({ exercise: {
936
1136
  title,
937
1137
  exercise_type: exerciseType,
938
1138
  equipment_category: equipmentCategory,
@@ -944,40 +1144,15 @@ function registerTemplateTools(server, hevyClient) {
944
1144
  }, "create-exercise-template"));
945
1145
  const searchExerciseTemplatesSchema = {
946
1146
  query: z.string().min(1).describe("Case-insensitive substring to match against exercise template titles"),
947
- primaryMuscleGroup: z.enum(MUSCLE_GROUPS).optional().describe("Optional filter to restrict results to a specific primary muscle group"),
948
- refresh: z.boolean().optional().default(false).describe("Set to true to bust the in-memory cache and re-fetch all templates from the API")
1147
+ primaryMuscleGroup: muscleGroupEnum.optional().describe("Optional filter to restrict results to a specific primary muscle group"),
1148
+ refresh: z.boolean().optional().default(false).describe("Set to true to invalidate the catalog cache and re-fetch all templates from the API")
949
1149
  };
950
- 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, readOnlyAnnotations("Search Exercise Templates"), withErrorHandling(async (args) => {
951
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
1150
+ 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, caches the catalog in memory with a bounded TTL cache, and reuses it for subsequent searches. Use refresh:true to force a re-fetch.", searchExerciseTemplatesSchema, readOnlyAnnotations("Search Exercise Templates"), withErrorHandling(async (args) => {
1151
+ const client = requireClient(hevyClient);
952
1152
  const { query, primaryMuscleGroup, refresh } = args;
953
- if (exerciseTemplateCache === null || refresh) {
954
- if (refresh) exerciseTemplateFetch = null;
955
- if (exerciseTemplateFetch === null) exerciseTemplateFetch = (async () => {
956
- try {
957
- const allTemplates = [];
958
- let page = 1;
959
- let pageCount = 1;
960
- do {
961
- const data = await hevyClient.getExerciseTemplates({
962
- page,
963
- pageSize: 100
964
- });
965
- const templates = data?.exercise_templates ?? [];
966
- allTemplates.push(...templates);
967
- pageCount = data?.page_count ?? 1;
968
- page++;
969
- } while (page <= pageCount);
970
- exerciseTemplateCache = allTemplates;
971
- return allTemplates;
972
- } finally {
973
- exerciseTemplateFetch = null;
974
- }
975
- })();
976
- await exerciseTemplateFetch;
977
- }
1153
+ const catalog = await exerciseTemplateCatalogCache.getOrFetch(EXERCISE_TEMPLATE_CATALOG_CACHE_KEY, () => fetchExerciseTemplateCatalog(client), { refresh });
978
1154
  const queryLower = query.toLowerCase();
979
- if (exerciseTemplateCache === null) throw new Error("Failed to populate exercise template cache.");
980
- let results = exerciseTemplateCache.filter((t) => (t.title ?? "").toLowerCase().includes(queryLower));
1155
+ let results = catalog.filter((t) => (t.title ?? "").toLowerCase().includes(queryLower));
981
1156
  if (primaryMuscleGroup !== void 0) results = results.filter((t) => t.primary_muscle_group === primaryMuscleGroup);
982
1157
  if (results.length === 0) return createEmptyResponse(`No exercise templates found matching "${query}"${primaryMuscleGroup ? ` with primary muscle group "${primaryMuscleGroup}"` : ""}`);
983
1158
  return createJsonResponse(results.map(formatExerciseTemplate));
@@ -987,8 +1162,7 @@ function registerTemplateTools(server, hevyClient) {
987
1162
  //#region src/tools/user.ts
988
1163
  function registerUserTools(server, hevyClient) {
989
1164
  server.tool("get-user-info", "Get the authenticated user's account info, including user ID, display name, and public profile URL. Useful for verifying which account the API key belongs to.", {}, readOnlyAnnotations("Get User Info"), withErrorHandling(async (_args) => {
990
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
991
- const data = await hevyClient.getUserInfo();
1165
+ const data = await requireClient(hevyClient).getUserInfo();
992
1166
  if (!data?.data) return createEmptyResponse("No user info found for the authenticated user");
993
1167
  return createJsonResponse(data.data);
994
1168
  }, "get-user-info"));
@@ -1004,9 +1178,9 @@ function registerWorkoutTools(server, hevyClient) {
1004
1178
  pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
1005
1179
  };
1006
1180
  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, readOnlyAnnotations("Get Workouts"), withErrorHandling(async (args) => {
1007
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
1181
+ const client = requireClient(hevyClient);
1008
1182
  const { page, pageSize } = args;
1009
- const workouts = (await hevyClient.getWorkouts({
1183
+ const workouts = (await client.getWorkouts({
1010
1184
  page,
1011
1185
  pageSize
1012
1186
  }))?.workouts?.map((workout) => formatWorkout(workout)) || [];
@@ -1015,15 +1189,14 @@ function registerWorkoutTools(server, hevyClient) {
1015
1189
  }, "get-workouts"));
1016
1190
  const getWorkoutSchema = { workoutId: z.string().min(1) };
1017
1191
  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, readOnlyAnnotations("Get Workout"), withErrorHandling(async (args) => {
1018
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
1192
+ const client = requireClient(hevyClient);
1019
1193
  const { workoutId } = args;
1020
- const data = await hevyClient.getWorkout(workoutId);
1194
+ const data = await client.getWorkout(workoutId);
1021
1195
  if (!data) return createEmptyResponse(`Workout with ID ${workoutId} not found`);
1022
1196
  return createJsonResponse(formatWorkout(data));
1023
1197
  }, "get-workout"));
1024
1198
  server.tool("get-workout-count", "Get the total number of workouts on the account. Useful for pagination or statistics.", {}, readOnlyAnnotations("Get Workout Count"), withErrorHandling(async () => {
1025
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
1026
- return createJsonResponse({ count: (await hevyClient.getWorkoutCount())?.workout_count ?? 0 });
1199
+ return createJsonResponse({ count: (await requireClient(hevyClient).getWorkoutCount())?.workout_count ?? 0 });
1027
1200
  }, "get-workout-count"));
1028
1201
  const getWorkoutEventsSchema = {
1029
1202
  page: z.coerce.number().int().gte(1).default(1),
@@ -1031,9 +1204,9 @@ function registerWorkoutTools(server, hevyClient) {
1031
1204
  since: z.string().default("1970-01-01T00:00:00Z")
1032
1205
  };
1033
1206
  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, readOnlyAnnotations("Get Workout Events"), withErrorHandling(async (args) => {
1034
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
1207
+ const client = requireClient(hevyClient);
1035
1208
  const { page, pageSize, since } = args;
1036
- const events = (await hevyClient.getWorkoutEvents({
1209
+ const events = (await client.getWorkoutEvents({
1037
1210
  page,
1038
1211
  pageSize,
1039
1212
  since
@@ -1052,12 +1225,7 @@ function registerWorkoutTools(server, hevyClient) {
1052
1225
  supersetId: z.coerce.number().nullable().optional(),
1053
1226
  notes: z.string().optional().nullable(),
1054
1227
  sets: z.array(z.object({
1055
- type: z.enum([
1056
- "warmup",
1057
- "normal",
1058
- "failure",
1059
- "dropset"
1060
- ]).default("normal"),
1228
+ type: setTypeEnum,
1061
1229
  weight: z.coerce.number().optional().nullable(),
1062
1230
  weightKg: z.coerce.number().optional().nullable(),
1063
1231
  reps: z.coerce.number().int().optional().nullable(),
@@ -1071,7 +1239,7 @@ function registerWorkoutTools(server, hevyClient) {
1071
1239
  })))
1072
1240
  };
1073
1241
  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, createAnnotations("Create Workout"), withErrorHandling(async (args) => {
1074
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
1242
+ const client = requireClient(hevyClient);
1075
1243
  const { title, description, startTime, endTime, isPrivate, exercises } = args;
1076
1244
  const requestBody = { workout: {
1077
1245
  title,
@@ -1094,7 +1262,7 @@ function registerWorkoutTools(server, hevyClient) {
1094
1262
  }))
1095
1263
  }))
1096
1264
  } };
1097
- const data = await hevyClient.createWorkout(requestBody);
1265
+ const data = await client.createWorkout(requestBody);
1098
1266
  if (!data) return createEmptyResponse("Failed to create workout: Server returned no data");
1099
1267
  return createJsonResponse(formatWorkout(data), {
1100
1268
  pretty: true,
@@ -1113,12 +1281,7 @@ function registerWorkoutTools(server, hevyClient) {
1113
1281
  supersetId: z.coerce.number().nullable().optional(),
1114
1282
  notes: z.string().optional().nullable(),
1115
1283
  sets: z.array(z.object({
1116
- type: z.enum([
1117
- "warmup",
1118
- "normal",
1119
- "failure",
1120
- "dropset"
1121
- ]).default("normal"),
1284
+ type: setTypeEnum,
1122
1285
  weight: z.coerce.number().optional().nullable(),
1123
1286
  weightKg: z.coerce.number().optional().nullable(),
1124
1287
  reps: z.coerce.number().int().optional().nullable(),
@@ -1132,7 +1295,7 @@ function registerWorkoutTools(server, hevyClient) {
1132
1295
  })))
1133
1296
  };
1134
1297
  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, updateAnnotations("Update Workout"), withErrorHandling(async (args) => {
1135
- if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
1298
+ const client = requireClient(hevyClient);
1136
1299
  const { workoutId, title, description, startTime, endTime, isPrivate, exercises } = args;
1137
1300
  const requestBody = { workout: {
1138
1301
  title,
@@ -1155,7 +1318,7 @@ function registerWorkoutTools(server, hevyClient) {
1155
1318
  }))
1156
1319
  }))
1157
1320
  } };
1158
- const data = await hevyClient.updateWorkout(workoutId, requestBody);
1321
+ const data = await client.updateWorkout(workoutId, requestBody);
1159
1322
  if (!data) return createEmptyResponse(`Failed to update workout with ID ${workoutId}`);
1160
1323
  return createJsonResponse(formatWorkout(data), {
1161
1324
  pretty: true,
@@ -1165,16 +1328,23 @@ function registerWorkoutTools(server, hevyClient) {
1165
1328
  }
1166
1329
  //#endregion
1167
1330
  //#region src/utils/config.ts
1331
+ const DEPRECATED_CLI_ARGUMENT_WARNING = [
1332
+ "DEPRECATION WARNING: Passing the Hevy API key via CLI arguments",
1333
+ "(--hevy-api-key=..., --hevyApiKey=..., hevy-api-key=...) is",
1334
+ "deprecated and insecure. Use the HEVY_API_KEY environment",
1335
+ "variable instead."
1336
+ ].join(" ");
1168
1337
  /**
1169
1338
  * Parse CLI arguments and environment to derive configuration.
1170
- * Priority order for API key: CLI flag forms > environment variable.
1171
- * Supported CLI arg forms:
1339
+ * Priority order for API key: deprecated CLI flag forms > environment variable.
1340
+ * Supported deprecated CLI arg forms:
1172
1341
  * --hevy-api-key=KEY
1173
1342
  * --hevyApiKey=KEY
1174
1343
  * hevy-api-key=KEY (bare, e.g. when passed after npm start -- )
1175
1344
  */
1176
1345
  function parseConfig(argv, env) {
1177
1346
  let apiKey = "";
1347
+ let usedDeprecatedApiKeyArg = false;
1178
1348
  const apiKeyArgPatterns = [
1179
1349
  /^--hevy-api-key=(.+)$/i,
1180
1350
  /^--hevyApiKey=(.+)$/i,
@@ -1185,17 +1355,19 @@ function parseConfig(argv, env) {
1185
1355
  const m = raw.match(pattern);
1186
1356
  if (m) {
1187
1357
  apiKey = m[1];
1358
+ usedDeprecatedApiKeyArg = true;
1188
1359
  break;
1189
1360
  }
1190
1361
  }
1191
1362
  if (apiKey) break;
1192
1363
  }
1364
+ if (usedDeprecatedApiKeyArg) console.error(DEPRECATED_CLI_ARGUMENT_WARNING);
1193
1365
  if (!apiKey) apiKey = env.HEVY_API_KEY || "";
1194
1366
  return { apiKey };
1195
1367
  }
1196
1368
  function assertApiKey(apiKey) {
1197
1369
  if (!apiKey) {
1198
- 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.");
1370
+ console.error("Hevy API key is required. Provide it via the HEVY_API_KEY environment variable.");
1199
1371
  process.exit(1);
1200
1372
  }
1201
1373
  }
@@ -1794,6 +1966,134 @@ async function putV1WorkoutsWorkoutid(workoutId, data, headers, config = {}) {
1794
1966
  function wrapApi(fn) {
1795
1967
  return fn;
1796
1968
  }
1969
+ const DEFAULT_API_TIMEOUT_MS = 3e4;
1970
+ const RETRY_BACKOFF_MAX_MS = 5e3;
1971
+ const HEVY_RETRY_EXHAUSTED_ERROR_CODE = "HEVY_RETRY_EXHAUSTED";
1972
+ const RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([
1973
+ 408,
1974
+ 429,
1975
+ 500,
1976
+ 502,
1977
+ 503,
1978
+ 504
1979
+ ]);
1980
+ const RETRYABLE_NETWORK_ERROR_CODES = /* @__PURE__ */ new Set([
1981
+ "ECONNABORTED",
1982
+ "ECONNREFUSED",
1983
+ "ECONNRESET",
1984
+ "EAI_AGAIN",
1985
+ "ENETUNREACH",
1986
+ "ENOTFOUND",
1987
+ "ERR_NETWORK",
1988
+ "ERR_SOCKET_TIMEOUT",
1989
+ "ETIMEDOUT"
1990
+ ]);
1991
+ function getApiTimeoutMs() {
1992
+ const rawValue = process.env.HEVY_MCP_API_TIMEOUT;
1993
+ if (!rawValue) return DEFAULT_API_TIMEOUT_MS;
1994
+ const parsed = Number(rawValue);
1995
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_API_TIMEOUT_MS;
1996
+ return Math.trunc(parsed);
1997
+ }
1998
+ function sleep(ms) {
1999
+ return new Promise((resolve) => {
2000
+ setTimeout(resolve, ms);
2001
+ });
2002
+ }
2003
+ function isGetRequest(config) {
2004
+ return config.method?.toUpperCase() === "GET";
2005
+ }
2006
+ function isRetryableStatus(status) {
2007
+ if (status === void 0) return false;
2008
+ return RETRYABLE_STATUS_CODES.has(status);
2009
+ }
2010
+ function isTransientNetworkError(error) {
2011
+ if (error.code === "ERR_CANCELED") return false;
2012
+ if (error.code && RETRYABLE_NETWORK_ERROR_CODES.has(error.code)) return true;
2013
+ if (error.response) return false;
2014
+ const message = error.message.toLowerCase();
2015
+ return message.includes("network") || message.includes("socket hang up") || message.includes("timeout");
2016
+ }
2017
+ function normalizeHeaderValue(value) {
2018
+ if (typeof value === "string") {
2019
+ const trimmed = value.trim();
2020
+ return trimmed.length > 0 ? trimmed : void 0;
2021
+ }
2022
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
2023
+ if (Array.isArray(value) && value.length > 0) return normalizeHeaderValue(value[0]);
2024
+ }
2025
+ function getHeaderValue(headers, key) {
2026
+ if (!headers || typeof headers !== "object") return;
2027
+ if ("get" in headers && typeof headers.get === "function") return normalizeHeaderValue(headers.get(key));
2028
+ const headerRecord = headers;
2029
+ return normalizeHeaderValue(headerRecord[key] ?? headerRecord[key.toLowerCase()] ?? headerRecord[key.toUpperCase()]);
2030
+ }
2031
+ function parseRetryAfterMs(value) {
2032
+ const numericSeconds = Number(value);
2033
+ if (Number.isFinite(numericSeconds) && numericSeconds >= 0) return Math.min(RETRY_BACKOFF_MAX_MS, Math.round(numericSeconds * 1e3));
2034
+ const dateMillis = Date.parse(value);
2035
+ if (Number.isNaN(dateMillis)) return;
2036
+ return Math.min(RETRY_BACKOFF_MAX_MS, Math.max(0, dateMillis - Date.now()));
2037
+ }
2038
+ function getRetryAfterDelayMs(error) {
2039
+ if (error.response?.status !== 429) return;
2040
+ const retryAfterHeader = getHeaderValue(error.response.headers, "retry-after");
2041
+ if (!retryAfterHeader) return;
2042
+ return parseRetryAfterMs(retryAfterHeader);
2043
+ }
2044
+ function getExponentialBackoffMs(retryAttempt) {
2045
+ const delay = 300 * 2 ** Math.max(0, retryAttempt - 1);
2046
+ return Math.min(RETRY_BACKOFF_MAX_MS, delay);
2047
+ }
2048
+ function getRetryDelayMs(error, retryAttempt) {
2049
+ const exponentialDelay = getExponentialBackoffMs(retryAttempt);
2050
+ const retryAfterDelay = getRetryAfterDelayMs(error);
2051
+ if (retryAfterDelay === void 0) return exponentialDelay;
2052
+ return Math.min(RETRY_BACKOFF_MAX_MS, Math.max(exponentialDelay, retryAfterDelay));
2053
+ }
2054
+ function shouldRetryRequest(config, error) {
2055
+ if (!isGetRequest(config)) return false;
2056
+ return isRetryableStatus(error.response?.status) || isTransientNetworkError(error);
2057
+ }
2058
+ function markRetryExhausted(error, retryCount) {
2059
+ const retryError = error;
2060
+ retryError.hevyRetryExhausted = true;
2061
+ retryError.hevyRetryCount = retryCount;
2062
+ retryError.hevyRetryStatus = error.response?.status;
2063
+ if (retryError.code) retryError.hevyRetryOriginalCode = retryError.code;
2064
+ retryError.code = HEVY_RETRY_EXHAUSTED_ERROR_CODE;
2065
+ }
2066
+ async function requestWithRetries(axiosInstance, config) {
2067
+ let retryCount = 0;
2068
+ while (true) try {
2069
+ return await axiosInstance.request(config);
2070
+ } catch (error) {
2071
+ if (!isAxiosError(error)) throw error;
2072
+ if (!shouldRetryRequest(config, error)) throw error;
2073
+ if (retryCount >= 3) {
2074
+ markRetryExhausted(error, retryCount);
2075
+ throw error;
2076
+ }
2077
+ retryCount += 1;
2078
+ await sleep(getRetryDelayMs(error, retryCount));
2079
+ }
2080
+ }
2081
+ function createResilientClient(axiosInstance) {
2082
+ let clientConfig = { baseURL: axiosInstance.defaults.baseURL };
2083
+ const resilientClient = (async (config) => {
2084
+ return requestWithRetries(axiosInstance, config);
2085
+ });
2086
+ resilientClient.getConfig = () => ({ ...clientConfig });
2087
+ resilientClient.setConfig = (config) => {
2088
+ clientConfig = {
2089
+ ...clientConfig,
2090
+ ...config
2091
+ };
2092
+ if (config.baseURL !== void 0) axiosInstance.defaults.baseURL = config.baseURL;
2093
+ return resilientClient.getConfig();
2094
+ };
2095
+ return resilientClient;
2096
+ }
1797
2097
  /**
1798
2098
  * Extract common request metadata from an Axios config.
1799
2099
  * Shared between the success and error response interceptors to avoid
@@ -1838,6 +2138,7 @@ function finalizeRequestTrace(opts) {
1838
2138
  function createClient$1(apiKey, baseUrl = "https://api.hevyapp.com") {
1839
2139
  const axiosInstance = axios.create({
1840
2140
  baseURL: baseUrl,
2141
+ timeout: getApiTimeoutMs(),
1841
2142
  headers: { "api-key": apiKey }
1842
2143
  });
1843
2144
  axiosInstance.interceptors.request.use((config) => {
@@ -1887,7 +2188,7 @@ function createClient$1(apiKey, baseUrl = "https://api.hevyapp.com") {
1887
2188
  throw error;
1888
2189
  });
1889
2190
  const headers = { "api-key": apiKey };
1890
- const client = axiosInstance;
2191
+ const client = createResilientClient(axiosInstance);
1891
2192
  return {
1892
2193
  getWorkouts: (params) => wrapApi(getV1Workouts)(headers, params, { client }),
1893
2194
  getWorkout: (workoutId) => wrapApi(getV1WorkoutsWorkoutid)(workoutId, headers, { client }),
@@ -1921,6 +2222,44 @@ function createClient(apiKey, baseUrl) {
1921
2222
  //#endregion
1922
2223
  //#region src/utils/stdio-observability.ts
1923
2224
  const UTF8_BOM = "";
2225
+ /**
2226
+ * Adapter boundary around MCP SDK stdio internals.
2227
+ *
2228
+ * MCP SDK v1.29.0 exposes public message-level hooks but does not expose a
2229
+ * public raw-chunk hook on `StdioServerTransport`. To capture chunk metadata,
2230
+ * we currently rely on private internals (`_ondata`, `_readBuffer`, `_buffer`)
2231
+ * in this one place.
2232
+ *
2233
+ * If those internals change in a future SDK release, this adapter should fail
2234
+ * closed and preserve default transport behavior (no instrumentation patching).
2235
+ */
2236
+ function createSdkPrivateStdioAdapter(transport) {
2237
+ const mutableTransport = transport;
2238
+ return {
2239
+ wrapOnData(onChunk) {
2240
+ const originalOnData = mutableTransport._ondata;
2241
+ if (typeof originalOnData !== "function") return;
2242
+ mutableTransport._ondata = (chunk) => {
2243
+ onChunk(chunk);
2244
+ originalOnData(chunk);
2245
+ };
2246
+ },
2247
+ installReadMessageHook(onReadLine) {
2248
+ const readBuffer = mutableTransport._readBuffer;
2249
+ if (!readBuffer || typeof readBuffer.readMessage !== "function") return false;
2250
+ readBuffer.readMessage = () => {
2251
+ const buffer = readBuffer._buffer;
2252
+ if (!buffer) return null;
2253
+ const index = buffer.indexOf("\n");
2254
+ if (index === -1) return null;
2255
+ const lineBuffer = buffer.subarray(0, index);
2256
+ readBuffer._buffer = buffer.subarray(index + 1);
2257
+ return onReadLine(lineBuffer.toString("utf8").replace(/\r$/, ""));
2258
+ };
2259
+ return true;
2260
+ }
2261
+ };
2262
+ }
1924
2263
  function hasUtf8BomPrefix(chunk) {
1925
2264
  return chunk.length >= 3 && chunk[0] === 239 && chunk[1] === 187 && chunk[2] === 191;
1926
2265
  }
@@ -1987,36 +2326,48 @@ function deserializeMessageWithObservability(line, chunkSnapshot) {
1987
2326
  });
1988
2327
  }
1989
2328
  function createInstrumentedStdioTransport(transport) {
1990
- const mutableTransport = transport;
2329
+ const privateAdapter = createSdkPrivateStdioAdapter(transport);
1991
2330
  let lastChunkSnapshot = {
1992
2331
  lastChunkByteLength: 0,
1993
2332
  lastChunkStartsWithUtf8Bom: false
1994
2333
  };
1995
- const originalOnData = mutableTransport._ondata;
1996
- if (typeof originalOnData === "function") mutableTransport._ondata = (chunk) => {
2334
+ privateAdapter.wrapOnData((chunk) => {
1997
2335
  lastChunkSnapshot = {
1998
2336
  lastChunkByteLength: chunk.byteLength,
1999
2337
  lastChunkStartsWithUtf8Bom: hasUtf8BomPrefix(chunk)
2000
2338
  };
2001
- originalOnData(chunk);
2002
- };
2003
- const readBuffer = mutableTransport._readBuffer;
2004
- if (!readBuffer || typeof readBuffer.readMessage !== "function") return transport;
2005
- readBuffer.readMessage = () => {
2006
- const buffer = readBuffer._buffer;
2007
- if (!buffer) return null;
2008
- const index = buffer.indexOf("\n");
2009
- if (index === -1) return null;
2010
- const lineBuffer = buffer.subarray(0, index);
2011
- readBuffer._buffer = buffer.subarray(index + 1);
2012
- return deserializeMessageWithObservability(lineBuffer.toString("utf8").replace(/\r$/, ""), lastChunkSnapshot);
2013
- };
2339
+ });
2340
+ if (!privateAdapter.installReadMessageHook((line) => deserializeMessageWithObservability(line, lastChunkSnapshot))) return transport;
2014
2341
  return transport;
2015
2342
  }
2016
2343
  //#endregion
2017
2344
  //#region src/index.ts
2018
2345
  const name = name$1;
2019
2346
  const version = version$1;
2347
+ const HELP_TEXT = [
2348
+ "Usage:",
2349
+ " hevy-mcp [options]",
2350
+ "",
2351
+ "Options:",
2352
+ " -h, --help Show this help message and exit",
2353
+ " -v, --version Show version and exit",
2354
+ " --hevy-api-key=<api-key> (deprecated, use HEVY_API_KEY env var)",
2355
+ "",
2356
+ "Environment:",
2357
+ " HEVY_API_KEY=<api-key> Hevy API key from Hevy app settings",
2358
+ "",
2359
+ "Examples:",
2360
+ " HEVY_API_KEY=your-key npx hevy-mcp",
2361
+ " npx hevy-mcp --hevy-api-key=your-key",
2362
+ " npm start -- --hevy-api-key=your-key"
2363
+ ].join("\n");
2364
+ function getCliAction(args) {
2365
+ for (const arg of args) {
2366
+ if (arg === "--version" || arg === "-v") return "version";
2367
+ if (arg === "--help" || arg === "-h") return "help";
2368
+ }
2369
+ return "start";
2370
+ }
2020
2371
  const HEVY_API_BASEURL = "https://api.hevyapp.com";
2021
2372
  const SENTRY_USER_ID_CONTEXT = "hevy-mcp:sentry-user-id:v1";
2022
2373
  function fingerprintApiKey(apiKey) {
@@ -2075,10 +2426,20 @@ function createServer({ config }) {
2075
2426
  return buildServer(apiKey);
2076
2427
  }
2077
2428
  async function runServer() {
2429
+ const args = process.argv.slice(2);
2430
+ const cliAction = getCliAction(args);
2431
+ if (cliAction === "version") {
2432
+ console.log(version);
2433
+ return;
2434
+ }
2435
+ if (cliAction === "help") {
2436
+ console.log(HELP_TEXT);
2437
+ return;
2438
+ }
2078
2439
  serverStartups.add(1, { version });
2079
2440
  await tracer.startActiveSpan("mcp.server.run", { attributes: { "mcp.transport": "stdio" } }, async (span) => {
2080
2441
  try {
2081
- const apiKey = parseConfig(process.argv.slice(2), process.env).apiKey;
2442
+ const apiKey = parseConfig(args, process.env).apiKey;
2082
2443
  assertApiKey(apiKey);
2083
2444
  const server = buildServer(apiKey);
2084
2445
  console.error("Starting MCP server in stdio mode");
@@ -2106,4 +2467,4 @@ async function runServer() {
2106
2467
  //#endregion
2107
2468
  export { createServer as n, runServer as r, configSchema as t };
2108
2469
 
2109
- //# sourceMappingURL=src-Di7keTQA.mjs.map
2470
+ //# sourceMappingURL=src-CuOZcBEo.mjs.map