@trainheroic-unofficial/athlete-mcp 1.4.0 → 1.5.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 +172 -66
  2. package/package.json +3 -3
package/dist/server.mjs CHANGED
@@ -160,20 +160,41 @@ const loggedSetSchema = z.object({
160
160
  param2: z.union([z.number(), z.string()]).optional()
161
161
  });
162
162
  /**
163
+ * A logged set that can name the prescribed position it fills. `slot` is the 1-based set
164
+ * index in the prescription (10 max) the result should land in; omit it to fill the next
165
+ * sequential position (the first entry is set 1, the second set 2, and so on). Targeting a
166
+ * slot lets a partial log land in the right positions of a multi-set prescription — e.g.
167
+ * three top singles into the 4th/5th/6th positions of an `8,5,3,1,1,1` ramp. Used by the
168
+ * by-set logging write (not the by-exercise session log, where each exercise's sets are
169
+ * always sequential).
170
+ */
171
+ const loggedSetWithSlotSchema = loggedSetSchema.extend({ slot: z.number().int().min(1).max(10).optional() });
172
+ /**
163
173
  * Args for the set-logging write. `date` (the workout's day) locates the saved
164
174
  * workout via the range endpoint; `savedWorkoutSetId` picks the set to complete; `results`
165
- * gives, per exercise in it, the entered value of each set (param 1 / param 2 by entry slot).
175
+ * gives, per exercise in it, the entered value of each set. Each set fills the next position by
176
+ * default, or names its `slot` (1-based) to place a partial log at specific positions. A partial
177
+ * log keeps positions already logged in an earlier call and leaves the positions it does not
178
+ * write empty.
166
179
  */
167
180
  const logSetArgsSchema = z.object({
168
181
  date: dateString,
169
182
  savedWorkoutSetId: idArgSchema,
170
183
  results: z.array(z.object({
171
184
  savedWorkoutSetExerciseId: idArgSchema,
172
- sets: z.array(loggedSetSchema).min(1)
185
+ sets: z.array(loggedSetWithSlotSchema).min(1)
173
186
  })).min(1)
174
187
  });
175
188
  logSetArgsSchema.extend({ athleteId: idArgSchema });
