hevy-mcp 3.1.1 → 3.2.1

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@3.1.1" };
7
+ e.SENTRY_RELEASE = { id: "hevy-mcp@3.2.1" };
8
8
  var n = new e.Error().stack;
9
- n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "c68eeb18-d351-49fe-9f67-3f26fa89bfdb", e._sentryDebugIdIdentifier = "sentry-dbid-c68eeb18-d351-49fe-9f67-3f26fa89bfdb");
9
+ n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "8ce801d0-ad38-40ee-a7f3-dc829e212db8", e._sentryDebugIdIdentifier = "sentry-dbid-8ce801d0-ad38-40ee-a7f3-dc829e212db8");
10
10
  } catch (e) {}
11
11
  })();
12
12
  import * as Sentry from "@sentry/node";
@@ -40,10 +40,10 @@ import semver from "semver";
40
40
  * OTel Collector → Honeycomb: performance traces, metrics
41
41
  */
42
42
  const name$1 = "hevy-mcp";
43
- const version$1 = "3.1.1";
43
+ const version$1 = "3.2.1";
44
44
  const collectorToken = "NH9vOela-HYreQxAbJa68cjEmORoEKM57EvneUDVcOo";
45
45
  const COLLECTOR_ENDPOINT = "https://otel.chrisdoc.dev/v1";
