hevy-coach 0.2.0 → 1.0.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +42 -146
  3. package/dist/cli.mjs +16 -0
  4. package/dist/http.mjs +133 -0
  5. package/dist/index.d.mts +6 -0
  6. package/dist/index.mjs +2 -0
  7. package/dist/src-BxQx3e_u.mjs +651 -0
  8. package/package.json +32 -33
  9. package/CHATGPT.md +0 -388
  10. package/GEMINI.md +0 -416
  11. package/dist/cli.d.ts +0 -3
  12. package/dist/cli.d.ts.map +0 -1
  13. package/dist/cli.js +0 -16
  14. package/dist/cli.js.map +0 -1
  15. package/dist/http.d.ts +0 -3
  16. package/dist/http.d.ts.map +0 -1
  17. package/dist/http.js +0 -174
  18. package/dist/http.js.map +0 -1
  19. package/dist/index.d.ts +0 -3
  20. package/dist/index.d.ts.map +0 -1
  21. package/dist/index.js +0 -23
  22. package/dist/index.js.map +0 -1
  23. package/dist/tools/body-measurements.d.ts +0 -4
  24. package/dist/tools/body-measurements.d.ts.map +0 -1
  25. package/dist/tools/body-measurements.js +0 -88
  26. package/dist/tools/body-measurements.js.map +0 -1
  27. package/dist/tools/coaching.d.ts +0 -4
  28. package/dist/tools/coaching.d.ts.map +0 -1
  29. package/dist/tools/coaching.js +0 -353
  30. package/dist/tools/coaching.js.map +0 -1
  31. package/dist/tools/exercises.d.ts +0 -4
  32. package/dist/tools/exercises.d.ts.map +0 -1
  33. package/dist/tools/exercises.js +0 -121
  34. package/dist/tools/exercises.js.map +0 -1
  35. package/dist/tools/routines.d.ts +0 -4
  36. package/dist/tools/routines.d.ts.map +0 -1
  37. package/dist/tools/routines.js +0 -150
  38. package/dist/tools/routines.js.map +0 -1
  39. package/dist/tools/users.d.ts +0 -4
  40. package/dist/tools/users.d.ts.map +0 -1
  41. package/dist/tools/users.js +0 -16
  42. package/dist/tools/users.js.map +0 -1
  43. package/dist/tools/workouts.d.ts +0 -4
  44. package/dist/tools/workouts.d.ts.map +0 -1
  45. package/dist/tools/workouts.js +0 -134
  46. package/dist/tools/workouts.js.map +0 -1
  47. package/dist/utils/hevy-client.d.ts +0 -212
  48. package/dist/utils/hevy-client.d.ts.map +0 -1
  49. package/dist/utils/hevy-client.js +0 -178
  50. package/dist/utils/hevy-client.js.map +0 -1
  51. package/dist/utils/response.d.ts +0 -21
  52. package/dist/utils/response.d.ts.map +0 -1
  53. package/dist/utils/response.js +0 -22
  54. package/dist/utils/response.js.map +0 -1