176
- logSetArgsSchema.extend({ athleteId: idArgSchema });
189
+ z.object({
190
+ date: dateString,
191
+ savedWorkoutSetId: idArgSchema,
192
+ athleteId: idArgSchema,
193
+ results: z.array(z.object({
194
+ savedWorkoutSetExerciseId: idArgSchema,
195
+ sets: z.array(loggedSetSchema).min(1)
196
+ })).min(1)
197
+ });
177
198
  z.object({
178
199
  savedWorkoutSetExerciseId: idArgSchema,
179
200
  exerciseId: idArgSchema
@@ -635,6 +656,10 @@ function exerciseUnits(param1, param2) {
635
656
  function isRecord(x) {
636
657
  return typeof x === "object" && x !== null && !Array.isArray(x);
637
658
  }
659
+ /** A non-empty string, or null. Narrows a loosely-typed API field to a usable string. */
660
+ function str(v) {
661
+ return typeof v === "string" && v !== "" ? v : null;
662
+ }
638
663
  /**
639
664
  * Rank candidate rows for a free-text query (FTS5 replacement). Higher is better:
640
665
  * exact title, then prefix, then count of matched tokens, with shorter titles and
@@ -720,7 +745,6 @@ function fetchLeaderboard(client, workoutId, opts = {}) {
720
745
  const query = qs.toString();
721
746
  return getJson(client, `/3.0/athlete/leaderboard/${workoutId}${query ? `?${query}` : ""}`, "athlete leaderboard");
722
747
  }
723
- const SLOTS = 10;
724
748
  function nonEmpty(value) {
725
749
  return value !== void 0 && value !== null && String(value).trim() !== "";
726
750
  }
@@ -731,7 +755,7 @@ function nonEmpty(value) {
731
755
  */
732
756
  function prescribedSets(ex) {
733
757
  const out = [];
734
- for (let i = 1; i <= SLOTS; i += 1) {
758
+ for (let i = 1; i <= 10; i += 1) {
735
759
  const p1 = ex[`param_1_data_${i}`];
736
760
  const p2 = ex[`param_2_data_${i}`];
737
761
  const has1 = nonEmpty(p1);
@@ -752,7 +776,7 @@ function prescribedSets(ex) {
752
776
  */
753
777
  function performedSets(ex) {
754
778
  const out = [];
755
- for (let i = 1; i <= SLOTS; i += 1) {
779
+ for (let i = 1; i <= 10; i += 1) {
756
780
  if (coerceInt(ex[`param_${i}_made`]) !== 1) continue;
757
781
  const p1 = ex[`param_1_data_${i}`];
758
782
  const p2 = ex[`param_2_data_${i}`];
@@ -786,9 +810,6 @@ function presentBlock(set, performedById) {
786
810
  exercises: exercises.filter(isRecord).map((ex) => presentExercise(ex, performedById))
787
811
  };
788
812
  }
789
- function str(v) {
790
- return typeof v === "string" && v !== "" ? v : null;
791
- }
792
813
  /** Every logged set (programmed + athlete-added) in the saved copy, paired with its exercises. */
793
814
  function savedSets(saved) {
794
815
  const out = [];
@@ -918,7 +939,52 @@ function summarizeAthleteWorkouts(list) {
918
939
  };
919
940
  });
920
941
  }
921
- const MAX_PARAM_SLOTS = 10;
942
+ /** Flatten `/v5/exercises/{id}/history` into PRs + a session time-series. */
943
+ function presentExerciseHistory(detail) {
944
+ return {
945
+ liftPRs: (detail.liftPRs ?? []).map((p) => ({
946
+ description: p.description ?? null,
947
+ reps: p.reps ?? null,
948
+ weight: p.weight ?? null,
949
+ units: p.units ?? null,
950
+ date: p.dateCompleted ?? null
951
+ })),
952
+ sessions: (detail.history ?? []).map((h) => ({
953
+ date: h.dateCompleted,
954
+ abr: h.abr ?? null,
955
+ estimated1RM: h.bestEstimated1RM ?? null,
956
+ sets: (h.sets ?? []).map((s) => ({
957
+ setNumber: s.setNumber,
958
+ value: s.formattedValue ?? null
959
+ }))
960
+ }))
961
+ };
962
+ }
963
+ //#endregion
964
+ //#region ../js/src/athlete-set-write.ts
965
+ /**
966
+ * Coerce the loosely-typed `results` from a validated log/prescribe args object into the SDK's
967
+ * {@link SetResult}[]. The dto schemas validate ids as a number or a numeric string and leave
968
+ * empty param slots optional; this narrows the id to a number and copies only the present per-set
969
+ * values (so the per-set type stays free of `undefined` under exactOptionalPropertyTypes). Shared
970
+ * by the MCP tools and the CLI so the mapping lives in one place rather than once per surface.
971
+ */
972
+ function toSetResults(results) {
973
+ return results.map((r) => {
974
+ const id = coerceInt(r.savedWorkoutSetExerciseId);
975
+ if (id === null) throw new Error(`Invalid savedWorkoutSetExerciseId: ${String(r.savedWorkoutSetExerciseId)}`);
976
+ return {
977
+ savedWorkoutSetExerciseId: id,
978
+ sets: r.sets.map((s) => {
979
+ const set = {};
980
+ if (s.param1 !== void 0) set.param1 = s.param1;
981
+ if (s.param2 !== void 0) set.param2 = s.param2;
982
+ if (s.slot !== void 0) set.slot = s.slot;
983
+ return set;
984
+ })
985
+ };
986
+ });
987
+ }
922
988
  /**
923
989
  * Build the body for `PUT /1.0/{role}/savedworkoutsetexercise/{id}`. The body uses snake_case
924
990
  * keys matching the live API response shape. Each set slot (1-10) carries `param_1_data_N` /
@@ -926,36 +992,96 @@ const MAX_PARAM_SLOTS = 10;
926
992
  *
927
993
  * `mode` selects which write this is — the same endpoint serves both:
928
994
  * - `"log"`: the values ARE a performed result, so `param_N_made` is 1 where the slot has data
929
- * and the exercise `completed` flag is 1 when any set has data.
995
+ * and the exercise `completed` flag is 1 when any set has logged data.
930
996
  * - `"prescribe"`: the values are prescribed targets, written with every `param_N_made` and
931
997
  * `completed` left at 0 so the set is not marked done. This matches what the app sends when a
932
998
  * coach edits an athlete's prescribed reps/weight.
933
999
  *
1000
+ * Each set fills a 1-based slot: its explicit `slot`, or its sequential position in `results`
1001
+ * when `slot` is omitted. A `log` carrying the live exercise record in `existing` keeps the slots
1002
+ * it does not write that were ALREADY performed (`param_N_made === 1`), so logging a second part of
1003
+ * a set does not wipe the earlier-logged sets. A slot holding only un-logged prescription pre-fill
1004
+ * (`param_N_made === 0`) is left blank rather than carried over: marking the set completed makes
1005
+ * the server flag every data-bearing slot performed, so preserving that pre-fill would fabricate
1006
+ * sets the athlete never did. The prescription is unaffected (it lives in the separate `workout`
1007
+ * copy, not this saved copy). A `prescribe` ignores `existing` and replaces the whole prescription.
1008
+ *
934
1009
  * Only `savedWorkoutSetExerciseId`, `savedWorkoutSetId`, and `workoutSetExerciseId` are
935
- * required from the live exercise record; everything else is derived from `results`.
1010
+ * required from the live exercise record; everything else is derived from `results` and the
1011
+ * preserved slots of `existing`.
936
1012
  *
937
1013
  * Exported for unit testing — callers should use `logAthleteSet` / `prescribeForAthlete` instead.
938
1014
  */
939
- function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results, mode) {
940
- if (results.length > MAX_PARAM_SLOTS) throw new Error(`At most ${MAX_PARAM_SLOTS} sets are supported per exercise; got ${results.length}.`);
1015
+ function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results, mode, existing) {
1016
+ if (results.length > 10) throw new Error(`At most 10 sets are supported per exercise; got ${results.length}.`);
1017
+ const bySlot = /* @__PURE__ */ new Map();
1018
+ results.forEach((set, i) => {
1019
+ const slot = set.slot ?? i + 1;
1020
+ if (slot < 1 || slot > 10) throw new Error(`Set slot ${slot} is out of range; slots are 1–10.`);
1021
+ if (bySlot.has(slot)) throw new Error(`Two sets target slot ${slot}; each slot can be written once.`);
1022
+ bySlot.set(slot, set);
1023
+ });
941
1024
  const performed = mode === "log";
942
- const hasData = results.some((s) => s.param1 !== void 0 || s.param2 !== void 0);
1025
+ const carryOver = performed && existing !== void 0;
943
1026
  const body = {
944
1027
  id: savedWorkoutSetExerciseId,
945
1028
  saved_workout_set_id: savedWorkoutSetId,
946
- workout_set_exercise_id: workoutSetExerciseId,
947
- completed: performed && hasData ? 1 : 0
1029
+ workout_set_exercise_id: workoutSetExerciseId
948
1030
  };
949
- for (let i = 1; i <= MAX_PARAM_SLOTS; i += 1) {
950
- const slot = results[i - 1];
951
- const p1 = slot?.param1 !== void 0 ? String(slot.param1) : "";
952
- const p2 = slot?.param2 !== void 0 ? String(slot.param2) : "";
953
- body[`param_${i}_made`] = performed && (p1 !== "" || p2 !== "") ? 1 : 0;
1031
+ let anyMade = false;
1032
+ for (let i = 1; i <= 10; i += 1) {
1033
+ const target = bySlot.get(i);
1034
+ let p1;
1035
+ let p2;
1036
+ let made;
1037
+ if (target) {
1038
+ p1 = target.param1 !== void 0 ? String(target.param1) : "";
1039
+ p2 = target.param2 !== void 0 ? String(target.param2) : "";
1040
+ made = performed && (p1 !== "" || p2 !== "") ? 1 : 0;
1041
+ } else if (carryOver && coerceInt(existing?.[`param_${i}_made`]) === 1) {
1042
+ p1 = existingSlotData(existing, `param_1_data_${i}`);
1043
+ p2 = existingSlotData(existing, `param_2_data_${i}`);
1044
+ made = 1;
1045
+ } else {
1046
+ p1 = "";
1047
+ p2 = "";
1048
+ made = 0;
1049
+ }
1050
+ if (made === 1) anyMade = true;
1051
+ body[`param_${i}_made`] = made;
954
1052
  body[`param_1_data_${i}`] = p1;
955
1053
  body[`param_2_data_${i}`] = p2;
956
1054
  }
1055
+ body.completed = performed && anyMade ? 1 : 0;
957
1056
  return body;
958
1057
  }
1058
+ /** Read a saved-copy slot value (`param_1_data_N` / `param_2_data_N`) as the string the body uses. */
1059
+ function existingSlotData(existing, key) {
1060
+ const v = existing?.[key];
1061
+ return v === void 0 || v === null ? "" : String(v);
1062
+ }
1063
+ /** True when a saved-copy exercise already carries a performed slot (any `param_N_made` === 1). */
1064
+ function exerciseHasLoggedData(ex) {
1065
+ for (let i = 1; i <= 10; i += 1) if (coerceInt(ex[`param_${i}_made`]) === 1) return true;
1066
+ return false;
1067
+ }
1068
+ /** True when a result carries at least one non-empty param value (so it produces a performed slot). */
1069
+ function resultHasData(result) {
1070
+ return result.sets.some((s) => s.param1 !== void 0 && String(s.param1) !== "" || s.param2 !== void 0 && String(s.param2) !== "");
1071
+ }
1072
+ /**
1073
+ * Whether every exercise in a saved workout set now has logged data — either written with data in
1074
+ * this call (`loggedIds`) or already carrying a performed slot. Gates the set-completion PUT: a
1075
+ * superset/circuit stays open until the last exercise is logged, so completing it on a partial log
1076
+ * does not flip its still-empty siblings to "done". An exercise written with only empty values does
1077
+ * not count (it would not be marked performed), so an all-empty log never completes the set.
1078
+ */
1079
+ function isSetFullyLogged(exercises, loggedIds) {
1080
+ return exercises.every((ex) => {
1081
+ const id = coerceInt(ex.id);
1082
+ return id !== null && loggedIds.has(id) || exerciseHasLoggedData(ex);
1083
+ });
1084
+ }
959
1085
  /**
960
1086
  * Locate the target saved workout set across all program workouts on the given day.
961
1087
  * Returns the `savedWorkoutId`, the matching set's `workoutSetExercises` array so callers
@@ -1054,7 +1180,8 @@ async function logAthleteSet(client, args) {
1054
1180
  const r = await writeSetResults(client, { role: "athlete" }, await fetchAthleteWorkouts(client, args.date, args.date), args.savedWorkoutSetId, args.results, "log");
1055
1181
  return {
1056
1182
  savedWorkoutSetId: r.savedWorkoutSetId,
1057
- exercisesLogged: r.exercisesWritten
1183
+ exercisesLogged: r.exercisesWritten,
1184
+ setCompleted: r.setCompleted
1058
1185
  };
1059
1186
  }
1060
1187
  /**
@@ -1075,7 +1202,8 @@ async function logForAthlete(client, args) {
1075
1202
  }, workouts, args.savedWorkoutSetId, args.results, "log");
1076
1203
  return {
1077
1204
  savedWorkoutSetId: r.savedWorkoutSetId,
1078
- exercisesLogged: r.exercisesWritten
1205
+ exercisesLogged: r.exercisesWritten,
1206
+ setCompleted: r.setCompleted
1079
1207
  };
1080
1208
  }
1081
1209
  /**
@@ -1108,7 +1236,7 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
1108
1236
  const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
1109
1237
  if (!workoutSetExerciseId) throw new Error(`savedWorkoutSetExercise ${result.savedWorkoutSetExerciseId} is missing its workout_set_exercise_id (the prescription-template pointer the write needs). This is the savedWorkoutSetExerciseId, not an exercise_id — re-read the ids from athlete_saved_workouts.`);
1110
1238
  const body = {
1111
- ...buildExerciseSetPayload(result.savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, result.sets, mode),
1239
+ ...buildExerciseSetPayload(result.savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, result.sets, mode, ex),
1112
1240
  ...extra
1113
1241
  };
1114
1242
  const res = await client.request("PUT", `/1.0/${target.role}/savedworkoutsetexercise/${result.savedWorkoutSetExerciseId}${suffix}`, { body });
@@ -1118,17 +1246,22 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
1118
1246
  }
1119
1247
  exercisesWritten += 1;
1120
1248
  }
1249
+ let setCompleted = false;
1121
1250
  if (mode === "log") {
1122
- const setBody = {
1123
- ...buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true),
1124
- ...extra
1125
- };
1126
- const setRes = await client.request("PUT", `/1.0/${target.role}/savedworkoutset/${savedWorkoutSetId}${suffix}`, { body: setBody });
1127
- if (!setRes.ok) throw new Error(`Failed to mark workout set ${savedWorkoutSetId} completed (HTTP ${setRes.status}).`);
1251
+ if (isSetFullyLogged(exercises, new Set(results.filter(resultHasData).map((r) => r.savedWorkoutSetExerciseId)))) {
1252
+ const setBody = {
1253
+ ...buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true),
1254
+ ...extra
1255
+ };
1256
+ const setRes = await client.request("PUT", `/1.0/${target.role}/savedworkoutset/${savedWorkoutSetId}${suffix}`, { body: setBody });
1257
+ if (!setRes.ok) throw new Error(`Failed to mark workout set ${savedWorkoutSetId} completed (HTTP ${setRes.status}).`);
1258
+ setCompleted = true;
1259
+ }
1128
1260
  }
1129
1261
  return {
1130
1262
  savedWorkoutSetId,
1131
- exercisesWritten
1263
+ exercisesWritten,
1264
+ setCompleted
1132
1265
  };
1133
1266
  }
1134
1267
  /**
@@ -1235,7 +1368,10 @@ async function logResolvedExercises(client, target, date, resolved) {
1235
1368
  savedWorkoutSetId,
1236
1369
  results
1237
1370
  });
1238
- out.push(written);
1371
+ out.push({
1372
+ savedWorkoutSetId: written.savedWorkoutSetId,
1373
+ exercisesLogged: written.exercisesLogged
1374
+ });
1239
1375
  }
1240
1376
  return out;
1241
1377
  }
@@ -1278,27 +1414,6 @@ async function logAdHocSession(client, args) {
1278
1414
  sets
1279
1415
  };
1280
1416
  }
1281
- /** Flatten `/v5/exercises/{id}/history` into PRs + a session time-series. */
1282
- function presentExerciseHistory(detail) {
1283
- return {
1284
- liftPRs: (detail.liftPRs ?? []).map((p) => ({
1285
- description: p.description ?? null,
1286
- reps: p.reps ?? null,
1287
- weight: p.weight ?? null,
1288
- units: p.units ?? null,
1289
- date: p.dateCompleted ?? null
1290
- })),
1291
- sessions: (detail.history ?? []).map((h) => ({
1292
- date: h.dateCompleted,
1293
- abr: h.abr ?? null,
1294
- estimated1RM: h.bestEstimated1RM ?? null,
1295
- sets: (h.sets ?? []).map((s) => ({
1296
- setNumber: s.setNumber,
1297
- value: s.formattedValue ?? null
1298
- }))
1299
- }))
1300
- };
1301
- }
1302
1417
  //#endregion
1303
1418
  //#region ../core/src/history.ts
1304
1419
  /**
@@ -1523,7 +1638,7 @@ function registerLogTool(server, ctx) {
1523
1638
  }));
1524
1639
  server.registerTool("athlete_log_set", {
1525
1640
  title: "Log completed set results",
1526
- 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).",
1641
+ description: "Athlete-facing write: record entered results (reps/weight per set) for a saved workout set on a given day. Writes to the athlete's (coach-visible) training log and shows in exercise history. Each set fills the next position in order; give a set a 1-based `slot` to place it at a specific position — e.g. log three top singles into positions 4-6 of an 8,5,3,1,1,1 ramp. A partial log records only the positions you send (plus any logged in an earlier call) and leaves the rest unlogged, so it does not mark untouched sets as performed. In a superset/circuit the block is marked done only once every exercise in it has logged results, so logging one exercise leaves its siblings untouched (the response's `setCompleted` reports whether the block was completed). Get savedWorkoutSetId + savedWorkoutSetExerciseId from athlete_workouts (raw:true). Requires confirmation (elicitation or confirm:true).",
1527
1642
  inputSchema: {
1528
1643
  ...logSetArgsSchema.shape,
1529
1644
  confirm: z.boolean().optional()
@@ -1531,19 +1646,10 @@ function registerLogTool(server, ctx) {
1531
1646
  annotations: DESTRUCTIVE
1532
1647
  }, ({ date, savedWorkoutSetId, results, confirm }, extra) => attempt(async () => {
1533
1648
  if (!await confirmGate(server, extra.requestId, `Log results to saved workout set ${toId(savedWorkoutSetId)} on ${date}? This writes to your coach-visible training log.`, confirm)) return errorResult(NOT_CONFIRMED);
1534
- const mapped = results.map((r) => ({
1535
- savedWorkoutSetExerciseId: toId(r.savedWorkoutSetExerciseId),
1536
- sets: r.sets.map((s) => {
1537
- const slot = {};
1538
- if (s.param1 !== void 0) slot.param1 = s.param1;
1539
- if (s.param2 !== void 0) slot.param2 = s.param2;
1540
- return slot;
1541
- })
1542
- }));
1543
1649
  return jsonResult(await logAthleteSet(ctx.client, {
1544
1650
  date,
1545
1651
  savedWorkoutSetId: toId(savedWorkoutSetId),
1546
- results: mapped
1652
+ results: toSetResults(results)
1547
1653
  }));
1548
1654
  }));
1549
1655
  }
@@ -1574,7 +1680,7 @@ function registerAthleteTrainingTools(server, ctx) {
1574
1680
  }
1575
1681
  //#endregion
1576
1682
  //#region package.json
1577
- var version = "1.4.0";
1683
+ var version = "1.5.0";
1578
1684
  //#endregion
1579
1685
  //#region src/server.ts
1580
1686
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/athlete-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.5.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": "1.4.0",
25
- "@trainheroic-unofficial/js": "1.4.0"
24
+ "@trainheroic-unofficial/core": "1.5.0",
25
+ "@trainheroic-unofficial/js": "1.5.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^26.0.0",