@trainheroic-unofficial/athlete-mcp 0.6.5 → 1.1.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 (2) hide show
  1. package/dist/server.mjs +192 -6
  2. package/package.json +3 -3
package/dist/server.mjs CHANGED
@@ -152,6 +152,14 @@ z.object({
152
152
  endDate: dateString
153
153
  });
154
154
  /**
155
+ * One entered set: the value of each parameter slot (param 1 / param 2 — e.g. reps / weight).
156
+ * Shared by every logging write so the per-set shape is defined once.
157
+ */
158
+ const loggedSetSchema = z.object({
159
+ param1: z.union([z.number(), z.string()]).optional(),
160
+ param2: z.union([z.number(), z.string()]).optional()
161
+ });
162
+ /**
155
163
  * Args for the set-logging write. `date` (the workout's day) locates the saved
156
164
  * workout via the range endpoint; `savedWorkoutSetId` picks the set to complete; `results`
157
165
  * gives, per exercise in it, the entered value of each set (param 1 / param 2 by entry slot).
@@ -161,13 +169,25 @@ const logSetArgsSchema = z.object({
161
169
  savedWorkoutSetId: idArgSchema,
162
170
  results: z.array(z.object({
163
171
  savedWorkoutSetExerciseId: idArgSchema,
164
- sets: z.array(z.object({
165
- param1: z.union([z.number(), z.string()]).optional(),
166
- param2: z.union([z.number(), z.string()]).optional()
167
- })).min(1)
172
+ sets: z.array(loggedSetSchema).min(1)
168
173
  })).min(1)
169
174
  });
170
175
  logSetArgsSchema.extend({ athleteId: idArgSchema });
176
+ /**
177
+ * Args for logging a whole session by exercise (rather than by saved-workout-set id). Each
178
+ * exercise carries its entered sets and an optional 1-based `order`. The athlete path creates
179
+ * or reuses a personal session for the date and logs against it; the coach path resolves each
180
+ * exercise to a set already prescribed on that date and logs against that.
181
+ */
182
+ const logSessionArgsSchema = z.object({
183
+ date: dateString,
184
+ exercises: z.array(z.object({
185
+ exerciseId: idArgSchema,
186
+ order: z.number().int().positive().optional(),
187
+ sets: z.array(loggedSetSchema).min(1)
188
+ })).min(1)
189
+ });
190
+ logSessionArgsSchema.extend({ athleteId: idArgSchema });
171
191
  z.looseObject({
172
192
  title: z.string().min(1),
173
193
  param_1_type: z.number().optional(),
@@ -654,6 +674,15 @@ function fetchExerciseStats(client, exerciseId, userId, date) {
654
674
  function fetchAthleteWorkouts(client, startDate, endDate) {
655
675
  return getArray(client, `/3.0/athlete/programworkout/range?startDate=${startDate}&endDate=${endDate}`, "athlete workouts");
656
676
  }
677
+ /**
678
+ * A coach's view of a roster athlete's scheduled + completed workouts in an inclusive
679
+ * YYYY-MM-DD window (`/3.0/coach/athlete/programworkout/range/{athleteId}`). Returns the same
680
+ * `ProgramWorkout[]` shape as `fetchAthleteWorkouts`, so the same presenters and
681
+ * `findSavedWorkoutSet` apply — it just reads another athlete's data through the coach surface.
682
+ */
683
+ function fetchCoachAthleteWorkouts(client, athleteId, startDate, endDate) {
684
+ return getArray(client, `/3.0/coach/athlete/programworkout/range/${athleteId}?startDate=${startDate}&endDate=${endDate}`, "coach athlete workouts");
685
+ }
657
686
  function fetchLeaderboard(client, workoutId, opts = {}) {
658
687
  const qs = new URLSearchParams();
659
688
  if (opts.page !== void 0) qs.set("page", String(opts.page));
@@ -988,6 +1017,23 @@ async function logAthleteSet(client, args) {
988
1017
  return writeSetResults(client, { role: "athlete" }, await fetchAthleteWorkouts(client, args.date, args.date), args.savedWorkoutSetId, args.results);
989
1018
  }
990
1019
  /**
1020
+ * Coach "Log for Athlete": record set results for a roster athlete on their behalf, via the
1021
+ * coach surface — `PUT /1.0/coach/savedworkoutsetexercise/{id}/{athleteId}` (the data write)
1022
+ * then `PUT /1.0/coach/savedworkoutset/{id}/{athleteId}` (mark complete). Same two-step
1023
+ * contract as {@link logAthleteSet}; the bodies are identical except each is stamped with
1024
+ * `athleteId`. The day is located through the coach range endpoint for that athlete.
1025
+ *
1026
+ * NOTE: TrainHeroic's seeded *demo* athletes are read-only for results and return 401 on the
1027
+ * data-write step; real (invited) athletes accept it.
1028
+ */
1029
+ async function logForAthlete(client, args) {
1030
+ const workouts = await fetchCoachAthleteWorkouts(client, args.athleteId, args.date, args.date);
1031
+ return writeSetResults(client, {
1032
+ role: "coach",
1033
+ athleteId: args.athleteId
1034
+ }, workouts, args.savedWorkoutSetId, args.results);
1035
+ }
1036
+ /**
991
1037
  * Shared two-step set-log write behind {@link logAthleteSet} and {@link logForAthlete}.
992
1038
  * `target` selects the surface: `athlete` writes `/1.0/athlete/...`; `coach` writes
993
1039
  * `/1.0/coach/...{athleteId}` and stamps `athleteId` into each body. Step 1 PUTs each
@@ -1073,6 +1119,114 @@ async function addExercisesToWorkout(client, workoutId, exercises) {
1073
1119
  if (!res.ok) throw new Error(`Add exercises to workout failed (HTTP ${res.status}).`);
1074
1120
  return res.data;
1075
1121
  }
1122
+ /**
1123
+ * Find a personal-calendar session already on the given day. The range marks these with
1124
+ * `personal_cal === true`; the addable id is the program workout's `workout_id`. Returns the
1125
+ * first one's workoutId, or null when the day has no personal session.
1126
+ */
1127
+ function findPersonalSessionWorkoutId(workouts) {
1128
+ for (const pw of workouts) {
1129
+ const rec = pw;
1130
+ if (rec.personal_cal === true) {
1131
+ const workoutId = coerceInt(rec.workout_id);
1132
+ if (workoutId !== null) return workoutId;
1133
+ }
1134
+ }
1135
+ return null;
1136
+ }
1137
+ /**
1138
+ * Map the `addExercisesToWorkout` response (an array of saved sets) to per-exercise saved ids.
1139
+ * Each set's `id` is a savedWorkoutSetId; each `savedWorkoutSetExercises[].id` is a
1140
+ * savedWorkoutSetExerciseId and `.exerciseId` is the catalog exercise id that was added.
1141
+ */
1142
+ function indexAddedExercises(added) {
1143
+ const out = [];
1144
+ const sets = Array.isArray(added) ? added : [];
1145
+ for (const s of sets) {
1146
+ if (!isRecord(s)) continue;
1147
+ const savedWorkoutSetId = coerceInt(s.id);
1148
+ if (savedWorkoutSetId === null) continue;
1149
+ const exercises = Array.isArray(s.savedWorkoutSetExercises) ? s.savedWorkoutSetExercises : [];
1150
+ for (const ex of exercises) {
1151
+ if (!isRecord(ex)) continue;
1152
+ const savedWorkoutSetExerciseId = coerceInt(ex.id);
1153
+ const exerciseId = coerceInt(ex.exerciseId ?? ex.exercise_id);
1154
+ if (savedWorkoutSetExerciseId === null || exerciseId === null) continue;
1155
+ out.push({
1156
+ exerciseId,
1157
+ savedWorkoutSetId,
1158
+ savedWorkoutSetExerciseId
1159
+ });
1160
+ }
1161
+ }
1162
+ return out;
1163
+ }
1164
+ /** Group resolved exercises by saved set and write each set via the given log target. */
1165
+ async function logResolvedExercises(client, target, date, resolved) {
1166
+ const bySet = /* @__PURE__ */ new Map();
1167
+ for (const r of resolved) {
1168
+ const list = bySet.get(r.savedWorkoutSetId) ?? [];
1169
+ list.push({
1170
+ savedWorkoutSetExerciseId: r.savedWorkoutSetExerciseId,
1171
+ sets: [...r.sets]
1172
+ });
1173
+ bySet.set(r.savedWorkoutSetId, list);
1174
+ }
1175
+ const out = [];
1176
+ for (const [savedWorkoutSetId, results] of bySet) {
1177
+ const written = target.role === "coach" ? await logForAthlete(client, {
1178
+ athleteId: target.athleteId,
1179
+ date,
1180
+ savedWorkoutSetId,
1181
+ results
1182
+ }) : await logAthleteSet(client, {
1183
+ date,
1184
+ savedWorkoutSetId,
1185
+ results
1186
+ });
1187
+ out.push(written);
1188
+ }
1189
+ return out;
1190
+ }
1191
+ /**
1192
+ * Log a whole session for the logged-in athlete by exercise, with no pre-existing prescription
1193
+ * required. Reuses a personal session already on the date when one exists (the API marks these
1194
+ * `personal_cal`), otherwise creates one; then adds the exercises and logs their entered sets.
1195
+ * This is the "log whatever I just did" path — extra accessory work, a makeup lift, an off-plan
1196
+ * gym session.
1197
+ */
1198
+ async function logAdHocSession(client, args) {
1199
+ if (args.exercises.length === 0) throw new Error("Provide at least one exercise to log.");
1200
+ const existing = findPersonalSessionWorkoutId(await fetchAthleteWorkouts(client, args.date, args.date));
1201
+ const created = existing === null;
1202
+ const workoutId = existing ?? (await createPersonalWorkout(client, args.date)).workoutId;
1203
+ const withOrder = args.exercises.map((e, i) => ({
1204
+ ...e,
1205
+ order: e.order ?? i + 1
1206
+ }));
1207
+ const index = indexAddedExercises(await addExercisesToWorkout(client, workoutId, withOrder.map((e) => ({
1208
+ exerciseId: e.exerciseId,
1209
+ order: e.order
1210
+ }))));
1211
+ const consumed = /* @__PURE__ */ new Set();
1212
+ const resolved = withOrder.map((e) => {
1213
+ const hit = index.find((m) => m.exerciseId === e.exerciseId && !consumed.has(m.savedWorkoutSetExerciseId));
1214
+ if (!hit) throw new Error(`Could not place exercise ${e.exerciseId} into the session after adding it. The add-exercises response did not return a saved set for it.`);
1215
+ consumed.add(hit.savedWorkoutSetExerciseId);
1216
+ return {
1217
+ exerciseId: e.exerciseId,
1218
+ savedWorkoutSetId: hit.savedWorkoutSetId,
1219
+ savedWorkoutSetExerciseId: hit.savedWorkoutSetExerciseId,
1220
+ sets: e.sets
1221
+ };
1222
+ });
1223
+ const sets = await logResolvedExercises(client, { role: "athlete" }, args.date, resolved);
1224
+ return {
1225
+ date: args.date,
1226
+ created,
1227
+ sets
1228
+ };
1229
+ }
1076
1230
  /** Flatten `/v5/exercises/{id}/history` into PRs + a session time-series. */
1077
1231
  function presentExerciseHistory(detail) {
1078
1232
  return {
@@ -1280,8 +1434,40 @@ function registerSessionTools(server, ctx) {
1280
1434
  return jsonResult(await addExercisesToWorkout(ctx.client, toId(workoutId), mapped), { hint: "Each top-level id is a savedWorkoutSetId; savedWorkoutSetExercises[].id is the savedWorkoutSetExerciseId for athlete_log_set." });
1281
1435
  }));
1282
1436
  }
1283
- /** The gated set-logging write. */
1437
+ /** Map validated logSession exercises to the SDK's SessionExercise[] (ids coerced, slots trimmed). */
1438
+ function mapSessionExercises(exercises) {
1439
+ return exercises.map((e) => {
1440
+ const sets = e.sets.map((s) => {
1441
+ const slot = {};
1442
+ if (s.param1 !== void 0) slot.param1 = s.param1;
1443
+ if (s.param2 !== void 0) slot.param2 = s.param2;
1444
+ return slot;
1445
+ });
1446
+ const mapped = {
1447
+ exerciseId: toId(e.exerciseId),
1448
+ sets
1449
+ };
1450
+ if (e.order !== void 0) mapped.order = e.order;
1451
+ return mapped;
1452
+ });
1453
+ }
1454
+ /** The gated set-logging writes (by saved-set id, and the by-exercise ad-hoc session). */
1284
1455
  function registerLogTool(server, ctx) {
1456
+ server.registerTool("athlete_log_session", {
1457
+ title: "Log a whole session by exercise",
1458
+ description: "Athlete-facing write for logging what you actually did, with NO coach-scheduled workout required — use this for off-plan training (accessory work, a makeup lift, an unplanned gym session). Give a YYYY-MM-DD date and a list of exercises, each with its entered sets (param1/param2, e.g. reps/weight). It reuses a personal session already on that date or creates one, then logs the sets. Get each exerciseId from athlete_exercises. To log against a workout a coach already scheduled, use athlete_log_set instead. Requires confirmation (elicitation or confirm:true).",
1459
+ inputSchema: {
1460
+ ...logSessionArgsSchema.shape,
1461
+ confirm: z.boolean().optional()
1462
+ },
1463
+ annotations: DESTRUCTIVE
1464
+ }, ({ date, exercises, confirm }, extra) => attempt(async () => {
1465
+ if (!await confirmGate(server, extra.requestId, `Log a session of ${exercises.length} exercise(s) on ${date}? This writes to your coach-visible training log.`, confirm)) return errorResult(NOT_CONFIRMED);
1466
+ return jsonResult(await logAdHocSession(ctx.client, {
1467
+ date,
1468
+ exercises: mapSessionExercises(exercises)
1469
+ }));
1470
+ }));
1285
1471
  server.registerTool("athlete_log_set", {
1286
1472
  title: "Log completed set results",
1287
1473
  description: "Athlete-facing write: record entered results (reps/weight per set) for a saved workout set on a given day, marking the set completed. Writes to the athlete's (coach-visible) training log and shows in exercise history. Get savedWorkoutSetId + savedWorkoutSetExerciseId from athlete_workouts (raw:true). Requires confirmation (elicitation or confirm:true).",
@@ -1335,7 +1521,7 @@ function registerAthleteTrainingTools(server, ctx) {
1335
1521
  }
1336
1522
  //#endregion
1337
1523
  //#region package.json
1338
- var version = "0.6.5";
1524
+ var version = "1.1.0";
1339
1525
  //#endregion
1340
1526
  //#region src/server.ts
1341
1527
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/athlete-mcp",
3
- "version": "0.6.5",
3
+ "version": "1.1.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,8 +21,8 @@
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.29.0",
23
23
  "zod": "^4.4.3",
24
- "@trainheroic-unofficial/core": "0.6.5",
25
- "@trainheroic-unofficial/js": "0.6.5"
24
+ "@trainheroic-unofficial/core": "1.1.0",
25
+ "@trainheroic-unofficial/js": "1.1.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^26.0.0",