46
- const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@3.1.1`;
46
+ const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@3.2.1`;
47
47
  const resource = resourceFromAttributes({
48
48
  "service.name": name$1,
49
49
  "service.version": version$1
@@ -127,8 +127,81 @@ const stdioParseErrors = meter.createCounter("mcp.stdio.parse_errors", { descrip
127
127
  /** Total server startup count, grouped by version. */
128
128
  const serverStartups = meter.createCounter("mcp.server.startups", { description: "Total server startup count" });
129
129
  //#endregion
130
+ //#region src/utils/schemas.ts
131
+ function coerceNullishNumberInput(value) {
132
+ if (value === null || value === void 0) return value;
133
+ if (typeof value !== "string") return value;
134
+ const trimmed = value.trim();
135
+ if (trimmed === "") return;
136
+ const lowered = trimmed.toLowerCase();
137
+ if (lowered === "null") return null;
138
+ if (lowered === "undefined") return;
139
+ const asNumber = Number(trimmed);
140
+ if (Number.isNaN(asNumber)) return value;
141
+ return asNumber;
142
+ }
143
+ const zNullableInt = z.preprocess(coerceNullishNumberInput, z.number().int().nullable().optional());
144
+ const zNullableNumber = z.preprocess((value) => value === "" ? void 0 : value, z.coerce.number().nullable().optional());
145
+ const zOptionalRepRange = z.preprocess((value) => value === null ? void 0 : value, z.object({
146
+ start: zNullableInt,
147
+ end: zNullableInt
148
+ }).optional());
149
+ const setTypeEnum = z.enum([
150
+ "warmup",
151
+ "normal",
152
+ "failure",
153
+ "dropset"
154
+ ]).default("normal");
155
+ const muscleGroupEnum = z.enum([
156
+ "abdominals",
157
+ "shoulders",
158
+ "biceps",
159
+ "triceps",
160
+ "forearms",
161
+ "quadriceps",
162
+ "hamstrings",
163
+ "calves",
164
+ "glutes",
165
+ "abductors",
166
+ "adductors",
167
+ "lats",
168
+ "upper_back",
169
+ "traps",
170
+ "lower_back",
171
+ "chest",
172
+ "cardio",
173
+ "neck",
174
+ "full_body",
175
+ "other"
176
+ ]);
177
+ const exerciseTypeEnum = z.enum([
178
+ "weight_reps",
179
+ "reps_only",
180
+ "bodyweight_reps",
181
+ "bodyweight_assisted_reps",
182
+ "duration",
183
+ "weight_duration",
184
+ "distance_duration",
185
+ "short_distance_weight"
186
+ ]);
187
+ const equipmentCategoryEnum = z.enum([
188
+ "none",
189
+ "barbell",
190
+ "dumbbell",
191
+ "kettlebell",
192
+ "machine",
193
+ "plate",
194
+ "resistance_band",
195
+ "suspension",
196
+ "other"
197
+ ]);
198
+ const UTC_TIMESTAMP_MESSAGE = "Must use the UTC format YYYY-MM-DDTHH:mm:ssZ";
199
+ const utcSecondTimestamp = z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, UTC_TIMESTAMP_MESSAGE).refine((value) => {
200
+ const parsed = new Date(value);
201
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().replace(".000Z", "Z") === value;
202
+ }, UTC_TIMESTAMP_MESSAGE);
203
+ //#endregion
130
204
  //#region src/prompts/workouts.ts
131
- const utcSecondTimestamp = z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, "Must use the UTC format YYYY-MM-DDTHH:mm:ssZ");
132
205
  /** Register guided workout workflow prompts. */
133
206
  function registerWorkoutPrompts(server) {
134
207
  server.registerPrompt("analyze-workout-progress", {
@@ -141,9 +214,8 @@ function registerWorkoutPrompts(server) {
141
214
  type: "text",
142
215
  text: [
143
216
  `Analyze my workout progress over the last ${weeks} weeks.`,
144
- "Use get-workout-count to establish the available workout total.",
145
- "Then call get-workouts with pageSize=10 and continue through pages until the requested date window is fully covered or no more workouts remain.",
146
- "Also call get-body-measurements with pageSize=10 and paginate until the same date window is covered or no more measurements remain.",
217
+ "Call get-training-summary with the requested weeks; it combines recent workouts and body measurements into one compact evidence set.",
218
+ "Use the returned period, workout frequency, volume, exercise variety, session list, and measurement trend fields rather than issuing separate count and pagination calls.",
147
219
  "Base the analysis on retrieved evidence and discuss workout frequency, training volume, exercise variety, consistency, and body-measurement trends.",
148
220
  "Distinguish observations from suggestions, note missing or limited data, and do not make unsupported claims or medical conclusions."
149
221
  ].join("\n")
@@ -173,137 +245,31 @@ function registerWorkoutPrompts(server) {
173
245
  }] }));
174
246
  }
175
247
  //#endregion
176
- //#region src/utils/cache.ts
177
- /**
178
- * Shared in-memory cache for async fetches with TTL, LRU eviction,
179
- * in-flight de-duplication, and explicit refresh/invalidation support.
180
- */
181
- var AsyncTtlCache = class {
182
- ttlMs;
183
- maxSize;
184
- now;
185
- entries = /* @__PURE__ */ new Map();
186
- inFlight = /* @__PURE__ */ new Map();
187
- requestCounter = 0;
188
- constructor(options, now = Date.now) {
189
- const { ttlMs, maxSize } = options;
190
- if (ttlMs <= 0) throw new Error("Cache ttlMs must be greater than 0.");
191
- if (maxSize <= 0) throw new Error("Cache maxSize must be greater than 0.");
192
- this.ttlMs = ttlMs;
193
- this.maxSize = maxSize;
194
- this.now = now;
195
- }
196
- async getOrFetch(key, fetcher, options = {}) {
197
- const { refresh = false } = options;
198
- if (refresh) this.invalidate(key);
199
- else {
200
- const cachedEntry = this.entries.get(key);
201
- if (cachedEntry !== void 0) {
202
- if (cachedEntry.expiresAt > this.now()) {
203
- this.markAsRecentlyUsed(key, cachedEntry);
204
- return cachedEntry.value;
205
- }
206
- this.entries.delete(key);
207
- }
208
- const inFlightEntry = this.inFlight.get(key);
209
- if (inFlightEntry !== void 0) return inFlightEntry.promise;
210
- }
211
- const requestId = ++this.requestCounter;
212
- const request = (async () => {
213
- try {
214
- const value = await fetcher();
215
- if (this.isCurrentRequest(key, requestId)) this.setValue(key, value);
216
- return value;
217
- } finally {
218
- if (this.inFlight.get(key)?.requestId === requestId) this.inFlight.delete(key);
219
- }
220
- })();
221
- this.inFlight.set(key, {
222
- promise: request,
223
- requestId
224
- });
225
- return request;
226
- }
227
- invalidate(key) {
228
- this.entries.delete(key);
229
- this.inFlight.delete(key);
230
- }
231
- clear() {
232
- this.entries.clear();
233
- this.inFlight.clear();
234
- }
235
- get size() {
236
- return this.entries.size;
237
- }
238
- isCurrentRequest(key, requestId) {
239
- return this.inFlight.get(key)?.requestId === requestId;
240
- }
241
- markAsRecentlyUsed(key, entry) {
242
- this.entries.delete(key);
243
- this.entries.set(key, entry);
244
- }
245
- setValue(key, value) {
246
- this.entries.delete(key);
247
- this.entries.set(key, {
248
- value,
249
- expiresAt: this.now() + this.ttlMs
250
- });
251
- this.evictLeastRecentlyUsed();
252
- }
253
- evictLeastRecentlyUsed() {
254
- while (this.entries.size > this.maxSize) {
255
- const oldestKey = this.entries.keys().next().value;
256
- if (oldestKey === void 0) return;
257
- this.entries.delete(oldestKey);
258
- }
259
- }
260
- };
261
- //#endregion
262
- //#region src/utils/exercise-template-catalog.ts
263
- const EXERCISE_TEMPLATE_CATALOG_CACHE_KEY = "exercise-template-catalog";
264
- const EXERCISE_TEMPLATE_CATALOG_CACHE_TTL_MS = 300 * 1e3;
265
- const EXERCISE_TEMPLATE_CATALOG_CACHE_MAX_SIZE = 1;
266
- function getSafePageCount$1(data, currentPage) {
267
- const pageCount = data?.page_count;
268
- if (typeof pageCount !== "number" || !Number.isSafeInteger(pageCount) || pageCount < currentPage) return currentPage;
269
- return pageCount;
270
- }
271
- async function fetchExerciseTemplateCatalog(hevyClient) {
272
- const allTemplates = [];
248
+ //#region src/utils/pagination.ts
249
+ async function fetchAllPages(loader, pageSize) {
250
+ const items = [];
273
251
  let page = 1;
274
- let pageCount = 1;
275
- do {
276
- const data = await hevyClient.getExerciseTemplates({
277
- page,
278
- pageSize: 100
279
- });
280
- allTemplates.push(...data?.exercise_templates ?? []);
281
- pageCount = getSafePageCount$1(data, page);
282
- page++;
283
- } while (page <= pageCount);
284
- return allTemplates;
285
- }
286
- /** Create a cache owned by one MCP server/request lifecycle. */
287
- function createExerciseTemplateCatalog() {
288
- const cache = new AsyncTtlCache({
289
- ttlMs: EXERCISE_TEMPLATE_CATALOG_CACHE_TTL_MS,
290
- maxSize: EXERCISE_TEMPLATE_CATALOG_CACHE_MAX_SIZE
291
- });
292
- return {
293
- get(hevyClient, options = {}) {
294
- const reason = options.refresh ? "explicit-refresh" : cache.size === 0 ? "initial-load" : "ttl-expired";
295
- return cache.getOrFetch(EXERCISE_TEMPLATE_CATALOG_CACHE_KEY, async () => {
296
- const catalog = await fetchExerciseTemplateCatalog(hevyClient);
297
- options.onRefreshed?.(catalog, reason);
298
- return catalog;
299
- }, options);
300
- },
301
- reset() {
302
- cache.clear();
303
- }
304
- };
252
+ while (true) {
253
+ const result = await loader(page, pageSize);
254
+ if (result.items.length === 0) return items;
255
+ items.push(...result.items);
256
+ const pageCount = result.pageCount;
257
+ if (typeof pageCount !== "number" || !Number.isSafeInteger(pageCount) || pageCount <= page) return items;
258
+ page += 1;
259
+ }
305
260
  }
306
261
  //#endregion
262
+ //#region src/generated/client/schemas/userInfoSchema.ts
263
+ /**
264
+ * Generated by Kubb (https://kubb.dev/).
265
+ * Do not edit manually.
266
+ */
267
+ const userInfoSchema = z$1.object({
268
+ id: z$1.optional(z$1.string().describe("The user ID.")),
269
+ name: z$1.optional(z$1.string().describe("The user's display name.")),
270
+ url: z$1.optional(z$1.string().describe("The user's public profile URL."))
271
+ });
272
+ //#endregion
307
273
  //#region src/utils/hevy-http-error.ts
308
274
  const HEVY_RETRY_EXHAUSTED_ERROR_CODE = "HEVY_RETRY_EXHAUSTED";
309
275
  const HEVY_REQUEST_ABORTED_ERROR_CODE = "HEVY_REQUEST_ABORTED";
@@ -334,7 +300,7 @@ function isHevyHttpError(error) {
334
300
  return error instanceof HevyHttpError;
335
301
  }
336
302
  //#endregion
337
- //#region src/utils/safe-error-diagnostic.ts
303
+ //#region src/utils/error-policy.ts
338
304
  const SAFE_ERROR_CODES = /* @__PURE__ */ new Set([
339
305
  "EAI_AGAIN",
340
306
  "ECONNABORTED",
@@ -385,6 +351,108 @@ const SAFE_SOURCE_SUFFIXES = [
385
351
  ];
386
352
  const PROJECT_PATH_MARKER = "/hevy-mcp/";
387
353
  const MAX_STACK_POSITION = 1e6;
354
+ function normalizeHeaderValue(value) {
355
+ if (typeof value === "string") {
356
+ const trimmed = value.trim();
357
+ return trimmed.length > 0 ? trimmed : void 0;
358
+ }
359
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
360
+ if (Array.isArray(value) && value.length > 0) return normalizeHeaderValue(value[0]);
361
+ }
362
+ function getHeaderValue(headers, key) {
363
+ try {
364
+ if (!headers || typeof headers !== "object") return void 0;
365
+ if ("get" in headers && typeof headers.get === "function") return normalizeHeaderValue(headers.get(key));
366
+ const headerRecord = headers;
367
+ return normalizeHeaderValue(headerRecord[key] ?? headerRecord[key.toLowerCase()] ?? headerRecord[key.toUpperCase()]);
368
+ } catch {
369
+ return;
370
+ }
371
+ }
372
+ /** Extract a valid HTTP status without retaining untrusted error metadata. */
373
+ function extractErrorStatus(error) {
374
+ try {
375
+ if (!isHevyHttpError(error)) return void 0;
376
+ return error.status !== void 0 && Number.isInteger(error.status) && error.status >= 100 && error.status <= 599 ? error.status : void 0;
377
+ } catch {
378
+ return;
379
+ }
380
+ }
381
+ /** Return whether the client exhausted its bounded transient retry policy. */
382
+ function isRetryExhausted(error) {
383
+ try {
384
+ return !!error && typeof error === "object" && error.hevyRetryExhausted === true;
385
+ } catch {
386
+ return false;
387
+ }
388
+ }
389
+ /** Interpret a Retry-After header as seconds from the supplied current time. */
390
+ function getRetryAfterSeconds(error, now = Date.now()) {
391
+ try {
392
+ if (!isHevyHttpError(error)) return void 0;
393
+ const retryAfterHeader = getHeaderValue(error.headers, "retry-after");
394
+ if (!retryAfterHeader) return void 0;
395
+ const seconds = Number(retryAfterHeader);
396
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds;
397
+ const retryAtMillis = Date.parse(retryAfterHeader);
398
+ if (Number.isNaN(retryAtMillis)) return void 0;
399
+ return Math.ceil(Math.max(0, retryAtMillis - now) / 1e3);
400
+ } catch {
401
+ return;
402
+ }
403
+ }
404
+ /** Map bounded Hevy HTTP statuses to stable user-facing messages. */
405
+ function getStatusErrorMessage(status) {
406
+ if (status === 401 || status === 403) return "The Hevy API key is invalid or has expired. Check HEVY_API_KEY.";
407
+ if (status === 404) return "The requested resource was not found in Hevy.";
408
+ if (status === 409) return "A conflict occurred (e.g., a body measurement already exists for this date). Use the update tool instead.";
409
+ if (status === 422) return "The request failed Hevy validation. Check the field values and try again.";
410
+ if (status === 429) return "Rate limited by Hevy. Please wait and retry.";
411
+ if (status !== void 0 && status >= 500 && status <= 599) return "Hevy API experienced an error. Please retry later.";
412
+ return null;
413
+ }
414
+ function formatSecondsLabel(seconds) {
415
+ const roundedSeconds = Math.max(0, Math.round(seconds));
416
+ return `${roundedSeconds} second${roundedSeconds === 1 ? "" : "s"}`;
417
+ }
418
+ function getRateLimitMessage(error) {
419
+ const seconds = getRetryAfterSeconds(error);
420
+ if (seconds !== void 0) return `Rate limited by Hevy (HTTP 429). Please wait about ${formatSecondsLabel(seconds)} before retrying.`;
421
+ return "Rate limited by Hevy (HTTP 429). Please wait and retry your request.";
422
+ }
423
+ function getRetryExhaustedMessage(error) {
424
+ let retryCount;
425
+ try {
426
+ retryCount = typeof error === "object" && error !== null ? error.hevyRetryCount : void 0;
427
+ } catch {
428
+ retryCount = void 0;
429
+ }
430
+ const attemptCount = typeof retryCount === "number" && Number.isFinite(retryCount) ? retryCount + 1 : void 0;
431
+ if (attemptCount) return `Unable to complete the request after ${attemptCount} attempts to the Hevy API due to transient failures. Please try again shortly.`;
432
+ return "Unable to complete the request after multiple attempts to the Hevy API due to transient failures. Please try again shortly.";
433
+ }
434
+ /** Classify an error using bounded status, names, and supplied text. */
435
+ function determineErrorType(error, message) {
436
+ if (isRetryExhausted(error)) return "NETWORK_ERROR";
437
+ if (extractErrorStatus(error) === 429) return "RATE_LIMIT";
438
+ let originalMessage = "";
439
+ let nameLower = "";
440
+ try {
441
+ if (error instanceof Error) {
442
+ originalMessage = error.message.slice(0, 512);
443
+ nameLower = error.name.toLowerCase();
444
+ }
445
+ } catch {
446
+ originalMessage = "";
447
+ nameLower = "";
448
+ }
449
+ const classificationText = `${message}\n${originalMessage}`.toLowerCase();
450
+ if (nameLower.includes("network") || classificationText.includes("network") || classificationText.includes("fetch") || classificationText.includes("timeout")) return "NETWORK_ERROR";
451
+ if (nameLower.includes("validation") || classificationText.includes("validation") || classificationText.includes("invalid") || classificationText.includes("required")) return "VALIDATION_ERROR";
452
+ if (classificationText.includes("not found") || classificationText.includes("404") || classificationText.includes("does not exist")) return "NOT_FOUND";
453
+ if (nameLower.includes("api") || classificationText.includes("api") || classificationText.includes("server error") || classificationText.includes("500")) return "API_ERROR";
454
+ return "UNKNOWN_ERROR";
455
+ }
388
456
  function classifyError(error) {
389
457
  if (isHevyHttpError(error)) return "HevyHttpError";
390
458
  if (error instanceof TypeError) return "TypeError";
@@ -403,10 +471,6 @@ function getSafeCode(error) {
403
471
  const code = error.code;
404
472
  return typeof code === "string" && SAFE_ERROR_CODES.has(code) ? code : void 0;
405
473
  }
406
- function getSafeStatus(error) {
407
- if (!isHevyHttpError(error)) return void 0;
408
- return error.status !== void 0 && Number.isInteger(error.status) && error.status >= 100 && error.status <= 599 ? error.status : void 0;
409
- }
410
474
  function getSafeMethod(error) {
411
475
  if (!isHevyHttpError(error)) return void 0;
412
476
  const method = error.method.toUpperCase();
@@ -419,7 +483,7 @@ function getSafeEndpoint(error) {
419
483
  function parseSafeStackFrames(error) {
420
484
  if (!(error instanceof Error) || typeof error.stack !== "string") return;
421
485
  const frames = [];
422
- for (const frameLine of error.stack.split("\n").slice(1)) {
486
+ for (const frameLine of error.stack.split(/\r?\n/).slice(1)) {
423
487
  const match = /^\s{4}at (?:[^()\r\n]+ \()?([^()\s\r\n]+):(\d+):(\d+)\)?$/.exec(frameLine);
424
488
  if (!match) continue;
425
489
  const [, rawSource, rawLine, rawColumn] = match;
@@ -439,11 +503,12 @@ function parseSafeStackFrames(error) {
439
503
  }
440
504
  return frames.length > 0 ? frames : void 0;
441
505
  }
506
+ /** Build bounded diagnostic metadata with no raw messages, payloads, or URLs. */
442
507
  function createSafeErrorDiagnostic(error) {
443
508
  try {
444
509
  const diagnostic = { category: classifyError(error) };
445
510
  const code = getSafeCode(error);
446
- const status = getSafeStatus(error);
511
+ const status = extractErrorStatus(error);
447
512
  const method = getSafeMethod(error);
448
513
  const endpoint = getSafeEndpoint(error);
449
514
  const frames = parseSafeStackFrames(error);
@@ -457,8 +522,255 @@ function createSafeErrorDiagnostic(error) {
457
522
  return { category: "UnknownError" };
458
523
  }
459
524
  }
525
+ /** Resolve all bounded policy outputs used by MCP, telemetry, and reporting adapters. */
526
+ function resolveErrorPolicy(error, defaultMessage, notInitializedMessage) {
527
+ const diagnostic = createSafeErrorDiagnostic(error);
528
+ let message = getStatusErrorMessage(diagnostic.status) ?? (diagnostic.status !== void 0 ? `Hevy API request failed (HTTP ${diagnostic.status}).` : defaultMessage);
529
+ let isNotInitialized = false;
530
+ try {
531
+ isNotInitialized = Boolean(notInitializedMessage) && error instanceof Error && error.message === notInitializedMessage;
532
+ } catch {
533
+ isNotInitialized = false;
534
+ }
535
+ if (isNotInitialized) message = notInitializedMessage ?? defaultMessage;
536
+ else if (isRetryExhausted(error)) message = getRetryExhaustedMessage(error);
537
+ else if (diagnostic.status === 429) message = getRateLimitMessage(error);
538
+ return {
539
+ type: determineErrorType(error, message),
540
+ message,
541
+ diagnostic
542
+ };
543
+ }
460
544
  //#endregion
461
- //#region src/utils/formatters.ts
545
+ //#region src/utils/response-formatter.ts
546
+ function jsonText(data) {
547
+ return JSON.stringify(data, null, 2) ?? "null";
548
+ }
549
+ function textContent(text) {
550
+ return {
551
+ type: "text",
552
+ text
553
+ };
554
+ }
555
+ function defineStructuredResponseContract(definition) {
556
+ return {
557
+ outputSchema: definition.outputSchema,
558
+ render(data) {
559
+ const structuredContent = z.object(definition.outputSchema).parse(definition.normalize(data));
560
+ const text = definition.text?.(data, structuredContent) ?? jsonText(definition.legacyJson(structuredContent));
561
+ const additionalText = definition.additionalText?.(data, structuredContent) ?? [];
562
+ return {
563
+ content: [textContent(text), ...additionalText.map(textContent)],
564
+ structuredContent
565
+ };
566
+ }
567
+ };
568
+ }
569
+ function defineJsonResponseContract(present) {
570
+ return { render(data) {
571
+ const presentation = present(data);
572
+ return { content: [textContent(presentation.text ?? jsonText(presentation.json)), ...(presentation.additionalText ?? []).map(textContent)] };
573
+ } };
574
+ }
575
+ /** The only public success-response entry point used by tool handlers. */
576
+ function respond(contract, data) {
577
+ return contract.render(data);
578
+ }
579
+ const optionalNullableNumber = z.number().nullable().optional();
580
+ const formattedWorkoutSetSchema = z.object({
581
+ index: z.number().optional(),
582
+ type: z.string().optional(),
583
+ weight: optionalNullableNumber,
584
+ reps: optionalNullableNumber,
585
+ distance: optionalNullableNumber,
586
+ duration: optionalNullableNumber,
587
+ rpe: optionalNullableNumber,
588
+ customMetric: optionalNullableNumber
589
+ });
590
+ const formattedWorkoutExerciseSchema = z.object({
591
+ index: z.number().optional(),
592
+ name: z.string().optional(),
593
+ exerciseTemplateId: z.string().optional(),
594
+ notes: z.string().nullable().optional(),
595
+ supersetsId: optionalNullableNumber,
596
+ sets: z.array(formattedWorkoutSetSchema).optional()
597
+ });
598
+ const formattedWorkoutSchema = z.object({
599
+ id: z.string().optional(),
600
+ title: z.string().optional(),
601
+ description: z.string().nullable().optional(),
602
+ startTime: z.union([z.string(), z.number()]).optional(),
603
+ endTime: z.union([z.string(), z.number()]).optional(),
604
+ createdAt: z.string().optional(),
605
+ updatedAt: z.string().optional(),
606
+ duration: z.string(),
607
+ exercises: z.array(formattedWorkoutExerciseSchema).optional()
608
+ });
609
+ const formattedRoutineSetSchema = z.object({
610
+ index: z.number().optional(),
611
+ type: z.string().optional(),
612
+ weight: optionalNullableNumber,
613
+ reps: optionalNullableNumber,
614
+ distance: optionalNullableNumber,
615
+ duration: optionalNullableNumber,
616
+ customMetric: optionalNullableNumber,
617
+ repRange: z.object({
618
+ start: z.number().nullable().optional(),
619
+ end: z.number().nullable().optional()
620
+ }).nullable().optional(),
621
+ rpe: optionalNullableNumber
622
+ });
623
+ const formattedRoutineExerciseSchema = z.object({
624
+ name: z.string().optional(),
625
+ index: z.number().optional(),
626
+ exerciseTemplateId: z.string().optional(),
627
+ notes: z.string().nullable().optional(),
628
+ supersetId: optionalNullableNumber,
629
+ restSeconds: z.union([z.string(), z.number()]).nullable().optional(),
630
+ sets: z.array(formattedRoutineSetSchema).optional()
631
+ });
632
+ const formattedRoutineSchema = z.object({
633
+ id: z.string().optional(),
634
+ title: z.string().optional(),
635
+ folderId: z.number().nullable().optional(),
636
+ createdAt: z.string().optional(),
637
+ updatedAt: z.string().optional(),
638
+ exercises: z.array(formattedRoutineExerciseSchema).optional()
639
+ });
640
+ const formattedRoutineFolderSchema = z.object({
641
+ id: z.number().optional(),
642
+ title: z.string().optional(),
643
+ createdAt: z.string().optional(),
644
+ updatedAt: z.string().optional()
645
+ });
646
+ const formattedExerciseTemplateSchema = z.object({
647
+ id: z.string().optional(),
648
+ title: z.string().optional(),
649
+ type: z.string().optional(),
650
+ primaryMuscleGroup: z.string().optional(),
651
+ secondaryMuscleGroups: z.array(z.string()).optional(),
652
+ isCustom: z.boolean().optional()
653
+ });
654
+ const formattedExerciseHistoryEntrySchema = z.object({
655
+ workoutId: z.string().optional(),
656
+ workoutTitle: z.string().optional(),
657
+ workoutStartTime: z.string().optional(),
658
+ workoutEndTime: z.string().optional(),
659
+ exerciseTemplateId: z.string().optional(),
660
+ weight: optionalNullableNumber,
661
+ reps: optionalNullableNumber,
662
+ distance: optionalNullableNumber,
663
+ duration: optionalNullableNumber,
664
+ rpe: optionalNullableNumber,
665
+ customMetric: optionalNullableNumber,
666
+ setType: z.string().optional()
667
+ });
668
+ const formattedBodyMeasurementSchema = z.object({
669
+ date: z.string(),
670
+ weightKg: z.number().nullable(),
671
+ leanMassKg: z.number().nullable(),
672
+ fatPercent: z.number().nullable(),
673
+ neckCm: z.number().nullable(),
674
+ shoulderCm: z.number().nullable(),
675
+ chestCm: z.number().nullable(),
676
+ leftBicepCm: z.number().nullable(),
677
+ rightBicepCm: z.number().nullable(),
678
+ leftForearmCm: z.number().nullable(),
679
+ rightForearmCm: z.number().nullable(),
680
+ abdomen: z.number().nullable(),
681
+ waist: z.number().nullable(),
682
+ hips: z.number().nullable(),
683
+ leftThigh: z.number().nullable(),
684
+ rightThigh: z.number().nullable(),
685
+ leftCalf: z.number().nullable(),
686
+ rightCalf: z.number().nullable()
687
+ });
688
+ const workflowTelemetrySchema = z.object({
689
+ name: z.string(),
690
+ pagination: z.record(z.string(), z.number().int().nonnegative()),
691
+ cacheStatus: z.enum([
692
+ "hit",
693
+ "miss",
694
+ "not-used"
695
+ ]),
696
+ itemsScanned: z.number().int().nonnegative()
697
+ });
698
+ const trainingSummarySessionSchema = z.object({
699
+ id: z.string().optional(),
700
+ title: z.string().optional(),
701
+ startTime: z.string().optional(),
702
+ endTime: z.string().optional(),
703
+ durationSeconds: z.number().int().nonnegative().nullable(),
704
+ exerciseCount: z.number().int().nonnegative(),
705
+ setCount: z.number().int().nonnegative()
706
+ });
707
+ const compactRoutineSchema = z.object({
708
+ id: z.string().optional(),
709
+ title: z.string().optional(),
710
+ folderId: z.number().nullable(),
711
+ updatedAt: z.string().optional(),
712
+ exerciseCount: z.number().int().nonnegative(),
713
+ setCount: z.number().int().nonnegative()
714
+ });
715
+ const workoutsOutputSchema = { workouts: z.array(formattedWorkoutSchema) };
716
+ const workoutOutputSchema = { workout: formattedWorkoutSchema.nullable() };
717
+ const workoutCountOutputSchema = { count: z.number().int() };
718
+ const formattedUpdatedWorkoutSchema = z.object({
719
+ type: z.literal("updated"),
720
+ workout: formattedWorkoutSchema
721
+ });
722
+ const formattedDeletedWorkoutSchema = z.object({
723
+ type: z.literal("deleted"),
724
+ id: z.string(),
725
+ deletedAt: z.string().optional()
726
+ });
727
+ const workoutEventsOutputSchema = { events: z.array(z.union([formattedUpdatedWorkoutSchema, formattedDeletedWorkoutSchema])) };
728
+ const routinesOutputSchema = { routines: z.array(formattedRoutineSchema) };
729
+ const routineOutputSchema = { routine: formattedRoutineSchema.nullable() };
730
+ const exerciseTemplatesOutputSchema = { exerciseTemplates: z.array(formattedExerciseTemplateSchema) };
731
+ const exerciseTemplateOutputSchema = { exerciseTemplate: formattedExerciseTemplateSchema.nullable() };
732
+ const exerciseHistoryOutputSchema = { exerciseHistory: z.array(formattedExerciseHistoryEntrySchema) };
733
+ const routineFoldersOutputSchema = { routineFolders: z.array(formattedRoutineFolderSchema) };
734
+ const routineFolderOutputSchema = { routineFolder: formattedRoutineFolderSchema.nullable() };
735
+ const bodyMeasurementsOutputSchema = { bodyMeasurements: z.array(formattedBodyMeasurementSchema) };
736
+ const bodyMeasurementOutputSchema = { bodyMeasurement: formattedBodyMeasurementSchema.nullable() };
737
+ const userOutputSchema = { user: userInfoSchema.nullable() };
738
+ const trainingSummaryOutputSchema = {
739
+ period: z.object({
740
+ startDate: z.string(),
741
+ endDate: z.string(),
742
+ weeks: z.number().int().positive()
743
+ }),
744
+ workouts: z.object({
745
+ count: z.number().int().nonnegative(),
746
+ totalDurationSeconds: z.number().int().nonnegative(),
747
+ exerciseCount: z.number().int().nonnegative(),
748
+ setCount: z.number().int().nonnegative(),
749
+ uniqueExerciseTemplateIds: z.array(z.string()),
750
+ sessions: z.array(trainingSummarySessionSchema)
751
+ }),
752
+ bodyMeasurements: z.object({
753
+ count: z.number().int().nonnegative(),
754
+ latest: z.object({
755
+ date: z.string(),
756
+ weightKg: z.number().nullable(),
757
+ leanMassKg: z.number().nullable(),
758
+ fatPercent: z.number().nullable()
759
+ }).nullable(),
760
+ earliest: z.object({
761
+ date: z.string(),
762
+ weightKg: z.number().nullable(),
763
+ leanMassKg: z.number().nullable(),
764
+ fatPercent: z.number().nullable()
765
+ }).nullable(),
766
+ weightChangeKg: z.number().nullable()
767
+ }),
768
+ workflow: workflowTelemetrySchema
769
+ };
770
+ const compactRoutinesOutputSchema = {
771
+ routines: z.array(compactRoutineSchema),
772
+ workflow: workflowTelemetrySchema
773
+ };
462
774
  function getSupersetId(exercise) {
463
775
  if (exercise.superset_id !== void 0) return exercise.superset_id;
464
776
  if (exercise.supersets_id !== void 0) return exercise.supersets_id;
@@ -626,16 +938,137 @@ function formatBodyMeasurement(measurement) {
626
938
  rightCalf: measurement.right_calf ?? null
627
939
  };
628
940
  }
629
- //#endregion
630
- //#region src/utils/tool-helpers.ts
631
- const HEVY_CLIENT_NOT_INITIALIZED_ERROR = "API client not initialized. Please provide HEVY_API_KEY.";
632
- /**
633
- * Assert that the Hevy API client is initialized before running a tool.
634
- */
635
- function requireClient(client) {
636
- if (!client) throw new Error(HEVY_CLIENT_NOT_INITIALIZED_ERROR);
637
- return client;
941
+ function formatWorkoutEvent(event) {
942
+ if (event.type === "updated" && "workout" in event) return {
943
+ type: "updated",
944
+ workout: formatWorkout(event.workout)
945
+ };
946
+ if (event.type === "deleted" && "id" in event) return {
947
+ type: "deleted",
948
+ id: event.id,
949
+ deletedAt: event.deleted_at
950
+ };
951
+ throw new Error(`Unsupported workout event type: ${event.type}`);
638
952
  }
953
+ const workoutsResponse = defineStructuredResponseContract({
954
+ outputSchema: workoutsOutputSchema,
955
+ normalize: (workouts) => ({ workouts: workouts?.map(formatWorkout) ?? [] }),
956
+ legacyJson: ({ workouts }) => workouts,
957
+ text: (_data, { workouts }) => workouts.length === 0 ? "No workouts found for the specified parameters" : void 0
958
+ });
959
+ const workoutResponse = defineStructuredResponseContract({
960
+ outputSchema: workoutOutputSchema,
961
+ normalize: (data) => ({ workout: data.workout ? formatWorkout(data.workout) : null }),
962
+ legacyJson: ({ workout }) => workout,
963
+ text: ({ workoutId }, { workout }) => workout === null ? `Workout with ID ${workoutId} not found` : void 0
964
+ });
965
+ const workoutCountResponse = defineStructuredResponseContract({
966
+ outputSchema: workoutCountOutputSchema,
967
+ normalize: (count) => ({ count }),
968
+ legacyJson: (output) => output
969
+ });
970
+ const workoutEventsResponse = defineStructuredResponseContract({
971
+ outputSchema: workoutEventsOutputSchema,
972
+ normalize: (data) => ({ events: data.events?.map(formatWorkoutEvent) ?? [] }),
973
+ legacyJson: ({ events }) => events,
974
+ text: ({ since }, { events }) => events.length === 0 ? `No workout events found for the specified parameters since ${since}` : void 0
975
+ });
976
+ const routinesResponse = defineStructuredResponseContract({
977
+ outputSchema: routinesOutputSchema,
978
+ normalize: (routines) => ({ routines: routines?.map(formatRoutine) ?? [] }),
979
+ legacyJson: ({ routines }) => routines,
980
+ text: (_data, { routines }) => routines.length === 0 ? "No routines found for the specified parameters" : void 0
981
+ });
982
+ const routineResponse = defineStructuredResponseContract({
983
+ outputSchema: routineOutputSchema,
984
+ normalize: (data) => ({ routine: data.routine ? formatRoutine(data.routine) : null }),
985
+ legacyJson: ({ routine }) => routine,
986
+ text: ({ routineId }, { routine }) => routine === null ? `Routine with ID ${routineId} not found` : void 0
987
+ });
988
+ const exerciseTemplatesResponse = defineStructuredResponseContract({
989
+ outputSchema: exerciseTemplatesOutputSchema,
990
+ normalize: (templates) => ({ exerciseTemplates: templates?.map(formatExerciseTemplate) ?? [] }),
991
+ legacyJson: ({ exerciseTemplates }) => exerciseTemplates,
992
+ text: (_data, { exerciseTemplates }) => exerciseTemplates.length === 0 ? "No exercise templates found for the specified parameters" : void 0
993
+ });
994
+ const exerciseTemplateResponse = defineStructuredResponseContract({
995
+ outputSchema: exerciseTemplateOutputSchema,
996
+ normalize: (data) => ({ exerciseTemplate: data.exerciseTemplate ? formatExerciseTemplate(data.exerciseTemplate) : null }),
997
+ legacyJson: ({ exerciseTemplate }) => exerciseTemplate,
998
+ text: ({ exerciseTemplateId }, { exerciseTemplate }) => exerciseTemplate === null ? `Exercise template with ID ${exerciseTemplateId} not found` : void 0
999
+ });
1000
+ const exerciseHistoryResponse = defineStructuredResponseContract({
1001
+ outputSchema: exerciseHistoryOutputSchema,
1002
+ normalize: (data) => ({ exerciseHistory: data.history?.map(formatExerciseHistoryEntry) ?? [] }),
1003
+ legacyJson: ({ exerciseHistory }) => exerciseHistory,
1004
+ text: ({ exerciseTemplateId }, { exerciseHistory }) => exerciseHistory.length === 0 ? `No exercise history found for template ${exerciseTemplateId}` : void 0
1005
+ });
1006
+ const searchExerciseTemplatesResponse = defineStructuredResponseContract({
1007
+ outputSchema: exerciseTemplatesOutputSchema,
1008
+ normalize: (data) => ({ exerciseTemplates: data.results.map(formatExerciseTemplate) }),
1009
+ legacyJson: ({ exerciseTemplates }) => exerciseTemplates,
1010
+ text: ({ query, primaryMuscleGroup }, { exerciseTemplates }) => exerciseTemplates.length === 0 ? `No exercise templates found matching "${query}"${primaryMuscleGroup ? ` with primary muscle group "${primaryMuscleGroup}"` : ""}` : void 0
1011
+ });
1012
+ const routineFoldersResponse = defineStructuredResponseContract({
1013
+ outputSchema: routineFoldersOutputSchema,
1014
+ normalize: (folders) => ({ routineFolders: folders?.map(formatRoutineFolder) ?? [] }),
1015
+ legacyJson: ({ routineFolders }) => routineFolders,
1016
+ text: (_data, { routineFolders }) => routineFolders.length === 0 ? "No routine folders found for the specified parameters" : void 0
1017
+ });
1018
+ const routineFolderResponse = defineStructuredResponseContract({
1019
+ outputSchema: routineFolderOutputSchema,
1020
+ normalize: (data) => ({ routineFolder: data.routineFolder ? formatRoutineFolder(data.routineFolder) : null }),
1021
+ legacyJson: ({ routineFolder }) => routineFolder,
1022
+ text: ({ folderId }, { routineFolder }) => routineFolder === null ? `Routine folder with ID ${folderId} not found` : void 0
1023
+ });
1024
+ const bodyMeasurementsResponse = defineStructuredResponseContract({
1025
+ outputSchema: bodyMeasurementsOutputSchema,
1026
+ normalize: (measurements) => ({ bodyMeasurements: measurements?.map(formatBodyMeasurement) ?? [] }),
1027
+ legacyJson: ({ bodyMeasurements }) => bodyMeasurements,
1028
+ text: (_data, { bodyMeasurements }) => bodyMeasurements.length === 0 ? "No body measurements found for the specified parameters" : void 0
1029
+ });
1030
+ const bodyMeasurementResponse = defineStructuredResponseContract({
1031
+ outputSchema: bodyMeasurementOutputSchema,
1032
+ normalize: (data) => ({ bodyMeasurement: data.bodyMeasurement ? formatBodyMeasurement(data.bodyMeasurement) : null }),
1033
+ legacyJson: ({ bodyMeasurement }) => bodyMeasurement,
1034
+ text: ({ date }, { bodyMeasurement }) => bodyMeasurement === null ? `No body measurement found for date ${date}` : void 0
1035
+ });
1036
+ const userResponse = defineStructuredResponseContract({
1037
+ outputSchema: userOutputSchema,
1038
+ normalize: (user) => ({ user: user ?? null }),
1039
+ legacyJson: ({ user }) => user,
1040
+ text: (_data, { user }) => user === null ? "No user info found for the authenticated user" : void 0
1041
+ });
1042
+ const trainingSummaryResponse = defineStructuredResponseContract({
1043
+ outputSchema: trainingSummaryOutputSchema,
1044
+ normalize: (data) => data,
1045
+ legacyJson: (output) => output,
1046
+ text: (data) => data.workouts.count === 0 && data.bodyMeasurements.count === 0 ? "No workouts or body measurements found for the specified period" : void 0
1047
+ });
1048
+ const compactRoutinesResponse = defineStructuredResponseContract({
1049
+ outputSchema: compactRoutinesOutputSchema,
1050
+ normalize: (data) => data,
1051
+ legacyJson: ({ routines }) => routines,
1052
+ text: (_data, { routines }) => routines.length === 0 ? "No routines found matching the query" : void 0
1053
+ });
1054
+ const createWorkoutResponse = defineJsonResponseContract((workout) => workout ? { json: formatWorkout(workout) } : { text: "Failed to create workout: Server returned no data" });
1055
+ const updateWorkoutResponse = defineJsonResponseContract((data) => data.workout ? { json: formatWorkout(data.workout) } : { text: `Failed to update workout with ID ${data.workoutId}` });
1056
+ const repRangeDisplayWarningText = "Note: Hevy's public API stores rep ranges (rep_range), but the Hevy apps may not display them because they rely on an internal-only exercise field (input_modifier). See https://github.com/chrisdoc/hevy-mcp/issues/261 for details/workarounds.";
1057
+ const createRoutineResponse = defineJsonResponseContract((data) => data.routine ? {
1058
+ json: formatRoutine(data.routine),
1059
+ additionalText: data.usesRepRanges ? [repRangeDisplayWarningText] : []
1060
+ } : { text: "Failed to create routine: Server returned no data" });
1061
+ const updateRoutineResponse = defineJsonResponseContract((data) => data.routine ? {
1062
+ json: formatRoutine(data.routine),
1063
+ additionalText: data.usesRepRanges ? [repRangeDisplayWarningText] : []
1064
+ } : { text: `Failed to update routine with ID ${data.routineId}` });
1065
+ const createExerciseTemplateResponse = defineJsonResponseContract((response) => ({ json: {
1066
+ id: response?.id,
1067
+ message: "Exercise template created successfully"
1068
+ } }));
1069
+ const createRoutineFolderResponse = defineJsonResponseContract((folder) => folder ? { json: formatRoutineFolder(folder) } : { text: "Failed to create routine folder: Server returned no data" });
1070
+ const createBodyMeasurementResponse = defineJsonResponseContract((date) => ({ text: `Body measurement for ${date} created successfully.` }));
1071
+ const updateBodyMeasurementResponse = defineJsonResponseContract((date) => ({ text: `Body measurement for ${date} updated successfully.` }));
639
1072
  //#endregion
640
1073
  //#region src/resources/hevy.ts
641
1074
  const JSON_MIME_TYPE = "application/json";
@@ -646,57 +1079,49 @@ function createJsonResourceResult(uri, data) {
646
1079
  text: JSON.stringify(data)
647
1080
  }] };
648
1081
  }
649
- function getSafePageCount(data, currentPage) {
650
- const pageCount = data?.page_count;
651
- if (typeof pageCount !== "number" || !Number.isSafeInteger(pageCount) || pageCount < currentPage) return currentPage;
652
- return pageCount;
653
- }
654
- async function fetchAllRoutineFolders(hevyClient) {
655
- const allFolders = [];
656
- let page = 1;
657
- let pageCount = 1;
658
- do {
659
- const data = await hevyClient.getRoutineFolders({
1082
+ async function fetchAllRoutineFolders(runtime) {
1083
+ const client = runtime.getClient();
1084
+ return fetchAllPages(async (page, pageSize) => {
1085
+ const data = await client.getRoutineFolders({
660
1086
  page,
661
- pageSize: 10
1087
+ pageSize
662
1088
  });
663
- allFolders.push(...data?.routine_folders ?? []);
664
- pageCount = getSafePageCount(data, page);
665
- page++;
666
- } while (page <= pageCount);
667
- return allFolders;
1089
+ return {
1090
+ items: data?.routine_folders ?? [],
1091
+ pageCount: data?.page_count
1092
+ };
1093
+ }, 10);
668
1094
  }
669
- function registerHevyResources(server, hevyClient, catalog = createExerciseTemplateCatalog()) {
1095
+ function registerHevyResources(server, runtime) {
670
1096
  server.registerResource("user-profile", "hevy://user", {
671
1097
  description: "Authenticated Hevy user profile",
672
1098
  mimeType: JSON_MIME_TYPE
673
1099
  }, async (uri) => {
674
- return createJsonResourceResult(uri, (await requireClient(hevyClient).getUserInfo())?.data ?? null);
1100
+ return createJsonResourceResult(uri, (await runtime.getClient().getUserInfo())?.data ?? null);
675
1101
  });
676
1102
  server.registerResource("workout-count", "hevy://workout-count", {
677
1103
  description: "Total number of workouts in the Hevy account",
678
1104
  mimeType: JSON_MIME_TYPE
679
1105
  }, async (uri) => {
680
- return createJsonResourceResult(uri, { count: (await requireClient(hevyClient).getWorkoutCount())?.workout_count ?? 0 });
1106
+ return createJsonResourceResult(uri, { count: (await runtime.getClient().getWorkoutCount())?.workout_count ?? 0 });
681
1107
  });
682
1108
  server.registerResource("exercise-templates", "hevy://exercise-templates", {
683
1109
  description: "Full formatted Hevy exercise template catalog",
684
1110
  mimeType: JSON_MIME_TYPE
685
1111
  }, async (uri) => {
686
- const client = requireClient(hevyClient);
687
- return createJsonResourceResult(uri, (await catalog.get(client)).map(formatExerciseTemplate));
1112
+ return createJsonResourceResult(uri, (await runtime.catalog.get()).map(formatExerciseTemplate));
688
1113
  });
689
1114
  server.registerResource("routine-folders", "hevy://routine-folders", {
690
1115
  description: "Full formatted list of Hevy routine folders",
691
1116
  mimeType: JSON_MIME_TYPE
692
1117
  }, async (uri) => {
693
- return createJsonResourceResult(uri, (await fetchAllRoutineFolders(requireClient(hevyClient))).map(formatRoutineFolder));
1118
+ return createJsonResourceResult(uri, (await fetchAllRoutineFolders(runtime)).map(formatRoutineFolder));
694
1119
  });
695
1120
  }
696
1121
  //#endregion
697
1122
  //#region src/server-metadata.ts
698
1123
  const SERVER_NAME = "hevy-mcp";
699
- const SERVER_VERSION = "3.1.1";
1124
+ const SERVER_VERSION = "3.2.1";
700
1125
  const SERVER_INSTRUCTIONS = [
701
1126
  "Hevy MCP connects clients to the authenticated user's Hevy workout-tracking data, including workouts, routines, exercise templates, routine folders, body measurements, and profile information. HEVY_API_KEY must contain a valid Hevy API key for local stdio use.",
702
1127
  "Safety: all get-* and search-* tools are read-only. create-* and update-* tools mutate Hevy data. Creates are additive and non-idempotent, so repeating one can create duplicates. Updates can overwrite existing data. Delete operations are not available.",
@@ -705,349 +1130,64 @@ const SERVER_INSTRUCTIONS = [
705
1130
  "Rate limits and retries: minimize repeated calls. If Hevy returns HTTP 429, follow its retry guidance. Transient read requests retry automatically, but write requests do not; confirm uncertain write outcomes before trying again."
706
1131
  ].join("\n\n");
707
1132
  //#endregion
708
- //#region src/utils/error-classification.ts
1133
+ //#region src/utils/tool-helpers.ts
709
1134
  /**
710
- * Determine the type of error based on error characteristics.
1135
+ * Type-safe utilities for MCP tool handlers
711
1136
  */
712
- function determineErrorType(error, message) {
713
- if (error !== null && typeof error === "object" && error.hevyRetryExhausted === true) return "NETWORK_ERROR";
714
- if (isHevyHttpError(error) && error.status === 429) return "RATE_LIMIT";
715
- const messageLower = message.toLowerCase();
716
- const nameLower = error instanceof Error ? error.name.toLowerCase() : "";
717
- if (nameLower.includes("network") || messageLower.includes("network") || nameLower.includes("fetch") || messageLower.includes("fetch") || nameLower.includes("timeout") || messageLower.includes("timeout")) return "NETWORK_ERROR";
718
- if (nameLower.includes("validation") || messageLower.includes("validation") || messageLower.includes("invalid") || messageLower.includes("required")) return "VALIDATION_ERROR";
719
- if (messageLower.includes("not found") || messageLower.includes("404") || messageLower.includes("does not exist")) return "NOT_FOUND";
720
- if (nameLower.includes("api") || messageLower.includes("api") || messageLower.includes("server error") || messageLower.includes("500")) return "API_ERROR";
721
- return "UNKNOWN_ERROR";
722
- }
723
- //#endregion
724
- //#region src/utils/error-handler.ts
1137
+ const HEVY_CLIENT_NOT_INITIALIZED_ERROR = "API client not initialized. Please provide HEVY_API_KEY.";
725
1138
  /**
726
- * Centralized error handling utility for MCP tools
1139
+ * Assert that the Hevy API client is initialized before running a tool.
727
1140
  */
728
- function normalizeHeaderValue(value) {
729
- if (typeof value === "string") {
730
- const trimmed = value.trim();
731
- return trimmed.length > 0 ? trimmed : void 0;
732
- }
733
- if (typeof value === "number" && Number.isFinite(value)) return String(value);
734
- if (Array.isArray(value) && value.length > 0) return normalizeHeaderValue(value[0]);
735
- }
736
- function getHeaderValue(headers, key) {
737
- if (!headers || typeof headers !== "object") return;
738
- if ("get" in headers && typeof headers.get === "function") return normalizeHeaderValue(headers.get(key));
739
- const headerRecord = headers;
740
- return normalizeHeaderValue(headerRecord[key] ?? headerRecord[key.toLowerCase()] ?? headerRecord[key.toUpperCase()]);
741
- }
742
- function formatSecondsLabel(seconds) {
743
- const roundedSeconds = Math.max(0, Math.round(seconds));
744
- return `${roundedSeconds} second${roundedSeconds === 1 ? "" : "s"}`;
745
- }
746
- function getRateLimitMessage(error) {
747
- const retryAfterHeader = getHeaderValue(error.headers, "retry-after");
748
- if (!retryAfterHeader) return "Rate limited by Hevy (HTTP 429). Please wait and retry your request.";
749
- const seconds = Number(retryAfterHeader);
750
- if (Number.isFinite(seconds) && seconds >= 0) return `Rate limited by Hevy (HTTP 429). Please wait about ${formatSecondsLabel(seconds)} before retrying.`;
751
- const retryAtMillis = Date.parse(retryAfterHeader);
752
- 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.`;
753
- return "Rate limited by Hevy (HTTP 429). Please wait and retry your request.";
754
- }
755
- function isRetryExhaustedError(error) {
756
- return !!error && typeof error === "object" && error.hevyRetryExhausted === true;
757
- }
758
- function getRetryExhaustedMessage(error) {
759
- const retryCount = typeof error === "object" && error !== null ? error.hevyRetryCount : void 0;
760
- const attemptCount = typeof retryCount === "number" && Number.isFinite(retryCount) ? retryCount + 1 : void 0;
761
- if (attemptCount) return `Unable to complete the request after ${attemptCount} attempts to the Hevy API due to transient failures. Please try again shortly.`;
762
- return "Unable to complete the request after multiple attempts to the Hevy API due to transient failures. Please try again shortly.";
763
- }
764
- function getUserFacingMessage(error, defaultMessage) {
765
- if (error instanceof Error && error.message === "API client not initialized. Please provide HEVY_API_KEY.") return HEVY_CLIENT_NOT_INITIALIZED_ERROR;
766
- if (isRetryExhaustedError(error)) return getRetryExhaustedMessage(error);
767
- if (isHevyHttpError(error) && error.status === 429) return getRateLimitMessage(error);
768
- return defaultMessage;
1141
+ function requireClient(client) {
1142
+ if (!client) throw new Error(HEVY_CLIENT_NOT_INITIALIZED_ERROR);
1143
+ return client;
769
1144
  }
770
1145
  /**
771
- * Create a standardized error response for MCP tools
1146
+ * Create a type-safe tool handler that validates and narrows args to inferred types
772
1147
  *
773
- * @param error - The error object or message
774
- * @param context - Optional context information about where the error occurred
775
- * @returns A formatted MCP tool response with error information
1148
+ * This function wraps a handler with automatic Zod validation, ensuring runtime
1149
+ * type safety while maintaining compile-time type inference.
1150
+ *
1151
+ * @param schema - Zod schema object (e.g., { page: z.number(), ... })
1152
+ * @param handler - Handler function that receives validated, typed parameters
1153
+ * @returns A handler function that accepts Record<string, unknown> and validates it
1154
+ *
1155
+ * @example
1156
+ * ```typescript
1157
+ * const schema = {
1158
+ * page: z.coerce.number().int().gte(1).default(1),
1159
+ * pageSize: z.coerce.number().int().gte(1).lte(10).default(5),
1160
+ * } as const;
1161
+ *
1162
+ * const handler = createTypedToolHandler(schema, async (args) => {
1163
+ * // args is fully typed as { page: number; pageSize: number }
1164
+ * const { page, pageSize } = args;
1165
+ * // ...
1166
+ * });
1167
+ * ```
776
1168
  */
777
- function createErrorResponse(error, context) {
778
- let errorMessage = "The request failed unexpectedly. Please try again.";
779
- const safeDiagnostic = createSafeErrorDiagnostic(error);
780
- const axiosErrorContext = safeDiagnostic.status !== void 0 || safeDiagnostic.method !== void 0 || safeDiagnostic.endpoint !== void 0 ? {
781
- status: safeDiagnostic.status,
782
- method: safeDiagnostic.method,
783
- url: safeDiagnostic.endpoint
784
- } : null;
785
- const mappedHevyErrorMessage = mapHevyErrorMessageByStatus(axiosErrorContext?.status);
786
- if (mappedHevyErrorMessage) errorMessage = mappedHevyErrorMessage;
787
- else if (isHevyHttpError(error) && error.status !== void 0) errorMessage = `Hevy API request failed (HTTP ${error.status}).`;
788
- errorMessage = getUserFacingMessage(error, errorMessage);
789
- const errorCode = safeDiagnostic.code;
790
- const errorType = determineErrorType(error, errorMessage);
791
- const errorContext = {
792
- sourceContext: context,
793
- originalErrorMessage: `${safeDiagnostic.category} occurred`,
794
- errorCode,
795
- errorType,
796
- axios: axiosErrorContext ?? void 0
797
- };
798
- const formattedMessage = `${context ? `[${context}] ` : ""}Error: ${errorMessage}`;
799
- console.error("MCP tool failure", safeDiagnostic);
800
- return {
801
- content: [{
802
- type: "text",
803
- text: formattedMessage
804
- }],
805
- isError: true,
806
- errorContext
807
- };
808
- }
809
- function mapHevyErrorMessageByStatus(status) {
810
- if (status === 401 || status === 403) return "The Hevy API key is invalid or has expired. Check HEVY_API_KEY.";
811
- if (status === 404) return "The requested resource was not found in Hevy.";
812
- if (status === 409) return "A conflict occurred (e.g., a body measurement already exists for this date). Use the update tool instead.";
813
- if (status === 422) return "The request failed Hevy validation. Check the field values and try again.";
814
- if (status === 429) return "Rate limited by Hevy. Please wait and retry.";
815
- if (status && status >= 500 && status <= 599) return "Hevy API experienced an error. Please retry later.";
816
- return null;
817
- }
818
- /**
819
- * Wrap an async function with standardized error handling
820
- *
821
- * This function preserves the parameter types of the wrapped function while
822
- * providing error handling. The returned function accepts Record<string, unknown>
823
- * (as required by MCP SDK) but internally casts to the original parameter type.
824
- *
825
- * @param fn - The async function to wrap
826
- * @param context - Context information for error messages
827
- * @returns A function that catches errors and returns standardized error responses
828
- */
829
- function withErrorHandling(fn, context, onError) {
830
- return async (rawArgs) => {
831
- const args = rawArgs ?? {};
832
- try {
833
- return await fn(args);
834
- } catch (error) {
835
- try {
836
- onError?.(error, context, Object.keys(args).length);
837
- } catch {
838
- console.error("MCP error observer failure", { category: "ObserverError" });
839
- }
840
- return createErrorResponse(error, context);
841
- }
1169
+ function createTypedToolHandler(schema, handler) {
1170
+ const zodSchema = z.object(schema);
1171
+ return async (args) => {
1172
+ return handler(zodSchema.parse(args));
842
1173
  };
843
1174
  }
844
1175
  //#endregion
845
- //#region src/generated/client/schemas/userInfoSchema.ts
846
- /**
847
- * Generated by Kubb (https://kubb.dev/).
848
- * Do not edit manually.
849
- */
850
- const userInfoSchema = z$1.object({
851
- id: z$1.optional(z$1.string().describe("The user ID.")),
852
- name: z$1.optional(z$1.string().describe("The user's display name.")),
853
- url: z$1.optional(z$1.string().describe("The user's public profile URL."))
854
- });
855
- //#endregion
856
- //#region src/utils/output-schemas.ts
857
- const optionalNullableNumber = z.number().nullable().optional();
858
- const formattedWorkoutSetSchema = z.object({
859
- index: z.number().optional(),
860
- type: z.string().optional(),
861
- weight: optionalNullableNumber,
862
- reps: optionalNullableNumber,
863
- distance: optionalNullableNumber,
864
- duration: optionalNullableNumber,
865
- rpe: optionalNullableNumber,
866
- customMetric: optionalNullableNumber
867
- });
868
- const formattedWorkoutExerciseSchema = z.object({
869
- index: z.number().optional(),
870
- name: z.string().optional(),
871
- exerciseTemplateId: z.string().optional(),
872
- notes: z.string().nullable().optional(),
873
- supersetsId: optionalNullableNumber,
874
- sets: z.array(formattedWorkoutSetSchema).optional()
875
- });
876
- const formattedWorkoutSchema = z.object({
877
- id: z.string().optional(),
878
- title: z.string().optional(),
879
- description: z.string().nullable().optional(),
880
- startTime: z.union([z.string(), z.number()]).optional(),
881
- endTime: z.union([z.string(), z.number()]).optional(),
882
- createdAt: z.string().optional(),
883
- updatedAt: z.string().optional(),
884
- duration: z.string(),
885
- exercises: z.array(formattedWorkoutExerciseSchema).optional()
886
- });
887
- const formattedRoutineSetSchema = z.object({
888
- index: z.number().optional(),
889
- type: z.string().optional(),
890
- weight: optionalNullableNumber,
891
- reps: optionalNullableNumber,
892
- distance: optionalNullableNumber,
893
- duration: optionalNullableNumber,
894
- customMetric: optionalNullableNumber,
895
- repRange: z.object({
896
- start: z.number().nullable().optional(),
897
- end: z.number().nullable().optional()
898
- }).nullable().optional(),
899
- rpe: optionalNullableNumber
900
- });
901
- const formattedRoutineExerciseSchema = z.object({
902
- name: z.string().optional(),
903
- index: z.number().optional(),
904
- exerciseTemplateId: z.string().optional(),
905
- notes: z.string().nullable().optional(),
906
- supersetId: optionalNullableNumber,
907
- restSeconds: z.union([z.string(), z.number()]).nullable().optional(),
908
- sets: z.array(formattedRoutineSetSchema).optional()
909
- });
910
- const formattedRoutineSchema = z.object({
911
- id: z.string().optional(),
912
- title: z.string().optional(),
913
- folderId: z.number().nullable().optional(),
914
- createdAt: z.string().optional(),
915
- updatedAt: z.string().optional(),
916
- exercises: z.array(formattedRoutineExerciseSchema).optional()
917
- });
918
- const formattedRoutineFolderSchema = z.object({
919
- id: z.number().optional(),
920
- title: z.string().optional(),
921
- createdAt: z.string().optional(),
922
- updatedAt: z.string().optional()
923
- });
924
- const formattedExerciseTemplateSchema = z.object({
925
- id: z.string().optional(),
926
- title: z.string().optional(),
927
- type: z.string().optional(),
928
- primaryMuscleGroup: z.string().optional(),
929
- secondaryMuscleGroups: z.array(z.string()).optional(),
930
- isCustom: z.boolean().optional()
931
- });
932
- const formattedExerciseHistoryEntrySchema = z.object({
933
- workoutId: z.string().optional(),
934
- workoutTitle: z.string().optional(),
935
- workoutStartTime: z.string().optional(),
936
- workoutEndTime: z.string().optional(),
937
- exerciseTemplateId: z.string().optional(),
938
- weight: optionalNullableNumber,
939
- reps: optionalNullableNumber,
940
- distance: optionalNullableNumber,
941
- duration: optionalNullableNumber,
942
- rpe: optionalNullableNumber,
943
- customMetric: optionalNullableNumber,
944
- setType: z.string().optional()
945
- });
946
- const formattedBodyMeasurementSchema = z.object({
947
- date: z.string(),
948
- weightKg: z.number().nullable(),
949
- leanMassKg: z.number().nullable(),
950
- fatPercent: z.number().nullable(),
951
- neckCm: z.number().nullable(),
952
- shoulderCm: z.number().nullable(),
953
- chestCm: z.number().nullable(),
954
- leftBicepCm: z.number().nullable(),
955
- rightBicepCm: z.number().nullable(),
956
- leftForearmCm: z.number().nullable(),
957
- rightForearmCm: z.number().nullable(),
958
- abdomen: z.number().nullable(),
959
- waist: z.number().nullable(),
960
- hips: z.number().nullable(),
961
- leftThigh: z.number().nullable(),
962
- rightThigh: z.number().nullable(),
963
- leftCalf: z.number().nullable(),
964
- rightCalf: z.number().nullable()
965
- });
966
- const workoutsOutputSchema = { workouts: z.array(formattedWorkoutSchema) };
967
- const workoutOutputSchema = { workout: formattedWorkoutSchema.nullable() };
968
- const workoutCountOutputSchema = { count: z.number().int() };
969
- const formattedUpdatedWorkoutSchema = z.object({
970
- type: z.literal("updated"),
971
- workout: formattedWorkoutSchema
972
- });
973
- const formattedDeletedWorkoutSchema = z.object({
974
- type: z.literal("deleted"),
975
- id: z.string(),
976
- deletedAt: z.string().optional()
977
- });
978
- const workoutEventsOutputSchema = { events: z.array(z.union([formattedUpdatedWorkoutSchema, formattedDeletedWorkoutSchema])) };
979
- const routinesOutputSchema = { routines: z.array(formattedRoutineSchema) };
980
- const routineOutputSchema = { routine: formattedRoutineSchema.nullable() };
981
- const exerciseTemplatesOutputSchema = { exerciseTemplates: z.array(formattedExerciseTemplateSchema) };
982
- const exerciseTemplateOutputSchema = { exerciseTemplate: formattedExerciseTemplateSchema.nullable() };
983
- const exerciseHistoryOutputSchema = { exerciseHistory: z.array(formattedExerciseHistoryEntrySchema) };
984
- const routineFoldersOutputSchema = { routineFolders: z.array(formattedRoutineFolderSchema) };
985
- const routineFolderOutputSchema = { routineFolder: formattedRoutineFolderSchema.nullable() };
986
- const bodyMeasurementsOutputSchema = { bodyMeasurements: z.array(formattedBodyMeasurementSchema) };
987
- const bodyMeasurementOutputSchema = { bodyMeasurement: formattedBodyMeasurementSchema.nullable() };
988
- const userOutputSchema = { user: userInfoSchema.nullable() };
989
- //#endregion
990
- //#region src/utils/response-formatter.ts
991
- /**
992
- * Create a standardized success response with JSON data
993
- *
994
- * @param data - The data to include in the response
995
- * @param options - Formatting options
996
- * @returns A formatted MCP tool response with the data as JSON
997
- */
998
- function createJsonResponse(data, options = {
999
- pretty: true,
1000
- indent: 2
1001
- }) {
1002
- return { content: [{
1003
- type: "text",
1004
- text: (options.pretty ? JSON.stringify(data, null, options.indent) : JSON.stringify(data)) ?? "null"
1005
- }] };
1006
- }
1007
- /**
1008
- * Create a JSON text response with a typed machine-readable payload.
1009
- *
1010
- * The text content is produced by the legacy JSON formatter so existing MCP
1011
- * clients receive byte-for-byte identical output.
1012
- */
1013
- function createStructuredJsonResponse(data, structuredContent, options = {
1014
- pretty: true,
1015
- indent: 2
1016
- }) {
1017
- return {
1018
- ...createJsonResponse(data, options),
1019
- structuredContent
1020
- };
1021
- }
1022
- /**
1023
- * Create a standardized success response with text data
1024
- *
1025
- * @param message - The text message to include in the response
1026
- * @returns A formatted MCP tool response with the text message
1027
- */
1028
- function createTextResponse(message) {
1029
- return { content: [{
1030
- type: "text",
1031
- text: message
1032
- }] };
1033
- }
1034
- /**
1035
- * Create a standardized success response for empty or null results
1036
- *
1037
- * @param message - Optional message to include (default: "No data found")
1038
- * @returns A formatted MCP tool response for empty results
1039
- */
1040
- function createEmptyResponse(message = "No data found") {
1041
- return createTextResponse(message);
1042
- }
1043
- /**
1044
- * Create an empty/not-found text response with a valid structured payload.
1045
- */
1046
- function createStructuredEmptyResponse(message, structuredContent) {
1047
- return {
1048
- ...createEmptyResponse(message),
1049
- structuredContent
1050
- };
1176
+ //#region src/tools/define-tool.ts
1177
+ function registerToolDefinition(server, runtime, definition) {
1178
+ const directHandler = createTypedToolHandler(definition.inputSchema, async (args) => respond(definition.responseContract, await definition.execute(runtime, args)));
1179
+ const handler = runtime.wrapHandler(directHandler, definition.name);
1180
+ const callback = (args) => handler(args);
1181
+ if (definition.kind === "read") {
1182
+ server.registerTool(definition.name, {
1183
+ description: definition.description,
1184
+ inputSchema: definition.inputSchema,
1185
+ outputSchema: definition.outputSchema,
1186
+ annotations: definition.annotations
1187
+ }, callback);
1188
+ return;
1189
+ }
1190
+ server.tool(definition.name, definition.description, definition.inputSchema, definition.annotations, callback);
1051
1191
  }
1052
1192
  //#endregion
1053
1193
  //#region src/utils/tool-annotations.ts
@@ -1093,76 +1233,92 @@ function describeTool({ summary, aliases, useCase, importantNotes }) {
1093
1233
  ].join(" ");
1094
1234
  }
1095
1235
  //#endregion
1096
- //#region src/utils/schemas.ts
1097
- function coerceNullishNumberInput(value) {
1098
- if (value === null || value === void 0) return value;
1099
- if (typeof value !== "string") return value;
1100
- const trimmed = value.trim();
1101
- if (trimmed === "") return;
1102
- const lowered = trimmed.toLowerCase();
1103
- if (lowered === "null") return null;
1104
- if (lowered === "undefined") return;
1105
- const asNumber = Number(trimmed);
1106
- if (Number.isNaN(asNumber)) return value;
1107
- return asNumber;
1236
+ //#region src/utils/json-parser.ts
1237
+ /**
1238
+ * Preprocessor to handle MCP clients that send JSON-stringified arrays
1239
+ * instead of native arrays for complex parameters.
1240
+ *
1241
+ * This is used with Zod's z.preprocess to handle cases where MCP clients
1242
+ * serialize complex nested structures as JSON strings.
1243
+ *
1244
+ * @param val - The value to potentially parse
1245
+ * @returns The parsed array if val is a valid JSON string, otherwise returns val unchanged
1246
+ */
1247
+ function parseJsonArray(val) {
1248
+ if (typeof val === "string") try {
1249
+ return JSON.parse(val);
1250
+ } catch {
1251
+ return val;
1252
+ }
1253
+ return val;
1108
1254
  }
1109
- const zNullableInt = z.preprocess(coerceNullishNumberInput, z.number().int().nullable().optional());
1110
- const zNullableNumber = z.preprocess((value) => value === "" ? void 0 : value, z.coerce.number().nullable().optional());
1111
- const zOptionalRepRange = z.preprocess((value) => value === null ? void 0 : value, z.object({
1112
- start: zNullableInt,
1113
- end: zNullableInt
1114
- }).optional());
1115
- const setTypeEnum = z.enum([
1116
- "warmup",
1117
- "normal",
1118
- "failure",
1119
- "dropset"
1120
- ]).default("normal");
1121
- const muscleGroupEnum = z.enum([
1122
- "abdominals",
1123
- "shoulders",
1124
- "biceps",
1125
- "triceps",
1126
- "forearms",
1127
- "quadriceps",
1128
- "hamstrings",
1129
- "calves",
1130
- "glutes",
1131
- "abductors",
1132
- "adductors",
1133
- "lats",
1134
- "upper_back",
1135
- "traps",
1136
- "lower_back",
1137
- "chest",
1138
- "cardio",
1139
- "neck",
1140
- "full_body",
1141
- "other"
1142
- ]);
1143
- const exerciseTypeEnum = z.enum([
1144
- "weight_reps",
1145
- "reps_only",
1146
- "bodyweight_reps",
1147
- "bodyweight_assisted_reps",
1148
- "duration",
1149
- "weight_duration",
1150
- "distance_duration",
1151
- "short_distance_weight"
1152
- ]);
1153
- const equipmentCategoryEnum = z.enum([
1154
- "none",
1155
- "barbell",
1156
- "dumbbell",
1157
- "kettlebell",
1158
- "machine",
1159
- "plate",
1160
- "resistance_band",
1161
- "suspension",
1162
- "other"
1163
- ]);
1164
1255
  //#endregion
1165
- //#region src/tools/body-measurements.ts
1256
+ //#region src/tools/input-schemas.ts
1257
+ /** Build the page and pageSize fields shared by paginated tools. */
1258
+ function paginationShape({ defaultPageSize, maxPageSize, integerPage = true }) {
1259
+ const pageNumber = z.coerce.number().gte(1);
1260
+ return {
1261
+ page: integerPage ? pageNumber.int() : pageNumber,
1262
+ pageSize: z.coerce.number().int().gte(1).lte(maxPageSize).default(defaultPageSize)
1263
+ };
1264
+ }
1265
+ const nonEmptyId = z.string().min(1);
1266
+ const CALENDAR_DATE_MESSAGE = "Date must be in YYYY-MM-DD format";
1267
+ const calendarDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, CALENDAR_DATE_MESSAGE).refine((value) => {
1268
+ const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
1269
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(value);
1270
+ }, CALENDAR_DATE_MESSAGE);
1271
+ const workoutSetShape = {
1272
+ type: setTypeEnum,
1273
+ weight: z.coerce.number().optional().nullable(),
1274
+ weightKg: z.coerce.number().optional().nullable(),
1275
+ reps: z.coerce.number().int().optional().nullable(),
1276
+ distance: z.coerce.number().int().optional().nullable(),
1277
+ distanceMeters: z.coerce.number().int().optional().nullable(),
1278
+ duration: z.coerce.number().int().optional().nullable(),
1279
+ durationSeconds: z.coerce.number().int().optional().nullable(),
1280
+ rpe: z.coerce.number().optional().nullable(),
1281
+ customMetric: z.coerce.number().optional().nullable()
1282
+ };
1283
+ const workoutExerciseShape = {
1284
+ exerciseTemplateId: nonEmptyId,
1285
+ supersetId: z.coerce.number().nullable().optional(),
1286
+ notes: z.string().optional().nullable(),
1287
+ sets: z.array(z.object(workoutSetShape))
1288
+ };
1289
+ const workoutPayloadShape = {
1290
+ title: z.string().min(1),
1291
+ description: z.string().optional().nullable(),
1292
+ startTime: utcSecondTimestamp,
1293
+ endTime: utcSecondTimestamp,
1294
+ isPrivate: z.boolean().default(false),
1295
+ exercises: z.preprocess(parseJsonArray, z.array(z.object(workoutExerciseShape)))
1296
+ };
1297
+ const routineSetShape = {
1298
+ type: setTypeEnum,
1299
+ weight: z.coerce.number().optional(),
1300
+ weightKg: z.coerce.number().optional(),
1301
+ reps: zNullableInt,
1302
+ distance: z.coerce.number().int().optional(),
1303
+ distanceMeters: z.coerce.number().int().optional(),
1304
+ duration: z.coerce.number().int().optional(),
1305
+ durationSeconds: z.coerce.number().int().optional(),
1306
+ customMetric: z.coerce.number().optional(),
1307
+ repRange: zOptionalRepRange
1308
+ };
1309
+ const routineExerciseShape = {
1310
+ exerciseTemplateId: nonEmptyId,
1311
+ supersetId: z.coerce.number().nullable().optional(),
1312
+ restSeconds: z.coerce.number().int().min(0).optional(),
1313
+ notes: z.string().optional(),
1314
+ sets: z.array(z.object(routineSetShape))
1315
+ };
1316
+ const routinePayloadShape = {
1317
+ title: z.string().min(1),
1318
+ folderId: z.coerce.number().nullable().optional(),
1319
+ notes: z.string().optional(),
1320
+ exercises: z.preprocess(parseJsonArray, z.array(z.object(routineExerciseShape)))
1321
+ };
1166
1322
  const bodyMeasurementFieldsSchema = {
1167
1323
  weightKg: zNullableNumber.describe("Body weight in kilograms"),
1168
1324
  leanMassKg: zNullableNumber.describe("Lean body mass in kilograms"),
@@ -1182,7 +1338,7 @@ const bodyMeasurementFieldsSchema = {
1182
1338
  leftCalf: zNullableNumber.describe("Left calf circumference in centimeters"),
1183
1339
  rightCalf: zNullableNumber.describe("Right calf circumference in centimeters")
1184
1340
  };
1185
- const MEASUREMENT_FIELD_TO_API_KEY = {
1341
+ const measurementFieldToApiKey = {
1186
1342
  weightKg: "weight_kg",
1187
1343
  leanMassKg: "lean_mass_kg",
1188
1344
  fatPercent: "fat_percent",
@@ -1201,20 +1357,130 @@ const MEASUREMENT_FIELD_TO_API_KEY = {
1201
1357
  leftCalf: "left_calf",
1202
1358
  rightCalf: "right_calf"
1203
1359
  };
1204
- function buildMeasurementPayload(args) {
1360
+ //#endregion
1361
+ //#region src/tools/payload-mappers.ts
1362
+ /** Map the public camelCase workout input to the API's snake_case payload. */
1363
+ function buildWorkoutPayload(input) {
1364
+ return {
1365
+ title: input.title,
1366
+ description: input.description ?? null,
1367
+ start_time: input.startTime,
1368
+ end_time: input.endTime,
1369
+ is_private: input.isPrivate,
1370
+ exercises: input.exercises.map((exercise) => ({
1371
+ exercise_template_id: exercise.exerciseTemplateId,
1372
+ superset_id: exercise.supersetId ?? null,
1373
+ notes: exercise.notes ?? null,
1374
+ sets: exercise.sets.map((set) => ({
1375
+ type: set.type,
1376
+ weight_kg: set.weight ?? set.weightKg ?? null,
1377
+ reps: set.reps ?? null,
1378
+ distance_meters: set.distance ?? set.distanceMeters ?? null,
1379
+ duration_seconds: set.duration ?? set.durationSeconds ?? null,
1380
+ rpe: set.rpe ?? null,
1381
+ custom_metric: set.customMetric ?? null
1382
+ }))
1383
+ }))
1384
+ };
1385
+ }
1386
+ function buildRepRange(repRange) {
1387
+ if (!repRange) return null;
1388
+ const start = repRange.start ?? void 0;
1389
+ const end = repRange.end ?? void 0;
1390
+ if (start === void 0 && end === void 0) return null;
1391
+ return {
1392
+ start,
1393
+ end
1394
+ };
1395
+ }
1396
+ function getFixedRepsFromRepRange(repRange) {
1397
+ if (!repRange) return null;
1398
+ const start = repRange.start ?? null;
1399
+ const end = repRange.end ?? null;
1400
+ if (start === null || end === null || start !== end) return null;
1401
+ return start;
1402
+ }
1403
+ function buildRoutineSets(sets, mode) {
1404
+ return sets.map((set) => {
1405
+ const repRange = buildRepRange(set.repRange);
1406
+ const reps = typeof set.reps === "number" ? set.reps : getFixedRepsFromRepRange(repRange);
1407
+ const common = {
1408
+ weight_kg: set.weight ?? set.weightKg ?? null,
1409
+ reps: reps ?? null,
1410
+ distance_meters: set.distance ?? set.distanceMeters ?? null,
1411
+ duration_seconds: set.duration ?? set.durationSeconds ?? null,
1412
+ custom_metric: set.customMetric ?? null
1413
+ };
1414
+ if (mode === "create") return {
1415
+ ...common,
1416
+ type: set.type,
1417
+ rep_range: repRange
1418
+ };
1419
+ return {
1420
+ ...common,
1421
+ type: set.type,
1422
+ ...repRange ? { rep_range: repRange } : {}
1423
+ };
1424
+ });
1425
+ }
1426
+ function buildRoutinePayload(input, mode) {
1427
+ let usesRepRanges = false;
1428
+ const exercises = input.exercises.map((exercise) => {
1429
+ const sets = buildRoutineSets(exercise.sets, mode);
1430
+ if (sets.some((set) => set.rep_range != null && getFixedRepsFromRepRange(set.rep_range) === null)) usesRepRanges = true;
1431
+ return {
1432
+ exercise_template_id: exercise.exerciseTemplateId,
1433
+ superset_id: exercise.supersetId ?? null,
1434
+ rest_seconds: exercise.restSeconds ?? null,
1435
+ notes: exercise.notes ?? null,
1436
+ sets
1437
+ };
1438
+ });
1439
+ if (mode === "create") return {
1440
+ payload: {
1441
+ title: input.title,
1442
+ folder_id: input.folderId ?? null,
1443
+ notes: input.notes ?? "",
1444
+ exercises
1445
+ },
1446
+ usesRepRanges
1447
+ };
1448
+ return {
1449
+ payload: {
1450
+ title: input.title,
1451
+ notes: input.notes ?? null,
1452
+ exercises
1453
+ },
1454
+ usesRepRanges
1455
+ };
1456
+ }
1457
+ /** Omit nullish measurement values because the API rejects explicit nulls. */
1458
+ function buildMeasurementPayload(fields) {
1205
1459
  const payload = {};
1206
- for (const [camelKey, apiKey] of Object.entries(MEASUREMENT_FIELD_TO_API_KEY)) {
1207
- const value = args[camelKey];
1460
+ for (const [camelKey, apiKey] of Object.entries(measurementFieldToApiKey)) {
1461
+ const value = fields[camelKey];
1208
1462
  if (value != null) payload[apiKey] = value;
1209
1463
  }
1210
1464
  return payload;
1211
1465
  }
1212
- function registerBodyMeasurementTools(server, hevyClient, wrapHandler = withErrorHandling) {
1213
- const getBodyMeasurementsSchema = {
1214
- page: z.coerce.number().int().gte(1).default(1),
1215
- pageSize: z.coerce.number().int().gte(1).lte(10).default(10)
1216
- };
1217
- server.registerTool("get-body-measurements", {
1466
+ //#endregion
1467
+ //#region src/tools/body-measurements.ts
1468
+ const getBodyMeasurementsSchema = { ...paginationShape({
1469
+ defaultPageSize: 10,
1470
+ maxPageSize: 10
1471
+ }) };
1472
+ const getBodyMeasurementSchema = { date: calendarDate.describe("The date of the body measurement (YYYY-MM-DD)") };
1473
+ const createBodyMeasurementSchema = {
1474
+ date: calendarDate.describe("The date of the body measurement (YYYY-MM-DD). Must be unique — returns 409 if an entry already exists for this date."),
1475
+ ...bodyMeasurementFieldsSchema
1476
+ };
1477
+ const updateBodyMeasurementSchema = {
1478
+ date: calendarDate.describe("The date of the body measurement to update (YYYY-MM-DD). Must already exist — returns 404 otherwise."),
1479
+ ...bodyMeasurementFieldsSchema
1480
+ };
1481
+ const bodyMeasurementToolDefinitions = [
1482
+ {
1483
+ name: "get-body-measurements",
1218
1484
  description: describeTool({
1219
1485
  summary: "Read-only. Lists dated body measurements for the account.",
1220
1486
  aliases: [
@@ -1226,20 +1492,20 @@ function registerBodyMeasurementTools(server, hevyClient, wrapHandler = withErro
1226
1492
  importantNotes: "Results are paginated; page starts at 1 and pageSize is limited to 10."
1227
1493
  }),
1228
1494
  inputSchema: getBodyMeasurementsSchema,
1229
- outputSchema: bodyMeasurementsOutputSchema,
1230
- annotations: readOnlyAnnotations("Get Body Measurements")
1231
- }, wrapHandler(async (args) => {
1232
- const client = requireClient(hevyClient);
1233
- const { page, pageSize } = args;
1234
- const measurements = (await client.getBodyMeasurements({
1235
- page,
1236
- pageSize
1237
- }))?.body_measurements?.map((measurement) => formatBodyMeasurement(measurement)) || [];
1238
- if (measurements.length === 0) return createStructuredEmptyResponse("No body measurements found for the specified parameters", { bodyMeasurements: [] });
1239
- return createStructuredJsonResponse(measurements, { bodyMeasurements: measurements });
1240
- }, "get-body-measurements"));
1241
- 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)") };
1242
- server.registerTool("get-body-measurement", {
1495
+ outputSchema: bodyMeasurementsResponse.outputSchema,
1496
+ annotations: readOnlyAnnotations("Get Body Measurements"),
1497
+ kind: "read",
1498
+ responseContract: bodyMeasurementsResponse,
1499
+ execute: async (runtime, args) => {
1500
+ const { page, pageSize } = args;
1501
+ return (await runtime.getClient().getBodyMeasurements({
1502
+ page,
1503
+ pageSize
1504
+ }))?.body_measurements;
1505
+ }
1506
+ },
1507
+ {
1508
+ name: "get-body-measurement",
1243
1509
  description: describeTool({
1244
1510
  summary: "Read-only. Retrieves the body measurement entry for one date.",
1245
1511
  aliases: [
@@ -1251,71 +1517,79 @@ function registerBodyMeasurementTools(server, hevyClient, wrapHandler = withErro
1251
1517
  importantNotes: "date must use YYYY-MM-DD; at most one entry exists per date."
1252
1518
  }),
1253
1519
  inputSchema: getBodyMeasurementSchema,
1254
- outputSchema: bodyMeasurementOutputSchema,
1255
- annotations: readOnlyAnnotations("Get Body Measurement")
1256
- }, wrapHandler(async (args) => {
1257
- const client = requireClient(hevyClient);
1258
- const { date } = args;
1259
- const data = await client.getBodyMeasurement(date);
1260
- if (!data) return createStructuredEmptyResponse(`No body measurement found for date ${date}`, { bodyMeasurement: null });
1261
- const bodyMeasurement = formatBodyMeasurement(data);
1262
- return createStructuredJsonResponse(bodyMeasurement, { bodyMeasurement });
1263
- }, "get-body-measurement"));
1264
- const createBodyMeasurementSchema = {
1265
- date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").describe("The date of the body measurement (YYYY-MM-DD). Must be unique — returns 409 if an entry already exists for this date."),
1266
- ...bodyMeasurementFieldsSchema
1267
- };
1268
- server.tool("create-body-measurement", describeTool({
1269
- summary: "Writes to the Hevy account by creating a dated body measurement.",
1270
- aliases: [
1271
- "log weigh-in",
1272
- "add body stats",
1273
- "record measurements"
1274
- ],
1275
- useCase: "Use for a date without an entry; use update-body-measurement when that date already exists.",
1276
- importantNotes: "date must use YYYY-MM-DD and be unique. Null fields are omitted and cannot clear values; an existing date returns 409."
1277
- }), createBodyMeasurementSchema, createAnnotations("Create Body Measurement"), wrapHandler(async (args) => {
1278
- const client = requireClient(hevyClient);
1279
- const { date, ...fields } = args;
1280
- await client.createBodyMeasurement({
1281
- date,
1282
- ...buildMeasurementPayload(fields)
1283
- });
1284
- return createTextResponse(`Body measurement for ${date} created successfully.`);
1285
- }, "create-body-measurement"));
1286
- const updateBodyMeasurementSchema = {
1287
- date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").describe("The date of the body measurement to update (YYYY-MM-DD). Must already exist — returns 404 otherwise."),
1288
- ...bodyMeasurementFieldsSchema
1289
- };
1290
- server.tool("update-body-measurement", describeTool({
1291
- summary: "Mutates the Hevy account by updating a body measurement for a date.",
1292
- aliases: [
1293
- "edit weigh-in",
1294
- "correct body stats",
1295
- "change measurements"
1296
- ],
1297
- useCase: "Use to change fields on an existing date; use create-body-measurement for a new date.",
1298
- importantNotes: "date must use YYYY-MM-DD and already exist. Provide at least one numeric field; nulls are omitted and cannot clear stored values."
1299
- }), updateBodyMeasurementSchema, updateAnnotations("Update Body Measurement"), wrapHandler(async (args) => {
1300
- const client = requireClient(hevyClient);
1301
- const { date, ...fields } = args;
1302
- const payload = buildMeasurementPayload(fields);
1303
- 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.");
1304
- await client.updateBodyMeasurement(date, payload);
1305
- return createTextResponse(`Body measurement for ${date} updated successfully.`);
1306
- }, "update-body-measurement"));
1307
- }
1520
+ outputSchema: bodyMeasurementResponse.outputSchema,
1521
+ annotations: readOnlyAnnotations("Get Body Measurement"),
1522
+ kind: "read",
1523
+ responseContract: bodyMeasurementResponse,
1524
+ execute: async (runtime, args) => {
1525
+ const { date } = args;
1526
+ return {
1527
+ bodyMeasurement: await runtime.getClient().getBodyMeasurement(date),
1528
+ date
1529
+ };
1530
+ }
1531
+ },
1532
+ {
1533
+ name: "create-body-measurement",
1534
+ description: describeTool({
1535
+ summary: "Writes to the Hevy account by creating a dated body measurement.",
1536
+ aliases: [
1537
+ "log weigh-in",
1538
+ "add body stats",
1539
+ "record measurements"
1540
+ ],
1541
+ useCase: "Use for a date without an entry; use update-body-measurement when that date already exists.",
1542
+ importantNotes: "date must use YYYY-MM-DD and be unique. Null fields are omitted and cannot clear values; an existing date returns 409."
1543
+ }),
1544
+ inputSchema: createBodyMeasurementSchema,
1545
+ annotations: createAnnotations("Create Body Measurement"),
1546
+ kind: "write",
1547
+ responseContract: createBodyMeasurementResponse,
1548
+ execute: async (runtime, args) => {
1549
+ const { date, ...fields } = args;
1550
+ await runtime.getClient().createBodyMeasurement({
1551
+ date,
1552
+ ...buildMeasurementPayload(fields)
1553
+ });
1554
+ return date;
1555
+ }
1556
+ },
1557
+ {
1558
+ name: "update-body-measurement",
1559
+ description: describeTool({
1560
+ summary: "Mutates the Hevy account by updating a body measurement for a date.",
1561
+ aliases: [
1562
+ "edit weigh-in",
1563
+ "correct body stats",
1564
+ "change measurements"
1565
+ ],
1566
+ useCase: "Use to change fields on an existing date; use create-body-measurement for a new date.",
1567
+ importantNotes: "date must use YYYY-MM-DD and already exist. Provide at least one numeric field; nulls are omitted and cannot clear stored values."
1568
+ }),
1569
+ inputSchema: updateBodyMeasurementSchema,
1570
+ annotations: updateAnnotations("Update Body Measurement"),
1571
+ kind: "write",
1572
+ responseContract: updateBodyMeasurementResponse,
1573
+ execute: async (runtime, args) => {
1574
+ const { date, ...fields } = args;
1575
+ const payload = buildMeasurementPayload(fields);
1576
+ 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.");
1577
+ await runtime.getClient().updateBodyMeasurement(date, payload);
1578
+ return date;
1579
+ }
1580
+ }
1581
+ ];
1308
1582
  //#endregion
1309
1583
  //#region src/tools/folders.ts
1310
- /**
1311
- * Register all routine folder-related tools with the MCP server
1312
- */
1313
- function registerFolderTools(server, hevyClient, wrapHandler = withErrorHandling) {
1314
- const getRoutineFoldersSchema = {
1315
- page: z.coerce.number().int().gte(1).default(1),
1316
- pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
1317
- };
1318
- server.registerTool("get-routine-folders", {
1584
+ const getRoutineFoldersSchema = paginationShape({
1585
+ defaultPageSize: 5,
1586
+ maxPageSize: 10
1587
+ });
1588
+ const getRoutineFolderSchema = { folderId: nonEmptyId };
1589
+ const createRoutineFolderSchema = { name: z.string().min(1) };
1590
+ const folderToolDefinitions = [
1591
+ {
1592
+ name: "get-routine-folders",
1319
1593
  description: describeTool({
1320
1594
  summary: "Read-only. Lists default and custom routine folders.",
1321
1595
  aliases: [
@@ -1327,20 +1601,20 @@ function registerFolderTools(server, hevyClient, wrapHandler = withErrorHandling
1327
1601
  importantNotes: "Results are paginated; page starts at 1 and pageSize is limited to 10."
1328
1602
  }),
1329
1603
  inputSchema: getRoutineFoldersSchema,
1330
- outputSchema: routineFoldersOutputSchema,
1331
- annotations: readOnlyAnnotations("Get Routine Folders")
1332
- }, wrapHandler(async (args) => {
1333
- const client = requireClient(hevyClient);
1334
- const { page, pageSize } = args;
1335
- const folders = (await client.getRoutineFolders({
1336
- page,
1337
- pageSize
1338
- }))?.routine_folders?.map((folder) => formatRoutineFolder(folder)) || [];
1339
- if (folders.length === 0) return createStructuredEmptyResponse("No routine folders found for the specified parameters", { routineFolders: [] });
1340
- return createStructuredJsonResponse(folders, { routineFolders: folders });
1341
- }, "get-routine-folders"));
1342
- const getRoutineFolderSchema = { folderId: z.string().min(1) };
1343
- server.registerTool("get-routine-folder", {
1604
+ outputSchema: routineFoldersResponse.outputSchema,
1605
+ annotations: readOnlyAnnotations("Get Routine Folders"),
1606
+ kind: "read",
1607
+ responseContract: routineFoldersResponse,
1608
+ execute: async (runtime, args) => {
1609
+ const { page, pageSize } = args;
1610
+ return (await runtime.getClient().getRoutineFolders({
1611
+ page,
1612
+ pageSize
1613
+ }))?.routine_folders;
1614
+ }
1615
+ },
1616
+ {
1617
+ name: "get-routine-folder",
1344
1618
  description: describeTool({
1345
1619
  summary: "Read-only. Retrieves one routine folder's metadata by ID.",
1346
1620
  aliases: [
@@ -1352,161 +1626,100 @@ function registerFolderTools(server, hevyClient, wrapHandler = withErrorHandling
1352
1626
  importantNotes: "Requires a folderId from get-routine-folders or a prior create response."
1353
1627
  }),
1354
1628
  inputSchema: getRoutineFolderSchema,
1355
- outputSchema: routineFolderOutputSchema,
1356
- annotations: readOnlyAnnotations("Get Routine Folder")
1357
- }, wrapHandler(async (args) => {
1358
- const client = requireClient(hevyClient);
1359
- const { folderId } = args;
1360
- const data = await client.getRoutineFolder(folderId);
1361
- if (!data) return createStructuredEmptyResponse(`Routine folder with ID ${folderId} not found`, { routineFolder: null });
1362
- const folder = formatRoutineFolder(data);
1363
- return createStructuredJsonResponse(folder, { routineFolder: folder });
1364
- }, "get-routine-folder"));
1365
- const createRoutineFolderSchema = { name: z.string().min(1) };
1366
- server.tool("create-routine-folder", describeTool({
1367
- summary: "Writes to the Hevy account by creating a new routine folder.",
1368
- aliases: [
1369
- "add folder",
1370
- "organize routines",
1371
- "create plan group"
1372
- ],
1373
- useCase: "Use to create an organizational destination before assigning new routines to a folderId.",
1374
- importantNotes: "Requires a non-empty name. Retrying or reusing a name can create duplicate folders."
1375
- }), createRoutineFolderSchema, createAnnotations("Create Routine Folder"), wrapHandler(async (args) => {
1376
- const client = requireClient(hevyClient);
1377
- const { name } = args;
1378
- const data = await client.createRoutineFolder({ routine_folder: { title: name } });
1379
- if (!data) return createEmptyResponse("Failed to create routine folder: Server returned no data");
1380
- return createJsonResponse(formatRoutineFolder(data), {
1381
- pretty: true,
1382
- indent: 2
1383
- });
1384
- }, "create-routine-folder"));
1385
- }
1386
- //#endregion
1387
- //#region src/utils/json-parser.ts
1388
- /**
1389
- * Preprocessor to handle MCP clients that send JSON-stringified arrays
1390
- * instead of native arrays for complex parameters.
1391
- *
1392
- * This is used with Zod's z.preprocess to handle cases where MCP clients
1393
- * serialize complex nested structures as JSON strings.
1394
- *
1395
- * @param val - The value to potentially parse
1396
- * @returns The parsed array if val is a valid JSON string, otherwise returns val unchanged
1397
- */
1398
- function parseJsonArray(val) {
1399
- if (typeof val === "string") try {
1400
- return JSON.parse(val);
1401
- } catch {
1402
- return val;
1403
- }
1404
- return val;
1405
- }
1406
- //#endregion
1407
- //#region src/tools/routines.ts
1408
- function buildRepRange(repRange) {
1409
- if (!repRange) return null;
1410
- const start = repRange.start ?? void 0;
1411
- const end = repRange.end ?? void 0;
1412
- if (start === void 0 && end === void 0) return null;
1413
- return {
1414
- start,
1415
- end
1416
- };
1417
- }
1418
- /**
1419
- * Returns a fixed rep count when `repRange` is a fixed range (start and end are
1420
- * both non-null and equal). Otherwise returns null.
1421
- */
1422
- function getFixedRepsFromRepRange(repRange) {
1423
- if (!repRange) return null;
1424
- const start = repRange.start ?? null;
1425
- const end = repRange.end ?? null;
1426
- if (start === null || end === null) return null;
1427
- if (start !== end) return null;
1428
- return start;
1429
- }
1430
- const repRangeDisplayWarningText = "Note: Hevy's public API stores rep ranges (rep_range), but the Hevy apps may not display them because they rely on an internal-only exercise field (input_modifier). See https://github.com/chrisdoc/hevy-mcp/issues/261 for details/workarounds.";
1431
- /**
1432
- * Register all routine-related tools with the MCP server
1433
- */
1434
- function registerRoutineTools(server, hevyClient, wrapHandler = withErrorHandling) {
1435
- const getRoutinesSchema = {
1436
- page: z.coerce.number().int().gte(1).default(1),
1437
- pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
1438
- };
1439
- server.registerTool("get-routines", {
1629
+ outputSchema: routineFolderResponse.outputSchema,
1630
+ annotations: readOnlyAnnotations("Get Routine Folder"),
1631
+ kind: "read",
1632
+ responseContract: routineFolderResponse,
1633
+ execute: async (runtime, args) => {
1634
+ const { folderId } = args;
1635
+ return {
1636
+ routineFolder: await runtime.getClient().getRoutineFolder(folderId),
1637
+ folderId
1638
+ };
1639
+ }
1640
+ },
1641
+ {
1642
+ name: "create-routine-folder",
1440
1643
  description: describeTool({
1441
- summary: "Read-only. Lists custom and default workout routines.",
1644
+ summary: "Writes to the Hevy account by creating a new routine folder.",
1442
1645
  aliases: [
1443
- "list routines",
1444
- "show workout plans",
1445
- "browse saved routines"
1646
+ "add folder",
1647
+ "organize routines",
1648
+ "create plan group"
1446
1649
  ],
1447
- useCase: "Use to browse routines or discover a routine ID; use get-routine for one known routine.",
1448
- importantNotes: "Results are paginated; page starts at 1 and pageSize is limited to 10."
1650
+ useCase: "Use to create an organizational destination before assigning new routines to a folderId.",
1651
+ importantNotes: "Requires a non-empty name. Retrying or reusing a name can create duplicate folders."
1449
1652
  }),
1450
- inputSchema: getRoutinesSchema,
1451
- outputSchema: routinesOutputSchema,
1452
- annotations: readOnlyAnnotations("Get Routines")
1453
- }, wrapHandler(async (args) => {
1454
- const client = requireClient(hevyClient);
1455
- const { page, pageSize } = args;
1456
- const routines = (await client.getRoutines({
1653
+ inputSchema: createRoutineFolderSchema,
1654
+ annotations: createAnnotations("Create Routine Folder"),
1655
+ kind: "write",
1656
+ responseContract: createRoutineFolderResponse,
1657
+ execute: async (runtime, args) => {
1658
+ const { name } = args;
1659
+ return runtime.getClient().createRoutineFolder({ routine_folder: { title: name } });
1660
+ }
1661
+ }
1662
+ ];
1663
+ //#endregion
1664
+ //#region src/tools/routines.ts
1665
+ const getRoutinesSchema = paginationShape({
1666
+ defaultPageSize: 5,
1667
+ maxPageSize: 10
1668
+ });
1669
+ const getRoutinesDefinition = {
1670
+ name: "get-routines",
1671
+ description: describeTool({
1672
+ summary: "Read-only. Lists custom and default workout routines.",
1673
+ aliases: [
1674
+ "list routines",
1675
+ "show workout plans",
1676
+ "browse saved routines"
1677
+ ],
1678
+ useCase: "Use to browse routines or discover a routine ID; use get-routine for one known routine.",
1679
+ importantNotes: "Results are paginated; page starts at 1 and pageSize is limited to 10."
1680
+ }),
1681
+ inputSchema: getRoutinesSchema,
1682
+ kind: "read",
1683
+ outputSchema: routinesResponse.outputSchema,
1684
+ annotations: readOnlyAnnotations("Get Routines"),
1685
+ responseContract: routinesResponse,
1686
+ execute: async (runtime, { page, pageSize }) => {
1687
+ return (await runtime.getClient().getRoutines({
1457
1688
  page,
1458
1689
  pageSize
1459
- }))?.routines?.map((routine) => formatRoutine(routine)) || [];
1460
- if (routines.length === 0) return createStructuredEmptyResponse("No routines found for the specified parameters", { routines: [] });
1461
- return createStructuredJsonResponse(routines, { routines });
1462
- }, "get-routines"));
1463
- const getRoutineSchema = { routineId: z.string().min(1) };
1464
- server.registerTool("get-routine", {
1465
- description: describeTool({
1466
- summary: "Read-only. Retrieves one routine and its exercise configuration by ID.",
1467
- aliases: [
1468
- "show routine",
1469
- "fetch workout plan",
1470
- "routine details"
1471
- ],
1472
- useCase: "Use when the routineId is known; use get-routines to browse or discover IDs.",
1473
- importantNotes: "Requires a routineId from get-routines or a prior create response."
1474
- }),
1475
- inputSchema: getRoutineSchema,
1476
- outputSchema: routineOutputSchema,
1477
- annotations: readOnlyAnnotations("Get Routine")
1478
- }, wrapHandler(async (args) => {
1479
- const client = requireClient(hevyClient);
1480
- const { routineId } = args;
1481
- const data = await client.getRoutineById(String(routineId));
1482
- if (!data || !data.routine) return createStructuredEmptyResponse(`Routine with ID ${routineId} not found`, { routine: null });
1483
- const routine = formatRoutine(data.routine);
1484
- return createStructuredJsonResponse(routine, { routine });
1485
- }, "get-routine"));
1486
- const createRoutineSchema = {
1487
- title: z.string().min(1),
1488
- folderId: z.coerce.number().nullable().optional(),
1489
- notes: z.string().optional(),
1490
- exercises: z.preprocess(parseJsonArray, z.array(z.object({
1491
- exerciseTemplateId: z.string().min(1),
1492
- supersetId: z.coerce.number().nullable().optional(),
1493
- restSeconds: z.coerce.number().int().min(0).optional(),
1494
- notes: z.string().optional(),
1495
- sets: z.array(z.object({
1496
- type: setTypeEnum,
1497
- weight: z.coerce.number().optional(),
1498
- weightKg: z.coerce.number().optional(),
1499
- reps: zNullableInt,
1500
- distance: z.coerce.number().int().optional(),
1501
- distanceMeters: z.coerce.number().int().optional(),
1502
- duration: z.coerce.number().int().optional(),
1503
- durationSeconds: z.coerce.number().int().optional(),
1504
- customMetric: z.coerce.number().optional(),
1505
- repRange: zOptionalRepRange
1506
- }))
1507
- })))
1508
- };
1509
- server.tool("create-routine", describeTool({
1690
+ }))?.routines;
1691
+ }
1692
+ };
1693
+ const getRoutineSchema = { routineId: nonEmptyId };
1694
+ const getRoutineDefinition = {
1695
+ name: "get-routine",
1696
+ description: describeTool({
1697
+ summary: "Read-only. Retrieves one routine and its exercise configuration by ID.",
1698
+ aliases: [
1699
+ "show routine",
1700
+ "fetch workout plan",
1701
+ "routine details"
1702
+ ],
1703
+ useCase: "Use when the routineId is known; use get-routines to browse or discover IDs.",
1704
+ importantNotes: "Requires a routineId from get-routines or a prior create response."
1705
+ }),
1706
+ inputSchema: getRoutineSchema,
1707
+ kind: "read",
1708
+ outputSchema: routineResponse.outputSchema,
1709
+ annotations: readOnlyAnnotations("Get Routine"),
1710
+ responseContract: routineResponse,
1711
+ execute: async (runtime, { routineId }) => {
1712
+ return {
1713
+ routine: (await runtime.getClient().getRoutineById(String(routineId)))?.routine,
1714
+ routineId
1715
+ };
1716
+ }
1717
+ };
1718
+ const routineExercisesSchema = z.preprocess(parseJsonArray, z.array(z.object(routineExerciseShape)));
1719
+ const createRoutineSchema = routinePayloadShape;
1720
+ const createRoutineDefinition = {
1721
+ name: "create-routine",
1722
+ description: describeTool({
1510
1723
  summary: "Writes to the Hevy account by creating a new workout routine.",
1511
1724
  aliases: [
1512
1725
  "add routine",
@@ -1515,138 +1728,84 @@ function registerRoutineTools(server, hevyClient, wrapHandler = withErrorHandlin
1515
1728
  ],
1516
1729
  useCase: "Use to create a reusable plan; use create-workout to log a completed session.",
1517
1730
  importantNotes: "Requires exercise template IDs; folderId is optional. Retrying can create duplicates, and non-fixed rep ranges may not display in Hevy apps."
1518
- }), createRoutineSchema, createAnnotations("Create Routine"), wrapHandler(async (args) => {
1519
- const client = requireClient(hevyClient);
1520
- const { title, folderId, notes, exercises } = args;
1521
- let usesRepRanges = false;
1522
- const data = await client.createRoutine({ routine: {
1523
- title,
1524
- folder_id: folderId ?? null,
1525
- notes: notes ?? "",
1526
- exercises: exercises.map((exercise) => {
1527
- const sets = exercise.sets.map((set) => {
1528
- const repRange = buildRepRange(set.repRange);
1529
- const fixedReps = getFixedRepsFromRepRange(repRange);
1530
- const reps = typeof set.reps === "number" ? set.reps : fixedReps;
1531
- return {
1532
- type: set.type,
1533
- weight_kg: set.weight ?? set.weightKg ?? null,
1534
- reps: reps ?? null,
1535
- distance_meters: set.distance ?? set.distanceMeters ?? null,
1536
- duration_seconds: set.duration ?? set.durationSeconds ?? null,
1537
- custom_metric: set.customMetric ?? null,
1538
- rep_range: repRange
1539
- };
1540
- });
1541
- if (sets.some((set) => set.rep_range != null && getFixedRepsFromRepRange(set.rep_range) === null)) usesRepRanges = true;
1542
- return {
1543
- exercise_template_id: exercise.exerciseTemplateId,
1544
- superset_id: exercise.supersetId ?? null,
1545
- rest_seconds: exercise.restSeconds ?? null,
1546
- notes: exercise.notes ?? null,
1547
- sets
1548
- };
1549
- })
1550
- } });
1551
- if (!data) return createEmptyResponse("Failed to create routine: Server returned no data");
1552
- const response = createJsonResponse(formatRoutine(data), {
1553
- pretty: true,
1554
- indent: 2
1555
- });
1556
- if (usesRepRanges) response.content.push({
1557
- type: "text",
1558
- text: repRangeDisplayWarningText
1559
- });
1560
- return response;
1561
- }, "create-routine"));
1562
- const updateRoutineSchema = {
1563
- routineId: z.string().min(1),
1564
- title: z.string().min(1),
1565
- notes: z.string().optional(),
1566
- exercises: z.preprocess(parseJsonArray, z.array(z.object({
1567
- exerciseTemplateId: z.string().min(1),
1568
- supersetId: z.coerce.number().nullable().optional(),
1569
- restSeconds: z.coerce.number().int().min(0).optional(),
1570
- notes: z.string().optional(),
1571
- sets: z.array(z.object({
1572
- type: setTypeEnum,
1573
- weight: z.coerce.number().optional(),
1574
- weightKg: z.coerce.number().optional(),
1575
- reps: zNullableInt,
1576
- distance: z.coerce.number().int().optional(),
1577
- distanceMeters: z.coerce.number().int().optional(),
1578
- duration: z.coerce.number().int().optional(),
1579
- durationSeconds: z.coerce.number().int().optional(),
1580
- customMetric: z.coerce.number().optional(),
1581
- repRange: zOptionalRepRange
1582
- }))
1583
- })))
1584
- };
1585
- server.tool("update-routine", describeTool({
1586
- summary: "Mutates the Hevy account by replacing an existing routine's content.",
1587
- aliases: [
1588
- "edit routine",
1589
- "revise workout plan",
1590
- "replace routine exercises"
1591
- ],
1592
- useCase: "Use to change a known routine; use create-routine for a separate new plan.",
1593
- importantNotes: "Requires routineId and the complete title and exercises payload; omitted exercises are removed. Non-fixed rep ranges may not display in Hevy apps."
1594
- }), updateRoutineSchema, updateAnnotations("Update Routine"), wrapHandler(async (args) => {
1595
- const client = requireClient(hevyClient);
1596
- const { routineId, title, notes, exercises } = args;
1597
- let usesRepRanges = false;
1598
- const data = await client.updateRoutine(routineId, { routine: {
1599
- title,
1600
- notes: notes ?? null,
1601
- exercises: exercises.map((exercise) => {
1602
- const sets = exercise.sets.map((set) => {
1603
- const repRange = buildRepRange(set.repRange);
1604
- const fixedReps = getFixedRepsFromRepRange(repRange);
1605
- const reps = typeof set.reps === "number" ? set.reps : fixedReps;
1606
- return {
1607
- type: set.type,
1608
- weight_kg: set.weight ?? set.weightKg ?? null,
1609
- reps: reps ?? null,
1610
- distance_meters: set.distance ?? set.distanceMeters ?? null,
1611
- duration_seconds: set.duration ?? set.durationSeconds ?? null,
1612
- custom_metric: set.customMetric ?? null,
1613
- ...repRange ? { rep_range: repRange } : {}
1614
- };
1615
- });
1616
- if (sets.some((set) => set.rep_range != null && getFixedRepsFromRepRange(set.rep_range) === null)) usesRepRanges = true;
1617
- return {
1618
- exercise_template_id: exercise.exerciseTemplateId,
1619
- superset_id: exercise.supersetId ?? null,
1620
- rest_seconds: exercise.restSeconds ?? null,
1621
- notes: exercise.notes ?? null,
1622
- sets
1623
- };
1624
- })
1625
- } });
1626
- if (!data) return createEmptyResponse(`Failed to update routine with ID ${routineId}`);
1627
- const response = createJsonResponse(formatRoutine(data), {
1628
- pretty: true,
1629
- indent: 2
1630
- });
1631
- if (usesRepRanges) response.content.push({
1632
- type: "text",
1633
- text: repRangeDisplayWarningText
1634
- });
1635
- return response;
1636
- }, "update-routine"));
1637
- }
1731
+ }),
1732
+ inputSchema: createRoutineSchema,
1733
+ kind: "write",
1734
+ annotations: createAnnotations("Create Routine"),
1735
+ responseContract: createRoutineResponse,
1736
+ execute: async (runtime, args) => {
1737
+ const { payload, usesRepRanges } = buildRoutinePayload(args, "create");
1738
+ return {
1739
+ routine: await runtime.getClient().createRoutine({ routine: payload }),
1740
+ usesRepRanges
1741
+ };
1742
+ }
1743
+ };
1744
+ const updateRoutineSchema = {
1745
+ routineId: nonEmptyId,
1746
+ title: z.string().min(1),
1747
+ notes: z.string().optional(),
1748
+ exercises: routineExercisesSchema
1749
+ };
1750
+ const routineToolDefinitions = [
1751
+ getRoutinesDefinition,
1752
+ getRoutineDefinition,
1753
+ createRoutineDefinition,
1754
+ {
1755
+ name: "update-routine",
1756
+ description: describeTool({
1757
+ summary: "Mutates the Hevy account by replacing an existing routine's content.",
1758
+ aliases: [
1759
+ "edit routine",
1760
+ "revise workout plan",
1761
+ "replace routine exercises"
1762
+ ],
1763
+ useCase: "Use to change a known routine; use create-routine for a separate new plan.",
1764
+ importantNotes: "Requires routineId and the complete title and exercises payload; omitted exercises are removed. Non-fixed rep ranges may not display in Hevy apps."
1765
+ }),
1766
+ inputSchema: updateRoutineSchema,
1767
+ kind: "write",
1768
+ annotations: updateAnnotations("Update Routine"),
1769
+ responseContract: updateRoutineResponse,
1770
+ execute: async (runtime, args) => {
1771
+ const { routineId } = args;
1772
+ const { payload, usesRepRanges } = buildRoutinePayload(args, "update");
1773
+ return {
1774
+ routine: await runtime.getClient().updateRoutine(routineId, { routine: payload }),
1775
+ routineId,
1776
+ usesRepRanges
1777
+ };
1778
+ }
1779
+ }
1780
+ ];
1638
1781
  //#endregion
1639
1782
  //#region src/tools/templates.ts
1640
- /**
1641
- * Register all exercise template-related tools with the MCP server
1642
- */
1643
- function registerTemplateTools(server, hevyClient, options = {}) {
1644
- const { catalog = createExerciseTemplateCatalog(), logger, wrapHandler = withErrorHandling } = options;
1645
- const getExerciseTemplatesSchema = {
1646
- page: z.coerce.number().int().gte(1).default(1),
1647
- pageSize: z.coerce.number().int().gte(1).lte(100).default(5)
1648
- };
1649
- server.registerTool("get-exercise-templates", {
1783
+ const getExerciseTemplatesSchema = paginationShape({
1784
+ defaultPageSize: 5,
1785
+ maxPageSize: 100
1786
+ });
1787
+ const getExerciseTemplateSchema = { exerciseTemplateId: nonEmptyId };
1788
+ const getExerciseHistorySchema = {
1789
+ exerciseTemplateId: nonEmptyId,
1790
+ startDate: z.string().datetime({ offset: true }).describe("ISO 8601 start date for filtering history").optional(),
1791
+ endDate: z.string().datetime({ offset: true }).describe("ISO 8601 end date for filtering history").optional()
1792
+ };
1793
+ const createExerciseTemplateSchema = {
1794
+ title: z.string().min(1),
1795
+ exerciseType: exerciseTypeEnum,
1796
+ equipmentCategory: equipmentCategoryEnum,
1797
+ muscleGroup: muscleGroupEnum,
1798
+ otherMuscles: z.array(muscleGroupEnum).default([])
1799
+ };
1800
+ const searchExerciseTemplatesSchema = {
1801
+ query: z.string().min(1).describe("Case-insensitive substring to match against exercise template titles"),
1802
+ primaryMuscleGroup: muscleGroupEnum.optional().describe("Optional filter to restrict results to a specific primary muscle group"),
1803
+ refresh: z.boolean().optional().default(false).describe("Set to true to invalidate the catalog cache and re-fetch all templates from the API")
1804
+ };
1805
+ /** Ordered exercise-template tools for composition by the shared server. */
1806
+ const templateToolDefinitions = [
1807
+ {
1808
+ name: "get-exercise-templates",
1650
1809
  description: describeTool({
1651
1810
  summary: "Read-only. Lists default and custom exercise templates with equipment and muscle metadata.",
1652
1811
  aliases: [
@@ -1658,20 +1817,20 @@ function registerTemplateTools(server, hevyClient, options = {}) {
1658
1817
  importantNotes: "Results are paginated; page starts at 1 and pageSize is limited to 100."
1659
1818
  }),
1660
1819
  inputSchema: getExerciseTemplatesSchema,
1661
- outputSchema: exerciseTemplatesOutputSchema,
1662
- annotations: readOnlyAnnotations("Get Exercise Templates")
1663
- }, wrapHandler(async (args) => {
1664
- const client = requireClient(hevyClient);
1665
- const { page, pageSize } = args;
1666
- const templates = (await client.getExerciseTemplates({
1667
- page,
1668
- pageSize
1669
- }))?.exercise_templates?.map((template) => formatExerciseTemplate(template)) || [];
1670
- if (templates.length === 0) return createStructuredEmptyResponse("No exercise templates found for the specified parameters", { exerciseTemplates: [] });
1671
- return createStructuredJsonResponse(templates, { exerciseTemplates: templates });
1672
- }, "get-exercise-templates"));
1673
- const getExerciseTemplateSchema = { exerciseTemplateId: z.string().min(1) };
1674
- server.registerTool("get-exercise-template", {
1820
+ outputSchema: exerciseTemplatesResponse.outputSchema,
1821
+ annotations: readOnlyAnnotations("Get Exercise Templates"),
1822
+ kind: "read",
1823
+ responseContract: exerciseTemplatesResponse,
1824
+ execute: async (runtime, args) => {
1825
+ const { page, pageSize } = args;
1826
+ return (await runtime.getClient().getExerciseTemplates({
1827
+ page,
1828
+ pageSize
1829
+ }))?.exercise_templates;
1830
+ }
1831
+ },
1832
+ {
1833
+ name: "get-exercise-template",
1675
1834
  description: describeTool({
1676
1835
  summary: "Read-only. Retrieves complete metadata for one exercise template by ID.",
1677
1836
  aliases: [
@@ -1683,22 +1842,20 @@ function registerTemplateTools(server, hevyClient, options = {}) {
1683
1842
  importantNotes: "Requires an exerciseTemplateId from a template list, search, routine, or workout."
1684
1843
  }),
1685
1844
  inputSchema: getExerciseTemplateSchema,
1686
- outputSchema: exerciseTemplateOutputSchema,
1687
- annotations: readOnlyAnnotations("Get Exercise Template")
1688
- }, wrapHandler(async (args) => {
1689
- const client = requireClient(hevyClient);
1690
- const { exerciseTemplateId } = args;
1691
- const data = await client.getExerciseTemplate(exerciseTemplateId);
1692
- if (!data) return createStructuredEmptyResponse(`Exercise template with ID ${exerciseTemplateId} not found`, { exerciseTemplate: null });
1693
- const template = formatExerciseTemplate(data);
1694
- return createStructuredJsonResponse(template, { exerciseTemplate: template });
1695
- }, "get-exercise-template"));
1696
- const getExerciseHistorySchema = {
1697
- exerciseTemplateId: z.string().min(1),
1698
- startDate: z.string().datetime({ offset: true }).describe("ISO 8601 start date for filtering history").optional(),
1699
- endDate: z.string().datetime({ offset: true }).describe("ISO 8601 end date for filtering history").optional()
1700
- };
1701
- server.registerTool("get-exercise-history", {
1845
+ outputSchema: exerciseTemplateResponse.outputSchema,
1846
+ annotations: readOnlyAnnotations("Get Exercise Template"),
1847
+ kind: "read",
1848
+ responseContract: exerciseTemplateResponse,
1849
+ execute: async (runtime, args) => {
1850
+ const { exerciseTemplateId } = args;
1851
+ return {
1852
+ exerciseTemplate: await runtime.getClient().getExerciseTemplate(exerciseTemplateId),
1853
+ exerciseTemplateId
1854
+ };
1855
+ }
1856
+ },
1857
+ {
1858
+ name: "get-exercise-history",
1702
1859
  description: describeTool({
1703
1860
  summary: "Read-only. Retrieves past performed sets for one exercise template.",
1704
1861
  aliases: [
@@ -1710,54 +1867,50 @@ function registerTemplateTools(server, hevyClient, options = {}) {
1710
1867
  importantNotes: "Requires an exerciseTemplateId. Optional startDate and endDate must be ISO 8601 datetimes with an offset."
1711
1868
  }),
1712
1869
  inputSchema: getExerciseHistorySchema,
1713
- outputSchema: exerciseHistoryOutputSchema,
1714
- annotations: readOnlyAnnotations("Get Exercise History")
1715
- }, wrapHandler(async (args) => {
1716
- const client = requireClient(hevyClient);
1717
- const { exerciseTemplateId, startDate, endDate } = args;
1718
- const history = (await client.getExerciseHistory(exerciseTemplateId, {
1719
- ...startDate ? { start_date: startDate } : {},
1720
- ...endDate ? { end_date: endDate } : {}
1721
- }))?.exercise_history?.map((entry) => formatExerciseHistoryEntry(entry)) || [];
1722
- if (history.length === 0) return createStructuredEmptyResponse(`No exercise history found for template ${exerciseTemplateId}`, { exerciseHistory: [] });
1723
- return createStructuredJsonResponse(history, { exerciseHistory: history });
1724
- }, "get-exercise-history"));
1725
- const createExerciseTemplateSchema = {
1726
- title: z.string().min(1),
1727
- exerciseType: exerciseTypeEnum,
1728
- equipmentCategory: equipmentCategoryEnum,
1729
- muscleGroup: muscleGroupEnum,
1730
- otherMuscles: z.array(muscleGroupEnum).default([])
1731
- };
1732
- server.tool("create-exercise-template", describeTool({
1733
- summary: "Writes to the Hevy account by creating a custom exercise template.",
1734
- aliases: [
1735
- "add custom exercise",
1736
- "create movement",
1737
- "define exercise"
1738
- ],
1739
- useCase: "Use only when the needed movement is absent; search-exercise-templates should check existing templates first.",
1740
- importantNotes: "Requires title, exercise type, equipment category, and primary muscle group. Retrying or reusing a title can create duplicates."
1741
- }), createExerciseTemplateSchema, createAnnotations("Create Exercise Template"), wrapHandler(async (args) => {
1742
- const client = requireClient(hevyClient);
1743
- const { title, exerciseType, equipmentCategory, muscleGroup, otherMuscles } = args;
1744
- return createJsonResponse({
1745
- id: (await client.createExerciseTemplate({ exercise: {
1870
+ outputSchema: exerciseHistoryResponse.outputSchema,
1871
+ annotations: readOnlyAnnotations("Get Exercise History"),
1872
+ kind: "read",
1873
+ responseContract: exerciseHistoryResponse,
1874
+ execute: async (runtime, args) => {
1875
+ const { exerciseTemplateId, startDate, endDate } = args;
1876
+ return {
1877
+ history: (await runtime.getClient().getExerciseHistory(exerciseTemplateId, {
1878
+ ...startDate ? { start_date: startDate } : {},
1879
+ ...endDate ? { end_date: endDate } : {}
1880
+ }))?.exercise_history,
1881
+ exerciseTemplateId
1882
+ };
1883
+ }
1884
+ },
1885
+ {
1886
+ name: "create-exercise-template",
1887
+ description: describeTool({
1888
+ summary: "Writes to the Hevy account by creating a custom exercise template.",
1889
+ aliases: [
1890
+ "add custom exercise",
1891
+ "create movement",
1892
+ "define exercise"
1893
+ ],
1894
+ useCase: "Use only when the needed movement is absent; search-exercise-templates should check existing templates first.",
1895
+ importantNotes: "Requires title, exercise type, equipment category, and primary muscle group. Retrying or reusing a title can create duplicates."
1896
+ }),
1897
+ inputSchema: createExerciseTemplateSchema,
1898
+ annotations: createAnnotations("Create Exercise Template"),
1899
+ kind: "write",
1900
+ responseContract: createExerciseTemplateResponse,
1901
+ execute: async (runtime, args) => {
1902
+ const { title, exerciseType, equipmentCategory, muscleGroup, otherMuscles } = args;
1903
+ return await runtime.getClient().createExerciseTemplate({ exercise: {
1746
1904
  title,
1747
1905
  exercise_type: exerciseType,
1748
1906
  equipment_category: equipmentCategory,
1749
1907
  muscle_group: muscleGroup,
1750
1908
  other_muscles: otherMuscles
1751
- } }))?.id,
1752
- message: "Exercise template created successfully"
1753
- });
1754
- }, "create-exercise-template"));
1755
- const searchExerciseTemplatesSchema = {
1756
- query: z.string().min(1).describe("Case-insensitive substring to match against exercise template titles"),
1757
- primaryMuscleGroup: muscleGroupEnum.optional().describe("Optional filter to restrict results to a specific primary muscle group"),
1758
- refresh: z.boolean().optional().default(false).describe("Set to true to invalidate the catalog cache and re-fetch all templates from the API")
1759
- };
1760
- server.registerTool("search-exercise-templates", {
1909
+ } });
1910
+ }
1911
+ },
1912
+ {
1913
+ name: "search-exercise-templates",
1761
1914
  description: describeTool({
1762
1915
  summary: "Read-only for the Hevy account. Searches the full exercise template catalog by title substring.",
1763
1916
  aliases: [
@@ -1769,322 +1922,650 @@ function registerTemplateTools(server, hevyClient, options = {}) {
1769
1922
  importantNotes: "Matching is case-insensitive. The catalog is cached locally for 5 minutes; refresh:true re-fetches all pages and changes only local cache state."
1770
1923
  }),
1771
1924
  inputSchema: searchExerciseTemplatesSchema,
1772
- outputSchema: exerciseTemplatesOutputSchema,
1773
- annotations: readOnlyAnnotations("Search Exercise Templates")
1774
- }, wrapHandler(async (args) => {
1775
- const client = requireClient(hevyClient);
1776
- const { query, primaryMuscleGroup, refresh } = args;
1777
- const templates = await catalog.get(client, {
1778
- refresh,
1779
- onRefreshed: (refreshedCatalog, reason) => {
1780
- try {
1781
- logger?.({
1782
- level: "info",
1783
- logger: "hevy-cache",
1784
- data: {
1785
- message: "Exercise template catalog refreshed",
1786
- count: refreshedCatalog.length,
1787
- reason
1788
- }
1789
- });
1790
- } catch (error) {
1791
- console.error("Failed to emit structured exercise template cache log", createSafeErrorDiagnostic(error));
1925
+ outputSchema: searchExerciseTemplatesResponse.outputSchema,
1926
+ annotations: readOnlyAnnotations("Search Exercise Templates"),
1927
+ kind: "read",
1928
+ responseContract: searchExerciseTemplatesResponse,
1929
+ execute: async (runtime, args) => {
1930
+ runtime.getClient();
1931
+ const { query, primaryMuscleGroup, refresh } = args;
1932
+ const templates = await runtime.catalog.get({
1933
+ refresh,
1934
+ onRefreshed: (refreshedCatalog, reason) => {
1935
+ try {
1936
+ runtime.logger?.({
1937
+ level: "info",
1938
+ logger: "hevy-cache",
1939
+ data: {
1940
+ message: "Exercise template catalog refreshed",
1941
+ count: refreshedCatalog.length,
1942
+ reason
1943
+ }
1944
+ });
1945
+ } catch (error) {
1946
+ console.error("Failed to emit structured exercise template cache log", createSafeErrorDiagnostic(error));
1947
+ }
1792
1948
  }
1793
- }
1949
+ });
1950
+ const queryLower = query.toLowerCase();
1951
+ let results = templates.filter((t) => (t.title ?? "").toLowerCase().includes(queryLower));
1952
+ if (primaryMuscleGroup !== void 0) results = results.filter((t) => t.primary_muscle_group === primaryMuscleGroup);
1953
+ return {
1954
+ results,
1955
+ query,
1956
+ primaryMuscleGroup
1957
+ };
1958
+ }
1959
+ }
1960
+ ];
1961
+ const userToolDefinitions = [{
1962
+ name: "get-user-info",
1963
+ description: describeTool({
1964
+ summary: "Read-only. Returns the authenticated account's user ID, display name, and public profile URL.",
1965
+ aliases: [
1966
+ "who am I",
1967
+ "account info",
1968
+ "verify Hevy user"
1969
+ ],
1970
+ useCase: "Use to confirm which Hevy account is connected before reading or writing account data.",
1971
+ importantNotes: "Accepts no inputs and reports only the account associated with the configured credentials."
1972
+ }),
1973
+ inputSchema: {},
1974
+ kind: "read",
1975
+ outputSchema: userResponse.outputSchema,
1976
+ annotations: readOnlyAnnotations("Get User Info"),
1977
+ responseContract: userResponse,
1978
+ execute: async (runtime) => {
1979
+ return (await runtime.getClient().getUserInfo())?.data;
1980
+ }
1981
+ }];
1982
+ //#endregion
1983
+ //#region src/tools/routine-discovery.ts
1984
+ const routineDiscoverySchema = {
1985
+ query: z.string().min(1).optional().describe("Optional case-insensitive substring to match routine titles."),
1986
+ limit: z.coerce.number().int().min(1).max(100).default(20).describe("Maximum compact routines to return (1-100).")
1987
+ };
1988
+ async function discoverRoutines(runtime, { query, limit }) {
1989
+ const normalizedQuery = query?.toLocaleLowerCase();
1990
+ const routines = [];
1991
+ let page = 1;
1992
+ let pages = 0;
1993
+ let itemsScanned = 0;
1994
+ const client = runtime.getClient();
1995
+ while (routines.length < limit) {
1996
+ const data = await client.getRoutines({
1997
+ page,
1998
+ pageSize: 10
1794
1999
  });
1795
- const queryLower = query.toLowerCase();
1796
- let results = templates.filter((t) => (t.title ?? "").toLowerCase().includes(queryLower));
1797
- if (primaryMuscleGroup !== void 0) results = results.filter((t) => t.primary_muscle_group === primaryMuscleGroup);
1798
- if (results.length === 0) return createStructuredEmptyResponse(`No exercise templates found matching "${query}"${primaryMuscleGroup ? ` with primary muscle group "${primaryMuscleGroup}"` : ""}`, { exerciseTemplates: [] });
1799
- const exerciseTemplates = results.map(formatExerciseTemplate);
1800
- return createStructuredJsonResponse(exerciseTemplates, { exerciseTemplates });
1801
- }, "search-exercise-templates"));
1802
- }
1803
- //#endregion
1804
- //#region src/tools/user.ts
1805
- function registerUserTools(server, hevyClient, wrapHandler = withErrorHandling) {
1806
- server.registerTool("get-user-info", {
1807
- description: describeTool({
1808
- summary: "Read-only. Returns the authenticated account's user ID, display name, and public profile URL.",
1809
- aliases: [
1810
- "who am I",
1811
- "account info",
1812
- "verify Hevy user"
1813
- ],
1814
- useCase: "Use to confirm which Hevy account is connected before reading or writing account data.",
1815
- importantNotes: "Accepts no inputs and reports only the account associated with the configured credentials."
1816
- }),
1817
- inputSchema: {},
1818
- outputSchema: userOutputSchema,
1819
- annotations: readOnlyAnnotations("Get User Info")
1820
- }, wrapHandler(async (_args) => {
1821
- const data = await requireClient(hevyClient).getUserInfo();
1822
- if (!data?.data) return createStructuredEmptyResponse("No user info found for the authenticated user", { user: null });
1823
- return createStructuredJsonResponse(data.data, { user: data.data });
1824
- }, "get-user-info"));
2000
+ pages = page;
2001
+ const pageItems = data?.routines ?? [];
2002
+ itemsScanned += pageItems.length;
2003
+ for (const routine of pageItems) {
2004
+ if (normalizedQuery && !routine.title?.toLocaleLowerCase().includes(normalizedQuery)) continue;
2005
+ routines.push(routine);
2006
+ if (routines.length >= limit) break;
2007
+ }
2008
+ const pageCount = data?.page_count;
2009
+ if (typeof pageCount !== "number" || !Number.isSafeInteger(pageCount) || pageCount <= page) break;
2010
+ page += 1;
2011
+ }
2012
+ return {
2013
+ routines: routines.slice(0, limit).map((routine) => ({
2014
+ ...routine.id ? { id: routine.id } : {},
2015
+ ...routine.title ? { title: routine.title } : {},
2016
+ folderId: routine.folder_id ?? null,
2017
+ ...routine.updated_at ? { updatedAt: routine.updated_at } : {},
2018
+ exerciseCount: routine.exercises?.length ?? 0,
2019
+ setCount: routine.exercises?.reduce((total, exercise) => total + (exercise.sets?.length ?? 0), 0) ?? 0
2020
+ })),
2021
+ workflow: {
2022
+ name: "routine-discovery",
2023
+ pagination: { routines: pages },
2024
+ cacheStatus: "not-used",
2025
+ itemsScanned
2026
+ }
2027
+ };
1825
2028
  }
2029
+ const routineDiscoveryToolDefinitions = [{
2030
+ name: "search-routines",
2031
+ description: describeTool({
2032
+ summary: "Read-only. Discovers routines by title and returns compact metadata without full set payloads.",
2033
+ aliases: [
2034
+ "find routine",
2035
+ "browse routine names",
2036
+ "compact routine list"
2037
+ ],
2038
+ useCase: "Use to find a routine ID or shortlist plans before calling get-routine for full exercise details.",
2039
+ importantNotes: "Search scans routine pages at pageSize 10 and returns only IDs, titles, folder metadata, and exercise/set counts."
2040
+ }),
2041
+ inputSchema: routineDiscoverySchema,
2042
+ outputSchema: compactRoutinesResponse.outputSchema,
2043
+ annotations: readOnlyAnnotations("Search Routines"),
2044
+ kind: "read",
2045
+ responseContract: compactRoutinesResponse,
2046
+ execute: async (runtime, args) => discoverRoutines(runtime, args)
2047
+ }];
1826
2048
  //#endregion
1827
- //#region src/tools/workouts.ts
1828
- function formatWorkoutEvent(event) {
1829
- if (event.type === "updated" && "workout" in event) return {
1830
- type: "updated",
1831
- workout: formatWorkout(event.workout)
2049
+ //#region src/tools/workflows.ts
2050
+ const trainingSummarySchema = { weeks: z.coerce.number().int().min(1).max(12).default(4).describe("Number of recent weeks to summarize (1-12).") };
2051
+ const MILLISECONDS_PER_DAY = 1440 * 60 * 1e3;
2052
+ function parseUtcDate(value) {
2053
+ const normalized = value.length === 10 ? `${value}T00:00:00.000Z` : value;
2054
+ const timestamp = Date.parse(normalized);
2055
+ return Number.isFinite(timestamp) ? timestamp : void 0;
2056
+ }
2057
+ async function fetchRecentPages(loader, pageSize, startDate, endDate, getDate) {
2058
+ const items = [];
2059
+ let page = 1;
2060
+ let itemsScanned = 0;
2061
+ const startTimestamp = parseUtcDate(startDate);
2062
+ const endTimestamp = parseUtcDate(endDate);
2063
+ if (startTimestamp === void 0 || endTimestamp === void 0) return {
2064
+ items,
2065
+ pages: 0,
2066
+ itemsScanned
1832
2067
  };
1833
- if (event.type === "deleted" && "id" in event) return {
1834
- type: "deleted",
1835
- id: event.id,
1836
- deletedAt: event.deleted_at
2068
+ const endExclusiveTimestamp = endTimestamp + MILLISECONDS_PER_DAY;
2069
+ while (true) {
2070
+ const result = await loader(page, pageSize);
2071
+ itemsScanned += result.items.length;
2072
+ if (result.items.length === 0) break;
2073
+ for (const item of result.items) {
2074
+ const date = getDate(item);
2075
+ const timestamp = date === void 0 ? void 0 : parseUtcDate(date);
2076
+ if (timestamp !== void 0 && timestamp >= startTimestamp && timestamp < endExclusiveTimestamp) items.push(item);
2077
+ }
2078
+ const lastDate = result.items.map(getDate).filter((date) => date !== void 0).at(-1);
2079
+ const lastTimestamp = lastDate === void 0 ? void 0 : parseUtcDate(lastDate);
2080
+ if (lastTimestamp !== void 0 && lastTimestamp < startTimestamp) break;
2081
+ const pageCount = result.pageCount;
2082
+ if (typeof pageCount !== "number" || !Number.isSafeInteger(pageCount) || pageCount <= page) break;
2083
+ page += 1;
2084
+ }
2085
+ return {
2086
+ items,
2087
+ pages: page,
2088
+ itemsScanned
2089
+ };
2090
+ }
2091
+ function utcDateString(date) {
2092
+ return date.toISOString().slice(0, 10);
2093
+ }
2094
+ function getPeriod(weeks) {
2095
+ const end = /* @__PURE__ */ new Date();
2096
+ const start = new Date(end);
2097
+ start.setUTCDate(start.getUTCDate() - weeks * 7);
2098
+ return {
2099
+ startDate: utcDateString(start),
2100
+ endDate: utcDateString(end)
2101
+ };
2102
+ }
2103
+ function durationSeconds(workout) {
2104
+ if (!workout.start_time || !workout.end_time) return null;
2105
+ const duration = new Date(workout.end_time).getTime() - new Date(workout.start_time).getTime();
2106
+ return Number.isFinite(duration) && duration >= 0 ? Math.floor(duration / 1e3) : null;
2107
+ }
2108
+ function compactSession(workout) {
2109
+ const exercises = workout.exercises ?? [];
2110
+ return {
2111
+ ...workout.id ? { id: workout.id } : {},
2112
+ ...workout.title ? { title: workout.title } : {},
2113
+ ...workout.start_time ? { startTime: workout.start_time } : {},
2114
+ ...workout.end_time ? { endTime: workout.end_time } : {},
2115
+ durationSeconds: durationSeconds(workout),
2116
+ exerciseCount: exercises.length,
2117
+ setCount: exercises.reduce((total, exercise) => total + (exercise.sets?.length ?? 0), 0)
1837
2118
  };
1838
- throw new Error(`Unsupported workout event type: ${event.type}`);
1839
2119
  }
1840
- /**
1841
- * Register all workout-related tools with the MCP server
1842
- */
1843
- function registerWorkoutTools(server, hevyClient, wrapHandler = withErrorHandling) {
1844
- const getWorkoutsSchema = {
1845
- page: z.coerce.number().gte(1).default(1),
1846
- pageSize: z.coerce.number().int().gte(1).lte(10).default(5)
2120
+ function compactMeasurement(measurement) {
2121
+ return {
2122
+ date: measurement.date,
2123
+ weightKg: measurement.weight_kg ?? null,
2124
+ leanMassKg: measurement.lean_mass_kg ?? null,
2125
+ fatPercent: measurement.fat_percent ?? null
1847
2126
  };
1848
- server.registerTool("get-workouts", {
1849
- description: describeTool({
1850
- summary: "Read-only. Lists workouts from newest to oldest with exercise and timing details.",
1851
- aliases: [
1852
- "list workout history",
1853
- "show recent workouts",
1854
- "browse logs"
1855
- ],
1856
- useCase: "Use to browse or page through workout history; use get-workout when a workout ID is already known.",
1857
- importantNotes: "Results are paginated; page starts at 1 and pageSize is limited to 10."
1858
- }),
1859
- inputSchema: getWorkoutsSchema,
1860
- outputSchema: workoutsOutputSchema,
1861
- annotations: readOnlyAnnotations("Get Workouts")
1862
- }, wrapHandler(async (args) => {
1863
- const client = requireClient(hevyClient);
1864
- const { page, pageSize } = args;
1865
- const workouts = (await client.getWorkouts({
2127
+ }
2128
+ async function getTrainingSummary(runtime, weeks) {
2129
+ const client = runtime.getClient();
2130
+ const period = getPeriod(weeks);
2131
+ const pageSize = 10;
2132
+ const [workoutPages, measurementPages] = await Promise.all([fetchRecentPages(async (page, pageSize) => {
2133
+ const data = await client.getWorkouts({
1866
2134
  page,
1867
2135
  pageSize
1868
- }))?.workouts?.map((workout) => formatWorkout(workout)) || [];
1869
- if (workouts.length === 0) return createStructuredEmptyResponse("No workouts found for the specified parameters", { workouts: [] });
1870
- return createStructuredJsonResponse(workouts, { workouts });
1871
- }, "get-workouts"));
1872
- const getWorkoutSchema = { workoutId: z.string().min(1) };
1873
- server.registerTool("get-workout", {
1874
- description: describeTool({
1875
- summary: "Read-only. Retrieves complete details for one workout by its ID.",
1876
- aliases: [
1877
- "show workout",
1878
- "fetch workout details",
1879
- "open workout log"
1880
- ],
1881
- useCase: "Use after get-workouts identifies the exact workout; do not use for browsing multiple workouts.",
1882
- importantNotes: "Requires a workoutId discovered from a workout list, event, or prior create response."
1883
- }),
1884
- inputSchema: getWorkoutSchema,
1885
- outputSchema: workoutOutputSchema,
1886
- annotations: readOnlyAnnotations("Get Workout")
1887
- }, wrapHandler(async (args) => {
1888
- const client = requireClient(hevyClient);
1889
- const { workoutId } = args;
1890
- const data = await client.getWorkout(workoutId);
1891
- if (!data) return createStructuredEmptyResponse(`Workout with ID ${workoutId} not found`, { workout: null });
1892
- const workout = formatWorkout(data);
1893
- return createStructuredJsonResponse(workout, { workout });
1894
- }, "get-workout"));
1895
- server.registerTool("get-workout-count", {
1896
- description: describeTool({
1897
- summary: "Read-only. Returns the total workout count for the account.",
1898
- aliases: [
1899
- "count workouts",
1900
- "how many workouts",
1901
- "workout total"
1902
- ],
1903
- useCase: "Use for totals, statistics, or estimating pages; use get-workouts for actual workout records.",
1904
- importantNotes: "Returns only a count and accepts no paging or date filters."
1905
- }),
1906
- inputSchema: {},
1907
- outputSchema: workoutCountOutputSchema,
1908
- annotations: readOnlyAnnotations("Get Workout Count")
1909
- }, wrapHandler(async () => {
1910
- const count = (await requireClient(hevyClient).getWorkoutCount())?.workout_count ?? 0;
1911
- return createStructuredJsonResponse({ count }, { count });
1912
- }, "get-workout-count"));
1913
- const getWorkoutEventsSchema = {
1914
- page: z.coerce.number().int().gte(1).default(1),
1915
- pageSize: z.coerce.number().int().gte(1).lte(10).default(5),
1916
- since: z.string().default("1970-01-01T00:00:00Z")
1917
- };
1918
- server.registerTool("get-workout-events", {
1919
- description: describeTool({
1920
- summary: "Read-only. Lists workout update and delete events since a timestamp, newest first.",
1921
- aliases: [
1922
- "sync workout changes",
1923
- "workout change feed",
1924
- "deleted workouts"
1925
- ],
1926
- useCase: "Use to incrementally synchronize a local workout cache; use get-workouts for the current workout list.",
1927
- importantNotes: "since must be a timestamp string; events are paginated with pageSize at most 10, and the default since value reads from 1970."
1928
- }),
1929
- inputSchema: getWorkoutEventsSchema,
1930
- outputSchema: workoutEventsOutputSchema,
1931
- annotations: readOnlyAnnotations("Get Workout Events")
1932
- }, wrapHandler(async (args) => {
1933
- const client = requireClient(hevyClient);
1934
- const { page, pageSize, since } = args;
1935
- const events = (await client.getWorkoutEvents({
2136
+ });
2137
+ return {
2138
+ items: data?.workouts ?? [],
2139
+ pageCount: data?.page_count
2140
+ };
2141
+ }, pageSize, period.startDate, period.endDate, (workout) => workout.start_time), fetchRecentPages(async (page, pageSize) => {
2142
+ const data = await client.getBodyMeasurements({
1936
2143
  page,
1937
- pageSize,
1938
- since
1939
- }))?.events?.map(formatWorkoutEvent) || [];
1940
- if (events.length === 0) return createStructuredEmptyResponse(`No workout events found for the specified parameters since ${since}`, { events: [] });
1941
- return createStructuredJsonResponse(events, { events });
1942
- }, "get-workout-events"));
1943
- const createWorkoutSchema = {
1944
- title: z.string().min(1),
1945
- description: z.string().optional().nullable(),
1946
- startTime: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/),
1947
- endTime: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/),
1948
- isPrivate: z.boolean().default(false),
1949
- exercises: z.preprocess(parseJsonArray, z.array(z.object({
1950
- exerciseTemplateId: z.string().min(1),
1951
- supersetId: z.coerce.number().nullable().optional(),
1952
- notes: z.string().optional().nullable(),
1953
- sets: z.array(z.object({
1954
- type: setTypeEnum,
1955
- weight: z.coerce.number().optional().nullable(),
1956
- weightKg: z.coerce.number().optional().nullable(),
1957
- reps: z.coerce.number().int().optional().nullable(),
1958
- distance: z.coerce.number().int().optional().nullable(),
1959
- distanceMeters: z.coerce.number().int().optional().nullable(),
1960
- duration: z.coerce.number().int().optional().nullable(),
1961
- durationSeconds: z.coerce.number().int().optional().nullable(),
1962
- rpe: z.coerce.number().optional().nullable(),
1963
- customMetric: z.coerce.number().optional().nullable()
1964
- }))
1965
- })))
1966
- };
1967
- server.tool("create-workout", describeTool({
1968
- summary: "Writes to the Hevy account by creating a new workout.",
1969
- aliases: [
1970
- "log workout",
1971
- "add workout",
1972
- "record training session"
1973
- ],
1974
- useCase: "Use to add a completed workout; use update-workout only when modifying an existing workout ID.",
1975
- importantNotes: "Requires UTC startTime/endTime in YYYY-MM-DDTHH:mm:ssZ form and exercise template IDs. Retrying can create duplicates."
1976
- }), createWorkoutSchema, createAnnotations("Create Workout"), wrapHandler(async (args) => {
1977
- const client = requireClient(hevyClient);
1978
- const { title, description, startTime, endTime, isPrivate, exercises } = args;
1979
- const requestBody = { workout: {
1980
- title,
1981
- description: description ?? null,
1982
- start_time: startTime,
1983
- end_time: endTime,
1984
- is_private: isPrivate,
1985
- exercises: exercises.map((exercise) => ({
1986
- exercise_template_id: exercise.exerciseTemplateId,
1987
- superset_id: exercise.supersetId ?? null,
1988
- notes: exercise.notes ?? null,
1989
- sets: exercise.sets.map((set) => ({
1990
- type: set.type,
1991
- weight_kg: set.weight ?? set.weightKg ?? null,
1992
- reps: set.reps ?? null,
1993
- distance_meters: set.distance ?? set.distanceMeters ?? null,
1994
- duration_seconds: set.duration ?? set.durationSeconds ?? null,
1995
- rpe: set.rpe ?? null,
1996
- custom_metric: set.customMetric ?? null
1997
- }))
1998
- }))
1999
- } };
2000
- const data = await client.createWorkout(requestBody);
2001
- if (!data) return createEmptyResponse("Failed to create workout: Server returned no data");
2002
- return createJsonResponse(formatWorkout(data), {
2003
- pretty: true,
2004
- indent: 2
2144
+ pageSize
2005
2145
  });
2006
- }, "create-workout"));
2007
- const updateWorkoutSchema = {
2008
- workoutId: z.string().min(1),
2009
- title: z.string().min(1),
2010
- description: z.string().optional().nullable(),
2011
- startTime: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/),
2012
- endTime: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/),
2013
- isPrivate: z.boolean().default(false),
2014
- exercises: z.preprocess(parseJsonArray, z.array(z.object({
2015
- exerciseTemplateId: z.string().min(1),
2016
- supersetId: z.coerce.number().nullable().optional(),
2017
- notes: z.string().optional().nullable(),
2018
- sets: z.array(z.object({
2019
- type: setTypeEnum,
2020
- weight: z.coerce.number().optional().nullable(),
2021
- weightKg: z.coerce.number().optional().nullable(),
2022
- reps: z.coerce.number().int().optional().nullable(),
2023
- distance: z.coerce.number().int().optional().nullable(),
2024
- distanceMeters: z.coerce.number().int().optional().nullable(),
2025
- duration: z.coerce.number().int().optional().nullable(),
2026
- durationSeconds: z.coerce.number().int().optional().nullable(),
2027
- rpe: z.coerce.number().optional().nullable(),
2028
- customMetric: z.coerce.number().optional().nullable()
2029
- }))
2030
- })))
2146
+ return {
2147
+ items: data?.body_measurements ?? [],
2148
+ pageCount: data?.page_count
2149
+ };
2150
+ }, pageSize, period.startDate, period.endDate, (measurement) => measurement.date)]);
2151
+ const workouts = workoutPages.items;
2152
+ const sessions = workouts.map(compactSession);
2153
+ const uniqueExerciseTemplateIds = [...new Set(workouts.flatMap((workout) => (workout.exercises ?? []).map((exercise) => exercise.exercise_template_id).filter((id) => Boolean(id))))];
2154
+ const measurements = [...measurementPages.items].sort((a, b) => a.date.localeCompare(b.date));
2155
+ const earliestMeasurement = measurements[0];
2156
+ const latestMeasurement = measurements.at(-1);
2157
+ const earliest = earliestMeasurement ? compactMeasurement(earliestMeasurement) : null;
2158
+ const latest = latestMeasurement ? compactMeasurement(latestMeasurement) : null;
2159
+ const weightChangeKg = latest?.weightKg !== null && latest?.weightKg !== void 0 && earliest?.weightKg !== null && earliest?.weightKg !== void 0 ? latest.weightKg - earliest.weightKg : null;
2160
+ return {
2161
+ period: {
2162
+ ...period,
2163
+ weeks
2164
+ },
2165
+ workouts: {
2166
+ count: workouts.length,
2167
+ totalDurationSeconds: sessions.reduce((total, session) => total + (session.durationSeconds ?? 0), 0),
2168
+ exerciseCount: sessions.reduce((total, session) => total + session.exerciseCount, 0),
2169
+ setCount: sessions.reduce((total, session) => total + session.setCount, 0),
2170
+ uniqueExerciseTemplateIds,
2171
+ sessions
2172
+ },
2173
+ bodyMeasurements: {
2174
+ count: measurements.length,
2175
+ latest,
2176
+ earliest,
2177
+ weightChangeKg
2178
+ },
2179
+ workflow: {
2180
+ name: "training-summary",
2181
+ pagination: {
2182
+ workouts: workoutPages.pages,
2183
+ bodyMeasurements: measurementPages.pages
2184
+ },
2185
+ cacheStatus: "not-used",
2186
+ itemsScanned: workoutPages.itemsScanned + measurementPages.itemsScanned
2187
+ }
2031
2188
  };
2032
- server.tool("update-workout", describeTool({
2033
- summary: "Mutates the Hevy account by replacing an existing workout.",
2189
+ }
2190
+ const workflowToolDefinitions = [{
2191
+ name: "get-training-summary",
2192
+ description: describeTool({
2193
+ summary: "Read-only. Summarizes recent workout activity and body-measurement trends in one call.",
2034
2194
  aliases: [
2035
- "edit workout",
2036
- "correct workout log",
2037
- "replace workout details"
2195
+ "training progress",
2196
+ "progress summary",
2197
+ "recent training overview"
2038
2198
  ],
2039
- useCase: "Use to revise a known workout; use create-workout for a new training session.",
2040
- importantNotes: "Requires workoutId plus the complete title, times, privacy, exercises, and sets payload; omitted optional values may be cleared or defaulted."
2041
- }), updateWorkoutSchema, updateAnnotations("Update Workout"), wrapHandler(async (args) => {
2042
- const client = requireClient(hevyClient);
2043
- const { workoutId, title, description, startTime, endTime, isPrivate, exercises } = args;
2044
- const requestBody = { workout: {
2045
- title,
2046
- description: description ?? null,
2047
- start_time: startTime,
2048
- end_time: endTime,
2049
- is_private: isPrivate,
2050
- exercises: exercises.map((exercise) => ({
2051
- exercise_template_id: exercise.exerciseTemplateId,
2052
- superset_id: exercise.supersetId ?? null,
2053
- notes: exercise.notes ?? null,
2054
- sets: exercise.sets.map((set) => ({
2055
- type: set.type,
2056
- weight_kg: set.weight ?? set.weightKg ?? null,
2057
- reps: set.reps ?? null,
2058
- distance_meters: set.distance ?? set.distanceMeters ?? null,
2059
- duration_seconds: set.duration ?? set.durationSeconds ?? null,
2060
- rpe: set.rpe ?? null,
2061
- custom_metric: set.customMetric ?? null
2062
- }))
2063
- }))
2064
- } };
2065
- const data = await client.updateWorkout(workoutId, requestBody);
2066
- if (!data) return createEmptyResponse(`Failed to update workout with ID ${workoutId}`);
2067
- return createJsonResponse(formatWorkout(data), {
2068
- pretty: true,
2069
- indent: 2
2070
- });
2071
- }, "update-workout-operation"));
2072
- }
2199
+ useCase: "Use for a bounded progress review instead of separately counting and paging through workouts and body measurements.",
2200
+ importantNotes: "The summary covers the most recent 1-12 weeks, returns compact session evidence, and reports the pages and items scanned."
2201
+ }),
2202
+ inputSchema: trainingSummarySchema,
2203
+ outputSchema: trainingSummaryResponse.outputSchema,
2204
+ annotations: readOnlyAnnotations("Get Training Summary"),
2205
+ kind: "read",
2206
+ responseContract: trainingSummaryResponse,
2207
+ execute: async (runtime, args) => getTrainingSummary(runtime, args.weeks)
2208
+ }];
2209
+ //#endregion
2210
+ //#region src/tools/workouts.ts
2211
+ const getWorkoutsSchema = paginationShape({
2212
+ defaultPageSize: 5,
2213
+ maxPageSize: 10,
2214
+ integerPage: false
2215
+ });
2216
+ const getWorkoutSchema = { workoutId: nonEmptyId };
2217
+ const getWorkoutEventsSchema = {
2218
+ ...paginationShape({
2219
+ defaultPageSize: 5,
2220
+ maxPageSize: 10
2221
+ }),
2222
+ since: z.string().default("1970-01-01T00:00:00Z")
2223
+ };
2224
+ const createWorkoutSchema = workoutPayloadShape;
2225
+ const updateWorkoutSchema = {
2226
+ workoutId: nonEmptyId,
2227
+ ...workoutPayloadShape
2228
+ };
2073
2229
  //#endregion
2074
2230
  //#region src/tools/register.ts
2231
+ const hevyToolDefinitions = [
2232
+ ...[
2233
+ {
2234
+ name: "get-workouts",
2235
+ description: describeTool({
2236
+ summary: "Read-only. Lists workouts from newest to oldest with exercise and timing details.",
2237
+ aliases: [
2238
+ "list workout history",
2239
+ "show recent workouts",
2240
+ "browse logs"
2241
+ ],
2242
+ useCase: "Use to browse or page through workout history; use get-workout when a workout ID is already known.",
2243
+ importantNotes: "Results are paginated; page starts at 1 and pageSize is limited to 10."
2244
+ }),
2245
+ inputSchema: getWorkoutsSchema,
2246
+ outputSchema: workoutsResponse.outputSchema,
2247
+ annotations: readOnlyAnnotations("Get Workouts"),
2248
+ kind: "read",
2249
+ responseContract: workoutsResponse,
2250
+ execute: async (runtime, args) => {
2251
+ return (await runtime.getClient().getWorkouts({
2252
+ page: args.page,
2253
+ pageSize: args.pageSize
2254
+ }))?.workouts;
2255
+ }
2256
+ },
2257
+ {
2258
+ name: "get-workout",
2259
+ description: describeTool({
2260
+ summary: "Read-only. Retrieves complete details for one workout by its ID.",
2261
+ aliases: [
2262
+ "show workout",
2263
+ "fetch workout details",
2264
+ "open workout log"
2265
+ ],
2266
+ useCase: "Use after get-workouts identifies the exact workout; do not use for browsing multiple workouts.",
2267
+ importantNotes: "Requires a workoutId discovered from a workout list, event, or prior create response."
2268
+ }),
2269
+ inputSchema: getWorkoutSchema,
2270
+ outputSchema: workoutResponse.outputSchema,
2271
+ annotations: readOnlyAnnotations("Get Workout"),
2272
+ kind: "read",
2273
+ responseContract: workoutResponse,
2274
+ execute: async (runtime, args) => {
2275
+ return {
2276
+ workout: await runtime.getClient().getWorkout(args.workoutId),
2277
+ workoutId: args.workoutId
2278
+ };
2279
+ }
2280
+ },
2281
+ {
2282
+ name: "get-workout-count",
2283
+ description: describeTool({
2284
+ summary: "Read-only. Returns the total workout count for the account.",
2285
+ aliases: [
2286
+ "count workouts",
2287
+ "how many workouts",
2288
+ "workout total"
2289
+ ],
2290
+ useCase: "Use for totals, statistics, or estimating pages; use get-workouts for actual workout records.",
2291
+ importantNotes: "Returns only a count and accepts no paging or date filters."
2292
+ }),
2293
+ inputSchema: {},
2294
+ outputSchema: workoutCountResponse.outputSchema,
2295
+ annotations: readOnlyAnnotations("Get Workout Count"),
2296
+ kind: "read",
2297
+ responseContract: workoutCountResponse,
2298
+ execute: async (runtime) => {
2299
+ return (await runtime.getClient().getWorkoutCount())?.workout_count ?? 0;
2300
+ }
2301
+ },
2302
+ {
2303
+ name: "get-workout-events",
2304
+ description: describeTool({
2305
+ summary: "Read-only. Lists workout update and delete events since a timestamp, newest first.",
2306
+ aliases: [
2307
+ "sync workout changes",
2308
+ "workout change feed",
2309
+ "deleted workouts"
2310
+ ],
2311
+ useCase: "Use to incrementally synchronize a local workout cache; use get-workouts for the current workout list.",
2312
+ importantNotes: "since must be a timestamp string; events are paginated with pageSize at most 10, and the default since value reads from 1970."
2313
+ }),
2314
+ inputSchema: getWorkoutEventsSchema,
2315
+ outputSchema: workoutEventsResponse.outputSchema,
2316
+ annotations: readOnlyAnnotations("Get Workout Events"),
2317
+ kind: "read",
2318
+ responseContract: workoutEventsResponse,
2319
+ execute: async (runtime, args) => {
2320
+ return {
2321
+ events: (await runtime.getClient().getWorkoutEvents({
2322
+ page: args.page,
2323
+ pageSize: args.pageSize,
2324
+ since: args.since
2325
+ }))?.events,
2326
+ since: args.since
2327
+ };
2328
+ }
2329
+ },
2330
+ {
2331
+ name: "create-workout",
2332
+ description: describeTool({
2333
+ summary: "Writes to the Hevy account by creating a new workout.",
2334
+ aliases: [
2335
+ "log workout",
2336
+ "add workout",
2337
+ "record training session"
2338
+ ],
2339
+ useCase: "Use to add a completed workout; use update-workout only when modifying an existing workout ID.",
2340
+ importantNotes: "Requires UTC startTime/endTime in YYYY-MM-DDTHH:mm:ssZ form and exercise template IDs. Retrying can create duplicates."
2341
+ }),
2342
+ inputSchema: createWorkoutSchema,
2343
+ annotations: createAnnotations("Create Workout"),
2344
+ kind: "write",
2345
+ responseContract: createWorkoutResponse,
2346
+ execute: async (runtime, args) => {
2347
+ return await runtime.getClient().createWorkout({ workout: buildWorkoutPayload(args) });
2348
+ }
2349
+ },
2350
+ {
2351
+ name: "update-workout",
2352
+ description: describeTool({
2353
+ summary: "Mutates the Hevy account by replacing an existing workout.",
2354
+ aliases: [
2355
+ "edit workout",
2356
+ "correct workout log",
2357
+ "replace workout details"
2358
+ ],
2359
+ useCase: "Use to revise a known workout; use create-workout for a new training session.",
2360
+ importantNotes: "Requires workoutId plus the complete title, times, privacy, exercises, and sets payload; omitted optional values may be cleared or defaulted."
2361
+ }),
2362
+ inputSchema: updateWorkoutSchema,
2363
+ annotations: updateAnnotations("Update Workout"),
2364
+ kind: "write",
2365
+ responseContract: updateWorkoutResponse,
2366
+ execute: async (runtime, args) => {
2367
+ return {
2368
+ workout: await runtime.getClient().updateWorkout(args.workoutId, { workout: buildWorkoutPayload(args) }),
2369
+ workoutId: args.workoutId
2370
+ };
2371
+ }
2372
+ }
2373
+ ],
2374
+ ...routineToolDefinitions,
2375
+ ...templateToolDefinitions,
2376
+ ...folderToolDefinitions,
2377
+ ...bodyMeasurementToolDefinitions,
2378
+ ...userToolDefinitions,
2379
+ ...workflowToolDefinitions,
2380
+ ...routineDiscoveryToolDefinitions
2381
+ ];
2075
2382
  /** Register every Hevy tool in its production ordering. */
2076
- function registerHevyTools(server, hevyClient, options = {}) {
2077
- const wrapHandler = options.wrapHandler ?? withErrorHandling;
2078
- registerWorkoutTools(server, hevyClient, wrapHandler);
2079
- registerRoutineTools(server, hevyClient, wrapHandler);
2080
- registerTemplateTools(server, hevyClient, {
2081
- catalog: options.catalog,
2082
- logger: options.logger,
2083
- wrapHandler
2383
+ function registerHevyTools(server, runtime) {
2384
+ for (const definition of hevyToolDefinitions) registerToolDefinition(server, runtime, definition);
2385
+ }
2386
+ //#endregion
2387
+ //#region src/utils/error-handler.ts
2388
+ /**
2389
+ * Centralized error handling utility for MCP tools
2390
+ */
2391
+ /**
2392
+ * Create a standardized error response for MCP tools
2393
+ *
2394
+ * @param error - The error object or message
2395
+ * @param context - Optional context information about where the error occurred
2396
+ * @returns A formatted MCP tool response with error information
2397
+ */
2398
+ function createErrorResponse(error, context) {
2399
+ const policy = resolveErrorPolicy(error, "The request failed unexpectedly. Please try again.", HEVY_CLIENT_NOT_INITIALIZED_ERROR);
2400
+ const { diagnostic } = policy;
2401
+ const axiosErrorContext = diagnostic.status !== void 0 || diagnostic.method !== void 0 || diagnostic.endpoint !== void 0 ? {
2402
+ status: diagnostic.status,
2403
+ method: diagnostic.method,
2404
+ url: diagnostic.endpoint
2405
+ } : null;
2406
+ const errorContext = {
2407
+ sourceContext: context,
2408
+ originalErrorMessage: `${diagnostic.category} occurred`,
2409
+ errorCode: diagnostic.code,
2410
+ errorType: policy.type,
2411
+ axios: axiosErrorContext ?? void 0
2412
+ };
2413
+ const formattedMessage = `${context ? `[${context}] ` : ""}Error: ${policy.message}`;
2414
+ console.error("MCP tool failure", diagnostic);
2415
+ return {
2416
+ content: [{
2417
+ type: "text",
2418
+ text: formattedMessage
2419
+ }],
2420
+ isError: true,
2421
+ errorContext
2422
+ };
2423
+ }
2424
+ /**
2425
+ * Wrap an async function with standardized error handling
2426
+ *
2427
+ * This function preserves the parameter types of the wrapped function while
2428
+ * providing error handling. The returned function accepts Record<string, unknown>
2429
+ * (as required by MCP SDK) but internally casts to the original parameter type.
2430
+ *
2431
+ * @param fn - The async function to wrap
2432
+ * @param context - Context information for error messages
2433
+ * @returns A function that catches errors and returns standardized error responses
2434
+ */
2435
+ function withErrorHandling(fn, context, onError) {
2436
+ return async (rawArgs) => {
2437
+ const args = rawArgs ?? {};
2438
+ try {
2439
+ return await fn(args);
2440
+ } catch (error) {
2441
+ try {
2442
+ onError?.(error, context, Object.keys(args).length);
2443
+ } catch {
2444
+ console.error("MCP error observer failure", { category: "ObserverError" });
2445
+ }
2446
+ return createErrorResponse(error, context);
2447
+ }
2448
+ };
2449
+ }
2450
+ //#endregion
2451
+ //#region src/utils/cache.ts
2452
+ /**
2453
+ * Shared in-memory cache for async fetches with TTL, LRU eviction,
2454
+ * in-flight de-duplication, and explicit refresh/invalidation support.
2455
+ */
2456
+ var AsyncTtlCache = class {
2457
+ ttlMs;
2458
+ maxSize;
2459
+ now;
2460
+ entries = /* @__PURE__ */ new Map();
2461
+ inFlight = /* @__PURE__ */ new Map();
2462
+ requestCounter = 0;
2463
+ constructor(options, now = Date.now) {
2464
+ const { ttlMs, maxSize } = options;
2465
+ if (ttlMs <= 0) throw new Error("Cache ttlMs must be greater than 0.");
2466
+ if (maxSize <= 0) throw new Error("Cache maxSize must be greater than 0.");
2467
+ this.ttlMs = ttlMs;
2468
+ this.maxSize = maxSize;
2469
+ this.now = now;
2470
+ }
2471
+ async getOrFetch(key, fetcher, options = {}) {
2472
+ const { refresh = false } = options;
2473
+ if (refresh) this.invalidate(key);
2474
+ else {
2475
+ const cachedEntry = this.entries.get(key);
2476
+ if (cachedEntry !== void 0) {
2477
+ if (cachedEntry.expiresAt > this.now()) {
2478
+ this.markAsRecentlyUsed(key, cachedEntry);
2479
+ return cachedEntry.value;
2480
+ }
2481
+ this.entries.delete(key);
2482
+ }
2483
+ const inFlightEntry = this.inFlight.get(key);
2484
+ if (inFlightEntry !== void 0) return inFlightEntry.promise;
2485
+ }
2486
+ const requestId = ++this.requestCounter;
2487
+ const request = (async () => {
2488
+ try {
2489
+ const value = await fetcher();
2490
+ if (this.isCurrentRequest(key, requestId)) this.setValue(key, value);
2491
+ return value;
2492
+ } finally {
2493
+ if (this.inFlight.get(key)?.requestId === requestId) this.inFlight.delete(key);
2494
+ }
2495
+ })();
2496
+ this.inFlight.set(key, {
2497
+ promise: request,
2498
+ requestId
2499
+ });
2500
+ return request;
2501
+ }
2502
+ invalidate(key) {
2503
+ this.entries.delete(key);
2504
+ this.inFlight.delete(key);
2505
+ }
2506
+ clear() {
2507
+ this.entries.clear();
2508
+ this.inFlight.clear();
2509
+ }
2510
+ get size() {
2511
+ return this.entries.size;
2512
+ }
2513
+ isCurrentRequest(key, requestId) {
2514
+ return this.inFlight.get(key)?.requestId === requestId;
2515
+ }
2516
+ markAsRecentlyUsed(key, entry) {
2517
+ this.entries.delete(key);
2518
+ this.entries.set(key, entry);
2519
+ }
2520
+ setValue(key, value) {
2521
+ this.entries.delete(key);
2522
+ this.entries.set(key, {
2523
+ value,
2524
+ expiresAt: this.now() + this.ttlMs
2525
+ });
2526
+ this.evictLeastRecentlyUsed();
2527
+ }
2528
+ evictLeastRecentlyUsed() {
2529
+ while (this.entries.size > this.maxSize) {
2530
+ const oldestKey = this.entries.keys().next().value;
2531
+ if (oldestKey === void 0) return;
2532
+ this.entries.delete(oldestKey);
2533
+ }
2534
+ }
2535
+ };
2536
+ //#endregion
2537
+ //#region src/utils/exercise-template-catalog.ts
2538
+ const EXERCISE_TEMPLATE_CATALOG_CACHE_KEY = "exercise-template-catalog";
2539
+ const EXERCISE_TEMPLATE_CATALOG_CACHE_TTL_MS = 300 * 1e3;
2540
+ const EXERCISE_TEMPLATE_CATALOG_CACHE_MAX_SIZE = 1;
2541
+ /** Create a cache owned by one MCP server/request lifecycle. */
2542
+ function createExerciseTemplateCatalog(hevyClient) {
2543
+ const cache = new AsyncTtlCache({
2544
+ ttlMs: EXERCISE_TEMPLATE_CATALOG_CACHE_TTL_MS,
2545
+ maxSize: EXERCISE_TEMPLATE_CATALOG_CACHE_MAX_SIZE
2084
2546
  });
2085
- registerFolderTools(server, hevyClient, wrapHandler);
2086
- registerBodyMeasurementTools(server, hevyClient, wrapHandler);
2087
- registerUserTools(server, hevyClient, wrapHandler);
2547
+ return {
2548
+ get(options = {}) {
2549
+ const reason = options.refresh ? "explicit-refresh" : cache.size === 0 ? "initial-load" : "ttl-expired";
2550
+ return cache.getOrFetch(EXERCISE_TEMPLATE_CATALOG_CACHE_KEY, async () => {
2551
+ const catalog = await fetchAllPages(async (page, pageSize) => {
2552
+ const data = await hevyClient.getExerciseTemplates({
2553
+ page,
2554
+ pageSize
2555
+ });
2556
+ return {
2557
+ items: data?.exercise_templates ?? [],
2558
+ pageCount: data?.page_count
2559
+ };
2560
+ }, 100);
2561
+ options.onRefreshed?.(catalog, reason);
2562
+ return catalog;
2563
+ }, options);
2564
+ },
2565
+ reset() {
2566
+ cache.clear();
2567
+ }
2568
+ };
2088
2569
  }
2089
2570
  //#endregion
2090
2571
  //#region src/generated/.kubb/fetch.ts
@@ -2709,9 +3190,6 @@ const SAFE_DYNAMIC_ENDPOINTS = [
2709
3190
  ["/v1/routines/", "/v1/routines/:routineId"],
2710
3191
  ["/v1/workouts/", "/v1/workouts/:workoutId"]
2711
3192
  ];
2712
- function wrapApi(fn) {
2713
- return fn;
2714
- }
2715
3193
  function normalizePositiveInteger(value, fallback) {
2716
3194
  return value === void 0 || !Number.isFinite(value) || value <= 0 ? fallback : Math.max(1, Math.floor(value));
2717
3195
  }
@@ -2910,28 +3388,28 @@ function createClient$1(apiKey, baseUrl = "https://api.hevyapp.com", options = {
2910
3388
  const headers = { "api-key": apiKey };
2911
3389
  const client = createNativeClient(apiKey, baseUrl, options);
2912
3390
  return {
2913
- getWorkouts: (params) => wrapApi(getV1Workouts)(headers, params, { client }),
2914
- getWorkout: (workoutId) => wrapApi(getV1WorkoutsWorkoutid)(workoutId, headers, { client }),
2915
- createWorkout: (data) => wrapApi(postV1Workouts)(data, headers, { client }),
2916
- updateWorkout: (workoutId, data) => wrapApi(putV1WorkoutsWorkoutid)(workoutId, data, headers, { client }),
2917
- getWorkoutCount: () => wrapApi(getV1WorkoutsCount)(headers, { client }),
2918
- getWorkoutEvents: (params) => wrapApi(getV1WorkoutsEvents)(headers, params, { client }),
2919
- getRoutines: (params) => wrapApi(getV1Routines)(headers, params, { client }),
2920
- getRoutineById: (routineId) => wrapApi(getV1RoutinesRoutineid)(routineId, headers, { client }),
2921
- createRoutine: (data) => wrapApi(postV1Routines)(data, headers, { client }),
2922
- updateRoutine: (routineId, data) => wrapApi(putV1RoutinesRoutineid)(routineId, data, headers, { client }),
2923
- getExerciseTemplates: (params) => wrapApi(getV1ExerciseTemplates)(headers, params, { client }),
2924
- getExerciseTemplate: (templateId) => wrapApi(getV1ExerciseTemplatesExercisetemplateid)(templateId, headers, { client }),
2925
- getExerciseHistory: (exerciseTemplateId, params) => wrapApi(getV1ExerciseHistoryExercisetemplateid)(exerciseTemplateId, headers, params, { client }),
2926
- createExerciseTemplate: (data) => wrapApi(postV1ExerciseTemplates)(data, headers, { client }),
2927
- getRoutineFolders: (params) => wrapApi(getV1RoutineFolders)(headers, params, { client }),
2928
- createRoutineFolder: (data) => wrapApi(postV1RoutineFolders)(data, headers, { client }),
2929
- getRoutineFolder: (folderId) => wrapApi(getV1RoutineFoldersFolderid)(folderId, headers, { client }),
2930
- getBodyMeasurements: (params) => wrapApi(getV1BodyMeasurements)(headers, params, { client }),
2931
- getBodyMeasurement: (date) => wrapApi(getV1BodyMeasurementsDate)(date, headers, { client }),
2932
- createBodyMeasurement: (data) => wrapApi(postV1BodyMeasurements)(data, headers, { client }),
2933
- updateBodyMeasurement: (date, data) => wrapApi(putV1BodyMeasurementsDate)(date, data, headers, { client }),
2934
- getUserInfo: (config = {}) => wrapApi(getV1UserInfo)(headers, {
3391
+ getWorkouts: (params) => getV1Workouts(headers, params, { client }),
3392
+ getWorkout: (workoutId) => getV1WorkoutsWorkoutid(workoutId, headers, { client }),
3393
+ createWorkout: (data) => postV1Workouts(data, headers, { client }),
3394
+ updateWorkout: (workoutId, data) => putV1WorkoutsWorkoutid(workoutId, data, headers, { client }),
3395
+ getWorkoutCount: () => getV1WorkoutsCount(headers, { client }),
3396
+ getWorkoutEvents: (params) => getV1WorkoutsEvents(headers, params, { client }),
3397
+ getRoutines: (params) => getV1Routines(headers, params, { client }),
3398
+ getRoutineById: (routineId) => getV1RoutinesRoutineid(routineId, headers, { client }),
3399
+ createRoutine: (data) => postV1Routines(data, headers, { client }),
3400
+ updateRoutine: (routineId, data) => putV1RoutinesRoutineid(routineId, data, headers, { client }),
3401
+ getExerciseTemplates: (params) => getV1ExerciseTemplates(headers, params, { client }),
3402
+ getExerciseTemplate: (templateId) => getV1ExerciseTemplatesExercisetemplateid(templateId, headers, { client }),
3403
+ getExerciseHistory: (exerciseTemplateId, params) => getV1ExerciseHistoryExercisetemplateid(exerciseTemplateId, headers, params, { client }),
3404
+ createExerciseTemplate: (data) => postV1ExerciseTemplates(data, headers, { client }),
3405
+ getRoutineFolders: (params) => getV1RoutineFolders(headers, params, { client }),
3406
+ createRoutineFolder: (data) => postV1RoutineFolders(data, headers, { client }),
3407
+ getRoutineFolder: (folderId) => getV1RoutineFoldersFolderid(folderId, headers, { client }),
3408
+ getBodyMeasurements: (params) => getV1BodyMeasurements(headers, params, { client }),
3409
+ getBodyMeasurement: (date) => getV1BodyMeasurementsDate(date, headers, { client }),
3410
+ createBodyMeasurement: (data) => postV1BodyMeasurements(data, headers, { client }),
3411
+ updateBodyMeasurement: (date, data) => putV1BodyMeasurementsDate(date, data, headers, { client }),
3412
+ getUserInfo: (config = {}) => getV1UserInfo(headers, {
2935
3413
  ...config,
2936
3414
  client
2937
3415
  })
@@ -2966,6 +3444,17 @@ function createMcpClientLogger(server) {
2966
3444
  };
2967
3445
  }
2968
3446
  //#endregion
3447
+ //#region src/tools/tool-runtime.ts
3448
+ function createToolRuntime({ client, catalog, logger, wrapHandler = withErrorHandling }) {
3449
+ return {
3450
+ client,
3451
+ catalog,
3452
+ logger,
3453
+ wrapHandler,
3454
+ getClient: () => requireClient(client)
3455
+ };
3456
+ }
3457
+ //#endregion
2969
3458
  //#region src/shared-server.ts
2970
3459
  function createToolCountingServer(server) {
2971
3460
  let count = 0;
@@ -3004,17 +3493,17 @@ function createSharedMcpServer(options) {
3004
3493
  ...options.clientOptions,
3005
3494
  logger
3006
3495
  });
3007
- const wrapHandler = options.wrapHandler ?? withErrorHandling;
3008
- const catalog = createExerciseTemplateCatalog();
3009
- const counting = createToolCountingServer(server);
3010
- registerHevyTools(counting.server, hevyClient, {
3011
- catalog,
3496
+ const runtime = createToolRuntime({
3497
+ client: hevyClient,
3498
+ catalog: createExerciseTemplateCatalog(hevyClient),
3012
3499
  logger,
3013
- wrapHandler
3500
+ wrapHandler: options.wrapHandler ?? withErrorHandling
3014
3501
  });
3502
+ const counting = createToolCountingServer(server);
3503
+ registerHevyTools(counting.server, runtime);
3015
3504
  options.onToolsRegistered?.(counting.getCount());
3016
3505
  registerWorkoutPrompts(server);
3017
- registerHevyResources(server, hevyClient, catalog);
3506
+ registerHevyResources(server, runtime);
3018
3507
  return server;
3019
3508
  }
3020
3509
  //#endregion
@@ -3264,6 +3753,28 @@ function extractSafeArgs(args) {
3264
3753
  }
3265
3754
  return attributes;
3266
3755
  }
3756
+ function isRecord(value) {
3757
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3758
+ }
3759
+ function getWorkflowTelemetry(result) {
3760
+ const structuredContent = result.structuredContent;
3761
+ if (!isRecord(structuredContent)) return null;
3762
+ const workflow = structuredContent.workflow;
3763
+ if (!isRecord(workflow)) return null;
3764
+ const name = workflow.name;
3765
+ const paginationValue = workflow.pagination;
3766
+ const cacheStatus = workflow.cacheStatus;
3767
+ const itemsScanned = workflow.itemsScanned;
3768
+ if (typeof name !== "string" || !isRecord(paginationValue) || typeof cacheStatus !== "string" || typeof itemsScanned !== "number" || !Number.isSafeInteger(itemsScanned) || itemsScanned < 0) return null;
3769
+ const pagination = {};
3770
+ for (const [key, value] of Object.entries(paginationValue)) if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) pagination[key] = value;
3771
+ return {
3772
+ name,
3773
+ pagination,
3774
+ cacheStatus,
3775
+ itemsScanned
3776
+ };
3777
+ }
3267
3778
  /**
3268
3779
  * Wrap an MCP tool handler with its existing OpenTelemetry span and metrics.
3269
3780
  */
@@ -3283,6 +3794,7 @@ function withTelemetry(fn, context) {
3283
3794
  const whitelistedKeys = Object.keys(safeArgs).map((key) => key.replace("mcp.tool.args.", ""));
3284
3795
  return tracer.startActiveSpan(`mcp.tool.${context}`, { attributes: {
3285
3796
  "mcp.tool.name": context,
3797
+ "workflow.name": context,
3286
3798
  "mcp.tool.args.key_count": argumentKeyCount,
3287
3799
  "mcp.tool.args.keys": whitelistedKeys.join(","),
3288
3800
  ...userId ? { "user.id": userId } : {},
@@ -3298,11 +3810,19 @@ function withTelemetry(fn, context) {
3298
3810
  const textLength = result.content.reduce((sum, item) => sum + (item.text?.length ?? 0), 0);
3299
3811
  span.setAttribute("mcp.tool.result.text_length", textLength);
3300
3812
  }
3813
+ const workflow = getWorkflowTelemetry(result);
3814
+ if (workflow) {
3815
+ span.setAttribute("workflow.name", workflow.name);
3816
+ span.setAttribute("workflow.cache_status", workflow.cacheStatus);
3817
+ span.setAttribute("workflow.items_scanned", workflow.itemsScanned);
3818
+ for (const [resource, pageCount] of Object.entries(workflow.pagination)) span.setAttribute(`workflow.pagination.${resource}.pages`, pageCount);
3819
+ }
3301
3820
  return result;
3302
3821
  } catch (error) {
3303
3822
  isError = true;
3304
3823
  span.setStatus({ code: SpanStatusCode.ERROR });
3305
- const diagnostic = createSafeErrorDiagnostic(error);
3824
+ const policy = resolveErrorPolicy(error, "");
3825
+ const { diagnostic } = policy;
3306
3826
  span.addEvent("mcp.tool.failure", {
3307
3827
  "error.category": diagnostic.category,
3308
3828
  ...diagnostic.code ? { "error.code": diagnostic.code } : {},
@@ -3310,7 +3830,7 @@ function withTelemetry(fn, context) {
3310
3830
  ...diagnostic.method ? { "http.method": diagnostic.method } : {},
3311
3831
  ...diagnostic.endpoint ? { "hevy.api.endpoint": diagnostic.endpoint } : {}
3312
3832
  });
3313
- const errorType = determineErrorType(error, `${diagnostic.category} occurred`);
3833
+ const errorType = policy.type;
3314
3834
  span.setAttribute("error.type", errorType);
3315
3835
  toolErrors.add(1, {
3316
3836
  tool_name: context,
@@ -3334,7 +3854,7 @@ function withTelemetry(fn, context) {
3334
3854
  */
3335
3855
  function withObservability(fn, context) {
3336
3856
  return withErrorHandling(withTelemetry(fn, context), context, (error, _toolContext, argumentKeyCount) => {
3337
- const diagnostic = createSafeErrorDiagnostic(error);
3857
+ const { diagnostic } = resolveErrorPolicy(error, "");
3338
3858
  Sentry.withScope((scope) => {
3339
3859
  scope.setTag("error.category", diagnostic.category);
3340
3860
  if (diagnostic.code) scope.setTag("error.code", diagnostic.code);
@@ -3810,4 +4330,4 @@ async function runServer() {
3810
4330
  //#endregion
3811
4331
  export { createSafeErrorDiagnostic as i, createServer as n, runServer as r, configSchema as t };
3812
4332
 
3813
- //# sourceMappingURL=src-CkkrTrrg.mjs.map
4333
+ //# sourceMappingURL=src-D1O3O9f2.mjs.map