@@ -0,0 +1,651 @@
1
+ import { HevyApiError, HevyNetworkError, MAX_PAGE_SIZE, MAX_TEMPLATE_PAGE_SIZE, createHevyClient } from "@furkantanyol/hevy-client";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { z } from "zod";
4
+ //#region src/utils/response.ts
5
+ function jsonResponse(data) {
6
+ return { content: [{
7
+ type: "text",
8
+ text: JSON.stringify(data, null, 2)
9
+ }] };
10
+ }
11
+ function textResponse(text) {
12
+ return { content: [{
13
+ type: "text",
14
+ text
15
+ }] };
16
+ }
17
+ function errorResponse(error) {
18
+ return {
19
+ content: [{
20
+ type: "text",
21
+ text: `Error: ${describeError(error)}`
22
+ }],
23
+ isError: true
24
+ };
25
+ }
26
+ /** Wraps a tool handler so any thrown error becomes an MCP error result the LLM can read. */
27
+ function safe(handler) {
28
+ return async (args) => {
29
+ try {
30
+ return await handler(args);
31
+ } catch (error) {
32
+ return errorResponse(error);
33
+ }
34
+ };
35
+ }
36
+ function describeError(error) {
37
+ if (error instanceof HevyApiError) {
38
+ const code = error.code ? ` (${error.code})` : "";
39
+ return `Hevy API ${error.status}${code}: ${error.message}`;
40
+ }
41
+ if (error instanceof HevyNetworkError) return `Could not reach Hevy: ${error.message}`;
42
+ return error instanceof Error ? error.message : String(error);
43
+ }
44
+ //#endregion
45
+ //#region src/tools/schemas.ts
46
+ const DEFAULT_PAGE_SIZE = 5;
47
+ const SET_TYPES = [
48
+ "warmup",
49
+ "normal",
50
+ "failure",
51
+ "dropset"
52
+ ];
53
+ const RPE_VALUES = [
54
+ 6,
55
+ 7,
56
+ 7.5,
57
+ 8,
58
+ 8.5,
59
+ 9,
60
+ 9.5,
61
+ 10
62
+ ];
63
+ const pageSchema = {
64
+ page: z.coerce.number().int().gte(1).default(1).describe("1-based page number"),
65
+ pageSize: z.coerce.number().int().gte(1).lte(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE).describe(`Items per page, max ${MAX_PAGE_SIZE} (Hevy limit)`)
66
+ };
67
+ const templatePageSchema = {
68
+ page: z.coerce.number().int().gte(1).default(1),
69
+ pageSize: z.coerce.number().int().gte(1).lte(MAX_TEMPLATE_PAGE_SIZE).default(MAX_TEMPLATE_PAGE_SIZE)
70
+ };
71
+ const isoDateTime = (label) => z.string().min(1).describe(`${label} as ISO 8601, e.g. 2026-09-09T18:30:00Z`);
72
+ const ymdDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD").describe("Calendar date, YYYY-MM-DD");
73
+ const nullableNumber = z.coerce.number().nullable().optional();
74
+ const nullableInt = z.coerce.number().int().nullable().optional();
75
+ const setFields = {
76
+ type: z.enum(SET_TYPES).default("normal"),
77
+ weight_kg: nullableNumber.describe("Weight in kg (metric only)"),
78
+ reps: nullableInt,
79
+ distance_meters: nullableInt,
80
+ duration_seconds: nullableInt,
81
+ custom_metric: nullableNumber.describe("Floors or steps for stair machines")
82
+ };
83
+ const workoutSetSchema = z.object({
84
+ ...setFields,
85
+ rpe: z.literal(RPE_VALUES).nullable().optional().describe("Rate of perceived exertion, 6 to 10 in half steps")
86
+ });
87
+ const routineSetSchema = z.object({
88
+ ...setFields,
89
+ rep_range: z.object({
90
+ start: z.coerce.number().int(),
91
+ end: z.coerce.number().int()
92
+ }).nullable().optional().describe("Target rep range, e.g. {start: 8, end: 12}")
93
+ });
94
+ const exerciseTemplateId = z.string().min(1).describe("Hevy exercise template id, e.g. \"D04AC939\". Resolve names with batch-find-exercises first.");
95
+ const workoutExerciseSchema = z.object({
96
+ exercise_template_id: exerciseTemplateId,
97
+ superset_id: nullableInt.describe("Same integer on every exercise in one superset; null otherwise"),
98
+ notes: z.string().optional(),
99
+ sets: z.array(workoutSetSchema).min(1)
100
+ });
101
+ const routineExerciseSchema = z.object({
102
+ exercise_template_id: exerciseTemplateId,
103
+ superset_id: nullableInt.describe("Same integer on every exercise in one superset; null otherwise"),
104
+ rest_seconds: nullableInt.describe("Rest between sets in seconds"),
105
+ notes: z.string().optional().describe("Coaching cue shown in the app"),
106
+ sets: z.array(routineSetSchema).min(1)
107
+ });
108
+ //#endregion
109
+ //#region src/tools/body-measurements.ts
110
+ const metric = z.coerce.number().nullable().optional();
111
+ const measurementFields = {
112
+ weight_kg: metric,
113
+ lean_mass_kg: metric,
114
+ fat_percent: metric,
115
+ neck_cm: metric,
116
+ shoulder_cm: metric,
117
+ chest_cm: metric,
118
+ left_bicep_cm: metric,
119
+ right_bicep_cm: metric,
120
+ left_forearm_cm: metric,
121
+ right_forearm_cm: metric,
122
+ abdomen: metric.describe("cm"),
123
+ waist: metric.describe("cm"),
124
+ hips: metric.describe("cm"),
125
+ left_thigh: metric.describe("cm"),
126
+ right_thigh: metric.describe("cm"),
127
+ left_calf: metric.describe("cm"),
128
+ right_calf: metric.describe("cm")
129
+ };
130
+ function registerBodyMeasurementTools(server, client) {
131
+ server.registerTool("get-body-measurements", {
132
+ description: "List body measurements (weight, body fat %, circumferences in cm), newest first.",
133
+ inputSchema: pageSchema
134
+ }, safe(async ({ page, pageSize }) => {
135
+ const result = await client.bodyMeasurements.list({
136
+ page,
137
+ pageSize
138
+ });
139
+ return result.body_measurements.length ? jsonResponse(result) : textResponse("No body measurements found.");
140
+ }));
141
+ server.registerTool("get-body-measurement", {
142
+ description: "Get the body measurement logged on one date.",
143
+ inputSchema: { date: ymdDate }
144
+ }, safe(async ({ date }) => jsonResponse(await client.bodyMeasurements.get(date))));
145
+ server.registerTool("log-body-measurement", {
146
+ description: "Log or overwrite the body measurement for a date. Provide any subset of fields; kg and cm only. Overwriting replaces every field for that date, so include all values you want kept.",
147
+ inputSchema: {
148
+ date: ymdDate,
149
+ ...measurementFields
150
+ }
151
+ }, safe(async (measurement) => {
152
+ await client.bodyMeasurements.upsert(measurement);
153
+ return textResponse(`Body measurement saved for ${measurement.date}.`);
154
+ }));
155
+ }
156
+ //#endregion
157
+ //#region src/tools/coaching.ts
158
+ const MAX_SUMMARY_WORKOUTS = 50;
159
+ const MAX_PROGRESSION_SESSIONS = 20;
160
+ const TOP_EXERCISES = 15;
161
+ const MS_PER_MINUTE = 6e4;
162
+ const MS_PER_DAY = 864e5;
163
+ const DAYS_PER_WEEK = 7;
164
+ const EPLEY_DIVISOR = 30;
165
+ const LIBRARY_CACHE_TTL_MS = 5 * MS_PER_MINUTE;
166
+ const RPE_INCREASE = 7;
167
+ const RPE_HOLD = 8;
168
+ const RPE_DECREASE = 9.5;
169
+ const round1 = (n) => Math.round(n * 10) / 10;
170
+ const epley1RM = (weightKg, reps) => weightKg * (1 + reps / EPLEY_DIVISOR);
171
+ const isWorking = (set) => set.type !== "warmup";
172
+ function registerCoachingTools(server, client) {
173
+ server.registerTool("analyze-workout", {
174
+ description: `Compare a completed workout with the planned session and get per-exercise adaptation recommendations
175
+ (increase_weight / maintain_then_increase / hold_weight / decrease_weight) based on reps hit and average RPE.
176
+ Call after each workout to drive progressive overload. Exercise names must match Hevy titles.`,
177
+ inputSchema: {
178
+ workoutId: z.string().min(1).describe("Id of the completed workout"),
179
+ plannedExercises: z.array(z.object({
180
+ name: z.string().describe("Exercise title exactly as in Hevy"),
181
+ targetSets: z.number().int(),
182
+ targetReps: z.number().int(),
183
+ targetWeightKg: z.number().nullable(),
184
+ targetRpe: z.number().nullable()
185
+ })).describe("The planned exercises for this session")
186
+ }
187
+ }, safe(async ({ workoutId, plannedExercises }) => jsonResponse(analyzeWorkoutPerformance(await client.workouts.get(workoutId), plannedExercises))));
188
+ server.registerTool("get-training-summary", {
189
+ description: `Aggregate the last N workouts: sessions, working sets, average duration, days between sessions,
190
+ sessions per week and the most frequent exercises with set counts. Use for weekly reviews and volume checks.`,
191
+ inputSchema: { workoutCount: z.coerce.number().int().gte(1).lte(MAX_SUMMARY_WORKOUTS).default(10).describe("How many recent workouts to include") }
192
+ }, safe(async ({ workoutCount }) => {
193
+ const workouts = await recentWorkouts(client, workoutCount);
194
+ return workouts.length ? jsonResponse(generateTrainingSummary(workouts)) : textResponse("No workouts found.");
195
+ }));
196
+ server.registerTool("get-exercise-progression", {
197
+ description: `Session-by-session progression for one exercise: max weight, max reps, volume, estimated 1RM (Epley),
198
+ best set, all-time best and trend (improving / plateau / declining). Use to spot PRs and plateaus.`,
199
+ inputSchema: {
200
+ exerciseTemplateId,
201
+ sessions: z.coerce.number().int().gte(1).lte(MAX_PROGRESSION_SESSIONS).default(10).describe("Most recent sessions to include")
202
+ }
203
+ }, safe(async ({ exerciseTemplateId, sessions }) => {
204
+ const history = await client.exerciseHistory.get(exerciseTemplateId);
205
+ if (!history.length) return textResponse("No history found for this exercise.");
206
+ return jsonResponse(analyzeExerciseProgression(history, sessions));
207
+ }));
208
+ server.registerTool("find-exercise", {
209
+ description: "Search the exercise library by name or muscle group and get id + title for every match, shortest title first. For 2+ names use batch-find-exercises.",
210
+ inputSchema: { query: z.string().min(1).describe("Exercise name or muscle group, e.g. 'incline press' or 'hamstrings'") }
211
+ }, safe(async ({ query }) => {
212
+ const matches = searchExercises(await loadLibrary(client), query);
213
+ if (!matches.length) return textResponse(`No exercises found matching "${query}".`);
214
+ return jsonResponse({
215
+ query,
216
+ matches: matches.length,
217
+ exercises: matches
218
+ });
219
+ }));
220
+ server.registerTool("batch-find-exercises", {
221
+ description: `Resolve many exercise names to Hevy template ids in ONE call. Exact title match wins, otherwise the shortest title containing the query.
222
+ Returns {found, total, missing, results: {name: {id, title} | null}}. Always use this before create-routine or create-workout.`,
223
+ inputSchema: { queries: z.array(z.string().min(1)).min(1).describe("Exercise names to look up") }
224
+ }, safe(async ({ queries }) => {
225
+ const library = await loadLibrary(client);
226
+ const results = Object.fromEntries(queries.map((q) => [q, findBestMatch(library, q)]));
227
+ const missing = queries.filter((q) => !results[q]);
228
+ return jsonResponse({
229
+ found: queries.length - missing.length,
230
+ total: queries.length,
231
+ missing,
232
+ results
233
+ });
234
+ }));
235
+ }
236
+ let libraryCache;
237
+ /** Call after creating a custom exercise so lookups see it immediately. */
238
+ function invalidateLibrary() {
239
+ libraryCache = void 0;
240
+ }
241
+ async function loadLibrary(client) {
242
+ if (libraryCache && libraryCache.expires > Date.now()) return libraryCache.templates;
243
+ const templates = await client.exerciseTemplates.listAll();
244
+ libraryCache = {
245
+ expires: Date.now() + LIBRARY_CACHE_TTL_MS,
246
+ templates
247
+ };
248
+ return templates;
249
+ }
250
+ const normalise = (text) => text.toLowerCase().replace(/[\s_]+/g, " ").trim();
251
+ const byTitleLength = (a, b) => a.title.length - b.title.length;
252
+ /** Exact normalised title match first; otherwise the shortest title containing the query. */
253
+ function findBestMatch(library, query) {
254
+ const q = normalise(query);
255
+ const exact = library.find((t) => normalise(t.title) === q);
256
+ if (exact) return {
257
+ id: exact.id,
258
+ title: exact.title
259
+ };
260
+ const partial = library.filter((t) => normalise(t.title).includes(q)).sort(byTitleLength)[0];
261
+ return partial ? {
262
+ id: partial.id,
263
+ title: partial.title
264
+ } : null;
265
+ }
266
+ function searchExercises(library, query) {
267
+ const q = normalise(query);
268
+ return library.filter((t) => normalise(t.title).includes(q) || normalise(t.primary_muscle_group).includes(q) || t.secondary_muscle_groups.some((m) => normalise(m).includes(q))).sort(byTitleLength).map((t) => ({
269
+ id: t.id,
270
+ title: t.title
271
+ }));
272
+ }
273
+ async function recentWorkouts(client, count) {
274
+ const workouts = [];
275
+ for (let page = 1; workouts.length < count; page++) {
276
+ const result = await client.workouts.list({
277
+ page,
278
+ pageSize: MAX_PAGE_SIZE
279
+ });
280
+ workouts.push(...result.workouts);
281
+ if (page >= result.page_count) break;
282
+ }
283
+ return workouts.slice(0, count);
284
+ }
285
+ /** "All reps hit" means every working set reached the target, so the weakest set decides. */
286
+ function recommend(plan, minReps, sets, avgRpe) {
287
+ if (!plan) return "maintain";
288
+ const hitReps = minReps >= plan.targetReps;
289
+ const hitSets = sets >= plan.targetSets;
290
+ if (hitReps && hitSets && avgRpe !== null && avgRpe <= RPE_INCREASE) return "increase_weight";
291
+ if (hitReps && hitSets && avgRpe !== null && avgRpe <= RPE_HOLD) return "maintain_then_increase";
292
+ if (!hitReps && avgRpe !== null && avgRpe >= RPE_DECREASE) return "decrease_weight";
293
+ if (!hitReps) return "hold_weight";
294
+ return "maintain";
295
+ }
296
+ function analyzeWorkoutPerformance(workout, planned) {
297
+ const sameName = (a, b) => a.toLowerCase() === b.toLowerCase();
298
+ const exerciseResults = workout.exercises.map((actual) => {
299
+ const plan = planned.find((p) => sameName(p.name, actual.title));
300
+ const workingSets = actual.sets.filter(isWorking);
301
+ const rated = workingSets.filter((s) => s.rpe !== null);
302
+ const totalReps = workingSets.reduce((sum, s) => sum + (s.reps ?? 0), 0);
303
+ const avgRpe = rated.length ? rated.reduce((sum, s) => sum + (s.rpe ?? 0), 0) / rated.length : null;
304
+ const repsPerSet = workingSets.length ? totalReps / workingSets.length : 0;
305
+ const minReps = workingSets.length ? Math.min(...workingSets.map((s) => s.reps ?? 0)) : 0;
306
+ return {
307
+ exercise: actual.title,
308
+ planned: plan ? {
309
+ sets: plan.targetSets,
310
+ reps: plan.targetReps,
311
+ weightKg: plan.targetWeightKg,
312
+ rpe: plan.targetRpe
313
+ } : null,
314
+ actual: {
315
+ setsCompleted: workingSets.length,
316
+ avgRepsPerSet: round1(repsPerSet),
317
+ minReps,
318
+ totalReps,
319
+ maxWeightKg: Math.max(0, ...workingSets.map((s) => s.weight_kg ?? 0)),
320
+ avgRpe: avgRpe === null ? null : round1(avgRpe)
321
+ },
322
+ recommendation: recommend(plan, minReps, workingSets.length, avgRpe),
323
+ notes: actual.notes
324
+ };
325
+ });
326
+ const skipped = planned.filter((p) => !workout.exercises.some((a) => sameName(a.title, p.name)));
327
+ return {
328
+ workoutId: workout.id,
329
+ title: workout.title,
330
+ date: workout.start_time,
331
+ durationMinutes: Math.round((new Date(workout.end_time).getTime() - new Date(workout.start_time).getTime()) / MS_PER_MINUTE),
332
+ totalWorkingSets: workout.exercises.reduce((sum, ex) => sum + ex.sets.filter(isWorking).length, 0),
333
+ exerciseResults,
334
+ skippedExercises: skipped.map((e) => e.name),
335
+ overallAdherence: planned.length ? Math.round((planned.length - skipped.length) / planned.length * 100) : 100
336
+ };
337
+ }
338
+ function generateTrainingSummary(workouts) {
339
+ const dates = workouts.map((w) => new Date(w.start_time).getTime()).sort((a, b) => a - b);
340
+ const gaps = dates.slice(1).map((t, i) => (t - (dates[i] ?? t)) / MS_PER_DAY);
341
+ const avgDaysBetween = gaps.length ? round1(gaps.reduce((a, b) => a + b, 0) / gaps.length) : 0;
342
+ const durations = workouts.map((w) => (new Date(w.end_time).getTime() - new Date(w.start_time).getTime()) / MS_PER_MINUTE);
343
+ const sessionsByExercise = /* @__PURE__ */ new Map();
344
+ for (const workout of workouts) {
345
+ const seenInWorkout = /* @__PURE__ */ new Set();
346
+ for (const exercise of workout.exercises) {
347
+ const entry = sessionsByExercise.get(exercise.title) ?? {
348
+ sessions: 0,
349
+ totalSets: 0
350
+ };
351
+ if (!seenInWorkout.has(exercise.title)) entry.sessions++;
352
+ seenInWorkout.add(exercise.title);
353
+ entry.totalSets += exercise.sets.filter(isWorking).length;
354
+ sessionsByExercise.set(exercise.title, entry);
355
+ }
356
+ }
357
+ const topExercises = [...sessionsByExercise.entries()].sort(([, a], [, b]) => b.sessions - a.sessions).slice(0, TOP_EXERCISES).map(([name, stats]) => ({
358
+ name,
359
+ ...stats
360
+ }));
361
+ const isoDay = (ms) => ms === void 0 ? void 0 : new Date(ms).toISOString().slice(0, 10);
362
+ return {
363
+ period: {
364
+ from: isoDay(dates[0]),
365
+ to: isoDay(dates[dates.length - 1])
366
+ },
367
+ totalSessions: workouts.length,
368
+ totalWorkingSets: [...sessionsByExercise.values()].reduce((sum, e) => sum + e.totalSets, 0),
369
+ avgSessionDurationMinutes: durations.length ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0,
370
+ avgDaysBetweenSessions: avgDaysBetween,
371
+ sessionsPerWeek: avgDaysBetween > 0 ? round1(DAYS_PER_WEEK / avgDaysBetween) : 0,
372
+ topExercises
373
+ };
374
+ }
375
+ function summariseSession(workingSets) {
376
+ const first = workingSets[0];
377
+ const bestSet = workingSets.reduce((best, s) => {
378
+ const e1rm = epley1RM(s.weight_kg ?? 0, s.reps ?? 0);
379
+ return e1rm > best.e1rm ? {
380
+ weight: s.weight_kg ?? 0,
381
+ reps: s.reps ?? 0,
382
+ e1rm
383
+ } : best;
384
+ }, {
385
+ weight: first.weight_kg ?? 0,
386
+ reps: first.reps ?? 0,
387
+ e1rm: epley1RM(first.weight_kg ?? 0, first.reps ?? 0)
388
+ });
389
+ return {
390
+ date: first.workout_start_time.slice(0, 10),
391
+ workingSets: workingSets.length,
392
+ maxWeightKg: Math.max(...workingSets.map((s) => s.weight_kg ?? 0)),
393
+ maxReps: Math.max(...workingSets.map((s) => s.reps ?? 0)),
394
+ totalVolume: Math.round(workingSets.reduce((sum, s) => sum + (s.weight_kg ?? 0) * (s.reps ?? 0), 0)),
395
+ estimated1RM: round1(bestSet.e1rm),
396
+ bestSet: {
397
+ weightKg: bestSet.weight,
398
+ reps: bestSet.reps
399
+ }
400
+ };
401
+ }
402
+ /**
403
+ * History is flat (one row per set). Groups by workout, oldest first, and reports the last `limit`
404
+ * sessions. Weighted exercises trend on estimated 1RM; bodyweight exercises (no load) on max reps.
405
+ */
406
+ function analyzeExerciseProgression(history, limit = Number.POSITIVE_INFINITY) {
407
+ const byWorkout = /* @__PURE__ */ new Map();
408
+ for (const entry of history) {
409
+ if (entry.set_type === "warmup") continue;
410
+ const sets = byWorkout.get(entry.workout_id) ?? [];
411
+ sets.push(entry);
412
+ byWorkout.set(entry.workout_id, sets);
413
+ }
414
+ const allSessions = [...byWorkout.values()].filter((sets) => sets.length > 0).sort((a, b) => a[0].workout_start_time.localeCompare(b[0].workout_start_time)).map(summariseSession);
415
+ const sessions = allSessions.slice(-limit);
416
+ const trendMetric = allSessions.some((s) => s.estimated1RM > 0) ? "estimated1RM" : "maxReps";
417
+ const latest = sessions[sessions.length - 1]?.[trendMetric] ?? 0;
418
+ const earliest = sessions[0]?.[trendMetric] ?? 0;
419
+ return {
420
+ sessionCount: sessions.length,
421
+ sessions,
422
+ allTimeBestE1RM: Math.max(0, ...allSessions.map((s) => s.estimated1RM)),
423
+ trendMetric,
424
+ progression: round1(latest - earliest),
425
+ trend: latest > earliest ? "improving" : latest === earliest ? "plateau" : "declining"
426
+ };
427
+ }
428
+ //#endregion
429
+ //#region src/tools/exercises.ts
430
+ const EXERCISE_TYPES = [
431
+ "weight_reps",
432
+ "reps_only",
433
+ "bodyweight_reps",
434
+ "bodyweight_assisted_reps",
435
+ "duration",
436
+ "weight_duration",
437
+ "distance_duration",
438
+ "short_distance_weight"
439
+ ];
440
+ const EQUIPMENT = [
441
+ "none",
442
+ "barbell",
443
+ "dumbbell",
444
+ "kettlebell",
445
+ "machine",
446
+ "plate",
447
+ "resistance_band",
448
+ "suspension",
449
+ "other"
450
+ ];
451
+ const MUSCLE_GROUPS = [
452
+ "abdominals",
453
+ "shoulders",
454
+ "biceps",
455
+ "triceps",
456
+ "forearms",
457
+ "quadriceps",
458
+ "hamstrings",
459
+ "calves",
460
+ "glutes",
461
+ "abductors",
462
+ "adductors",
463
+ "lats",
464
+ "upper_back",
465
+ "traps",
466
+ "lower_back",
467
+ "chest",
468
+ "cardio",
469
+ "neck",
470
+ "full_body",
471
+ "other"
472
+ ];
473
+ function registerExerciseTools(server, client) {
474
+ server.registerTool("get-exercise-templates", {
475
+ description: "Browse the exercise library page by page (up to 100 per page): id, title, type, muscle groups, equipment. Prefer batch-find-exercises when you know the names.",
476
+ inputSchema: templatePageSchema
477
+ }, safe(async ({ page, pageSize }) => {
478
+ const result = await client.exerciseTemplates.list({
479
+ page,
480
+ pageSize
481
+ });
482
+ return result.exercise_templates.length ? jsonResponse(result) : textResponse("No exercise templates found.");
483
+ }));
484
+ server.registerTool("get-exercise-template", {
485
+ description: "Get one exercise template by id: title, measurement type, muscle groups, equipment.",
486
+ inputSchema: { exerciseTemplateId }
487
+ }, safe(async ({ exerciseTemplateId }) => jsonResponse(await client.exerciseTemplates.get(exerciseTemplateId))));
488
+ server.registerTool("get-exercise-history", {
489
+ description: "Every logged set for one exercise, newest workout first, flat (one row per set with workout id, date, weight, reps, RPE, set type). Use get-exercise-progression for trends.",
490
+ inputSchema: {
491
+ exerciseTemplateId,
492
+ startDate: z.string().optional().describe("Inclusive start, YYYY-MM-DD or ISO 8601"),
493
+ endDate: z.string().optional().describe("Inclusive end, YYYY-MM-DD or ISO 8601")
494
+ }
495
+ }, safe(async ({ exerciseTemplateId, startDate, endDate }) => jsonResponse(await client.exerciseHistory.get(exerciseTemplateId, {
496
+ startDate,
497
+ endDate
498
+ }))));
499
+ server.registerTool("create-exercise-template", {
500
+ description: "Create a custom exercise in the user's library. Check batch-find-exercises first to avoid duplicating a built-in exercise. The response id is NOT an exercise_template_id: after creating, call batch-find-exercises with the title to get the id to use in routines and workouts.",
501
+ inputSchema: {
502
+ title: z.string().min(1),
503
+ exercise_type: z.enum(EXERCISE_TYPES).describe("How the exercise is measured"),
504
+ muscle_group: z.enum(MUSCLE_GROUPS).describe("Primary muscle group"),
505
+ other_muscles: z.array(z.enum(MUSCLE_GROUPS)).optional().describe("Secondary muscle groups"),
506
+ equipment_category: z.enum(EQUIPMENT)
507
+ }
508
+ }, safe(async (exercise) => {
509
+ const created = await client.exerciseTemplates.create(exercise);
510
+ invalidateLibrary();
511
+ return jsonResponse(created);
512
+ }));
513
+ }
514
+ //#endregion
515
+ //#region src/tools/routines.ts
516
+ const folderId = z.coerce.number().int().nullable().optional().describe("Routine folder id from get-routine-folders; null or omitted for the default 'My Routines'");
517
+ function registerRoutineTools(server, client) {
518
+ server.registerTool("get-routines", {
519
+ description: "List saved routines (workout templates) with their exercises and target sets.",
520
+ inputSchema: pageSchema
521
+ }, safe(async ({ page, pageSize }) => {
522
+ const result = await client.routines.list({
523
+ page,
524
+ pageSize
525
+ });
526
+ return result.routines.length ? jsonResponse(result) : textResponse("No routines found.");
527
+ }));
528
+ server.registerTool("get-routine", {
529
+ description: "Get one routine by id with every exercise and set.",
530
+ inputSchema: { routineId: z.string().min(1) }
531
+ }, safe(async ({ routineId }) => jsonResponse(await client.routines.get(routineId))));
532
+ server.registerTool("create-routine", {
533
+ description: "Create a routine in the user's Hevy app. WORKFLOW: call batch-find-exercises with all exercise names first, then pass the returned ids here. Free Hevy accounts are limited to 4 routines (409 routine-limit-exceeded).",
534
+ inputSchema: {
535
+ title: z.string().min(1),
536
+ folder_id: folderId,
537
+ notes: z.string().optional().describe("Routine-level notes shown in the app"),
538
+ exercises: z.array(routineExerciseSchema).min(1)
539
+ }
540
+ }, safe(async ({ folder_id, ...routine }) => jsonResponse(await client.routines.create({
541
+ ...routine,
542
+ folder_id: folder_id ?? null
543
+ }))));
544
+ server.registerTool("update-routine", {
545
+ description: "Replace a routine by id: title, notes and every exercise and set are overwritten. Omit folder_id to keep the current folder, pass null to move it to 'My Routines'. Fetch it with get-routine first if you only want to change part of it.",
546
+ inputSchema: {
547
+ routineId: z.string().min(1),
548
+ title: z.string().min(1),
549
+ folder_id: folderId,
550
+ notes: z.string().optional(),
551
+ exercises: z.array(routineExerciseSchema).min(1)
552
+ }
553
+ }, safe(async ({ routineId, folder_id, ...routine }) => jsonResponse(await client.routines.update(routineId, {
554
+ ...routine,
555
+ ...folder_id === void 0 ? {} : { folder_id }
556
+ }))));
557
+ server.registerTool("get-routine-folders", {
558
+ description: "List routine folders (e.g. one per training block).",
559
+ inputSchema: pageSchema
560
+ }, safe(async ({ page, pageSize }) => jsonResponse(await client.routineFolders.list({
561
+ page,
562
+ pageSize
563
+ }))));
564
+ server.registerTool("get-routine-folder", {
565
+ description: "Get one routine folder by id.",
566
+ inputSchema: { folderId: z.coerce.number().int().gte(1) }
567
+ }, safe(async ({ folderId }) => jsonResponse(await client.routineFolders.get(folderId))));
568
+ server.registerTool("create-routine-folder", {
569
+ description: "Create a routine folder, e.g. 'Block 2 - Hypertrophy'. New folders appear at the top of the list.",
570
+ inputSchema: { title: z.string().min(1) }
571
+ }, safe(async ({ title }) => jsonResponse(await client.routineFolders.create(title))));
572
+ }
573
+ //#endregion
574
+ //#region src/tools/users.ts
575
+ function registerUserTools(server, client) {
576
+ server.registerTool("get-user-info", {
577
+ description: "Get the authenticated Hevy user's profile: id, display name and public profile URL.",
578
+ inputSchema: {}
579
+ }, safe(async () => jsonResponse(await client.user.info())));
580
+ }
581
+ //#endregion
582
+ //#region src/tools/workouts.ts
583
+ const workoutFields = {
584
+ title: z.string().min(1),
585
+ description: z.string().optional(),
586
+ start_time: isoDateTime("Start time"),
587
+ end_time: isoDateTime("End time"),
588
+ is_private: z.boolean().default(false),
589
+ exercises: z.array(workoutExerciseSchema).min(1)
590
+ };
591
+ function registerWorkoutTools(server, client) {
592
+ server.registerTool("get-workouts", {
593
+ description: "List logged workouts, newest first, with exercises, sets, weights, reps and RPE. Paginated; use get-training-summary for aggregates over many workouts.",
594
+ inputSchema: pageSchema
595
+ }, safe(async ({ page, pageSize }) => {
596
+ const result = await client.workouts.list({
597
+ page,
598
+ pageSize
599
+ });
600
+ return result.workouts.length ? jsonResponse(result) : textResponse("No workouts found.");
601
+ }));
602
+ server.registerTool("get-workout", {
603
+ description: "Get one logged workout by id with every exercise and set.",
604
+ inputSchema: { workoutId: z.string().min(1) }
605
+ }, safe(async ({ workoutId }) => jsonResponse(await client.workouts.get(workoutId))));
606
+ server.registerTool("get-workout-count", {
607
+ description: "Total number of workouts the user has logged.",
608
+ inputSchema: {}
609
+ }, safe(async () => jsonResponse({ workout_count: await client.workouts.count() })));
610
+ server.registerTool("get-workout-events", {
611
+ description: "Workouts changed since an ISO timestamp, newest first, as 'updated' (full workout) or 'deleted' (id only) events. Use for syncing; omit `since` for everything.",
612
+ inputSchema: {
613
+ ...pageSchema,
614
+ since: z.string().optional().describe("ISO 8601 timestamp; defaults to the beginning of time")
615
+ }
616
+ }, safe(async ({ page, pageSize, since }) => jsonResponse(await client.workouts.events({
617
+ page,
618
+ pageSize,
619
+ since
620
+ }))));
621
+ server.registerTool("create-workout", {
622
+ description: "Log a completed workout in Hevy. Needs title, ISO start/end times and exercises with sets. Resolve exercise names to ids with batch-find-exercises first.",
623
+ inputSchema: workoutFields
624
+ }, safe(async (workout) => jsonResponse(await client.workouts.create(workout))));
625
+ server.registerTool("update-workout", {
626
+ description: "Replace a logged workout by id. All fields are overwritten, including every exercise and set.",
627
+ inputSchema: {
628
+ workoutId: z.string().min(1),
629
+ ...workoutFields
630
+ }
631
+ }, safe(async ({ workoutId, ...workout }) => jsonResponse(await client.workouts.update(workoutId, workout))));
632
+ }
633
+ //#endregion
634
+ //#region src/index.ts
635
+ const SERVER_VERSION = "1.0.0";
636
+ function createServer(options) {
637
+ const server = new McpServer({
638
+ name: "hevy-coach",
639
+ version: SERVER_VERSION
640
+ });
641
+ const client = createHevyClient(options);
642
+ registerUserTools(server, client);
643
+ registerWorkoutTools(server, client);
644
+ registerRoutineTools(server, client);
645
+ registerExerciseTools(server, client);
646
+ registerBodyMeasurementTools(server, client);
647
+ registerCoachingTools(server, client);
648
+ return server;
649
+ }
650
+ //#endregion
651
+ export { createServer as n, SERVER_VERSION as t };