hevy-mcp 3.1.1 → 3.3.0

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