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.
- package/CHANGELOG.md +35 -0
- package/README.md +42 -146
- package/dist/cli.mjs +16 -0
- package/dist/http.mjs +133 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +2 -0
- package/dist/src-BxQx3e_u.mjs +651 -0
- package/package.json +32 -33
- package/CHATGPT.md +0 -388
- package/GEMINI.md +0 -416
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -16
- package/dist/cli.js.map +0 -1
- package/dist/http.d.ts +0 -3
- package/dist/http.d.ts.map +0 -1
- package/dist/http.js +0 -174
- package/dist/http.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -23
- package/dist/index.js.map +0 -1
- package/dist/tools/body-measurements.d.ts +0 -4
- package/dist/tools/body-measurements.d.ts.map +0 -1
- package/dist/tools/body-measurements.js +0 -88
- package/dist/tools/body-measurements.js.map +0 -1
- package/dist/tools/coaching.d.ts +0 -4
- package/dist/tools/coaching.d.ts.map +0 -1
- package/dist/tools/coaching.js +0 -353
- package/dist/tools/coaching.js.map +0 -1
- package/dist/tools/exercises.d.ts +0 -4
- package/dist/tools/exercises.d.ts.map +0 -1
- package/dist/tools/exercises.js +0 -121
- package/dist/tools/exercises.js.map +0 -1
- package/dist/tools/routines.d.ts +0 -4
- package/dist/tools/routines.d.ts.map +0 -1
- package/dist/tools/routines.js +0 -150
- package/dist/tools/routines.js.map +0 -1
- package/dist/tools/users.d.ts +0 -4
- package/dist/tools/users.d.ts.map +0 -1
- package/dist/tools/users.js +0 -16
- package/dist/tools/users.js.map +0 -1
- package/dist/tools/workouts.d.ts +0 -4
- package/dist/tools/workouts.d.ts.map +0 -1
- package/dist/tools/workouts.js +0 -134
- package/dist/tools/workouts.js.map +0 -1
- package/dist/utils/hevy-client.d.ts +0 -212
- package/dist/utils/hevy-client.d.ts.map +0 -1
- package/dist/utils/hevy-client.js +0 -178
- package/dist/utils/hevy-client.js.map +0 -1
- package/dist/utils/response.d.ts +0 -21
- package/dist/utils/response.d.ts.map +0 -1
- package/dist/utils/response.js +0 -22
- 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 };
|