@trainheroic-unofficial/athlete-mcp 1.6.0 → 1.7.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 +204 -86
  2. package/package.json +4 -4
package/dist/server.mjs CHANGED
@@ -195,7 +195,14 @@ z.object({
195
195
  sets: z.array(loggedSetSchema).min(1)
196
196
  })).min(1)
197
197
  });
198
- z.object({
198
+ /**
199
+ * Args for the coach per-athlete exercise swap: replace the exercise prescribed in one of a
200
+ * roster athlete's saved-workout slots with a different exercise, the API equivalent of the
201
+ * app's per-athlete "swap exercise". `savedWorkoutSetExerciseId` is that athlete's own slot id
202
+ * (the same id `coachLogSetArgsSchema` uses, read off athlete_saved_workouts raw);
203
+ * `exerciseId` is the replacement exercise. The team/program prescription is left untouched.
204
+ */
205
+ const swapAthleteExerciseArgsSchema = z.object({
199
206
  savedWorkoutSetExerciseId: idArgSchema,
200
207
  exerciseId: idArgSchema
201
208
  });
@@ -775,66 +782,40 @@ function nonEmpty(value) {
775
782
  return value !== void 0 && value !== null && String(value).trim() !== "";
776
783
  }
777
784
  /**
778
- * Per-set prescriptions from the param_N_data slots, e.g. ["5 @ 225", "3 @ 245"] or ["AMRAP"].
779
- * Values are kept raw (a non-numeric prescription like "AMRAP" or "8-12" must survive); the
780
- * positional units come from the exercise's param types, mirroring the coach presenter.
785
+ * Slot-indexed values for one exercise. With `requireMade`, only slots the athlete logged
786
+ * (`param_i_made === 1`) the performed reader; otherwise every slot carrying data the
787
+ * prescription reader. Values stay raw; the projectors format or parse them.
781
788
  */
782
- function prescribedSets(ex) {
783
- const out = [];
789
+ function slotValues(ex, requireMade) {
790
+ const map = /* @__PURE__ */ new Map();
784
791
  for (let i = 1; i <= 10; i += 1) {
792
+ if (requireMade && coerceInt(ex[`param_${i}_made`]) !== 1) continue;
785
793
  const p1 = ex[`param_1_data_${i}`];
786
794
  const p2 = ex[`param_2_data_${i}`];
787
- const has1 = nonEmpty(p1);
788
- const has2 = nonEmpty(p2);
789
- if (!has1 && !has2) continue;
790
- if (has1 && has2) out.push(`${p1} @ ${p2}`);
791
- else if (has1) out.push(String(p1));
792
- else out.push(`@ ${p2}`);
795
+ if (!nonEmpty(p1) && !nonEmpty(p2)) continue;
796
+ map.set(i, {
797
+ p1: nonEmpty(p1) ? p1 : null,
798
+ p2: nonEmpty(p2) ? p2 : null
799
+ });
793
800
  }
794
- return out;
801
+ return map;
795
802
  }
796
- /**
797
- * The per-set values the athlete actually logged, read from a saved-copy exercise. A set
798
- * counts as performed only when its `param_{i}_made` flag is 1: the saved copy pre-fills the
799
- * `param_N_data` slots with the prescription, so the presence of data alone does not mean a
800
- * set was done. `param_{i}_made` is the same per-set flag the logging write sets, and is the
801
- * only reliable signal (the `completed` flags are often left at 0 on a logged session).
802
- */
803
- function performedSets(ex) {
804
- const out = [];
805
- for (let i = 1; i <= 10; i += 1) {
806
- if (coerceInt(ex[`param_${i}_made`]) !== 1) continue;
807
- const p1 = ex[`param_1_data_${i}`];
808
- const p2 = ex[`param_2_data_${i}`];
809
- const has1 = nonEmpty(p1);
810
- const has2 = nonEmpty(p2);
811
- if (has1 && has2) out.push(`${p1} @ ${p2}`);
812
- else if (has1) out.push(String(p1));
813
- else if (has2) out.push(`@ ${p2}`);
814
- }
815
- return out;
803
+ /** The joined display for a slot, kept verbatim (`"5 @ 225"`, `"AMRAP"`, `"@ 225"`). */
804
+ function fmtSlot(slot) {
805
+ const has1 = slot.p1 !== null;
806
+ const has2 = slot.p2 !== null;
807
+ if (has1 && has2) return `${slot.p1} @ ${slot.p2}`;
808
+ if (has1) return String(slot.p1);
809
+ if (has2) return `@ ${slot.p2}`;
810
+ return "";
816
811
  }
817
- function presentExercise(ex, performedById) {
818
- const instruction = typeof ex.instruction === "string" && ex.instruction !== "" ? ex.instruction : null;
819
- const id = coerceInt(ex.id);
820
- return {
821
- exerciseId: coerceInt(ex.exercise_id),
822
- title: typeof ex.title === "string" ? ex.title : "",
823
- instruction,
824
- units: exerciseUnits(ex.param_1_type, ex.param_2_type),
825
- prescribed: prescribedSets(ex),
826
- performed: (id !== null ? performedById.get(id) : void 0) ?? []
827
- };
812
+ /** Every slot carrying data, joined for display (the prescription reader): `["5 @ 225", "AMRAP"]`. */
813
+ function prescribedStrings(ex) {
814
+ return [...slotValues(ex, false).values()].map(fmtSlot);
828
815
  }
829
- function presentBlock(set, performedById) {
830
- const exercises = Array.isArray(set.workoutSetExercises) ? set.workoutSetExercises : [];
831
- return {
832
- order: coerceInt(set.order) ?? 0,
833
- title: typeof set.title === "string" && set.title !== "" ? set.title : null,
834
- instruction: typeof set.instruction === "string" && set.instruction !== "" ? set.instruction : null,
835
- isTest: coerceInt(set.is_test) === 1,
836
- exercises: exercises.filter(isRecord).map((ex) => presentExercise(ex, performedById))
837
- };
816
+ /** The slots the athlete logged (`param_i_made === 1`), joined for display (the performed reader). */
817
+ function performedStrings(ex) {
818
+ return [...slotValues(ex, true).values()].map(fmtSlot);
838
819
  }
839
820
  /** Every logged set (programmed + athlete-added) in the saved copy, paired with its exercises. */
840
821
  function savedSets(saved) {
@@ -853,54 +834,82 @@ function savedSets(saved) {
853
834
  return out;
854
835
  }
855
836
  /**
856
- * Map each prescription exercise id to the per-set values the athlete logged. In the saved
857
- * copy, `workout_set_exercise_id` points back at the prescription exercise's `id`, and the
858
- * entered values live in the same `param_N_data` slots as a prescription — so the
859
- * prescription reader works on them unchanged.
837
+ * Map each prescription exercise id to the slot-indexed values the athlete logged against it. In
838
+ * the saved copy `workout_set_exercise_id` points back at the prescription exercise's `id`, and the
839
+ * entered values live in the same `param_N_data` slots as a prescription.
860
840
  */
861
- function performedByExerciseId(sets) {
841
+ function performedSlotsByExerciseId(sets) {
862
842
  const map = /* @__PURE__ */ new Map();
863
843
  for (const { exercises } of sets) for (const ex of exercises) {
864
844
  const id = coerceInt(ex.workout_set_exercise_id);
865
845
  if (id === null) continue;
866
- const values = performedSets(ex);
867
- if (values.length > 0) map.set(id, values);
846
+ const slots = slotValues(ex, true);
847
+ if (slots.size > 0) map.set(id, slots);
868
848
  }
869
849
  return map;
870
850
  }
871
- /** Present a logged set straight from the saved copy (athlete-added or personal work). */
872
- function presentSavedBlock(set, exercises) {
851
+ /** Align an exercise's prescribed slots with the slots the athlete logged, per set index. */
852
+ function mergeExercise(prescribed, performed, meta) {
853
+ const indices = [.../* @__PURE__ */ new Set([...prescribed.keys(), ...performed.keys()])].sort((a, b) => a - b);
854
+ return {
855
+ ...meta,
856
+ sets: indices.map((i) => ({
857
+ set: i,
858
+ prescribed: prescribed.get(i) ?? null,
859
+ performed: performed.get(i) ?? null
860
+ }))
861
+ };
862
+ }
863
+ /** A prescription block, each exercise aligned with the slots the athlete logged against it. */
864
+ function mergePrescriptionBlock(set, performedById) {
865
+ const exercises = Array.isArray(set.workoutSetExercises) ? set.workoutSetExercises : [];
873
866
  return {
874
867
  order: coerceInt(set.order) ?? 0,
875
- title: typeof set.title === "string" && set.title !== "" ? set.title : null,
876
- instruction: typeof set.instruction === "string" && set.instruction !== "" ? set.instruction : null,
868
+ title: str(set.title),
869
+ instruction: str(set.instruction),
877
870
  isTest: coerceInt(set.is_test) === 1,
878
- exercises: exercises.map((ex) => ({
871
+ exercises: exercises.filter(isRecord).map((ex) => {
872
+ const id = coerceInt(ex.id);
873
+ const performed = (id !== null ? performedById.get(id) : void 0) ?? /* @__PURE__ */ new Map();
874
+ return mergeExercise(slotValues(ex, false), performed, {
875
+ exerciseId: coerceInt(ex.exercise_id),
876
+ title: typeof ex.title === "string" ? ex.title : "",
877
+ instruction: str(ex.instruction),
878
+ units: exerciseUnits(ex.param_1_type, ex.param_2_type)
879
+ });
880
+ })
881
+ };
882
+ }
883
+ /** A logged block straight from the saved copy (athlete-added or personal work; no prescription). */
884
+ function mergeSavedBlock(set, exercises) {
885
+ return {
886
+ order: coerceInt(set.order) ?? 0,
887
+ title: str(set.title),
888
+ instruction: str(set.instruction),
889
+ isTest: coerceInt(set.is_test) === 1,
890
+ exercises: exercises.map((ex) => mergeExercise(/* @__PURE__ */ new Map(), slotValues(ex, true), {
879
891
  exerciseId: coerceInt(ex.exercise_id),
880
892
  title: typeof ex.exercise_title === "string" ? ex.exercise_title : "",
881
- instruction: typeof ex.instruction === "string" && ex.instruction !== "" ? ex.instruction : null,
882
- units: exerciseUnits(ex.param_1_type, ex.param_2_type),
883
- prescribed: [],
884
- performed: performedSets(ex)
893
+ instruction: str(ex.instruction),
894
+ units: exerciseUnits(ex.param_1_type, ex.param_2_type)
885
895
  }))
886
896
  };
887
897
  }
888
898
  /**
889
- * Flatten one `/3.0/athlete/programworkout/range` item into a readable workout, merging the
899
+ * Flatten one `/3.0/athlete/programworkout/range` item into the canonical merged form, joining the
890
900
  * prescription (`summarizedSavedWorkout.workout`) with what the athlete logged
891
- * (`summarizedSavedWorkout.saved_workout`). Each exercise carries both its `prescribed` and
892
- * `performed` sets; athlete-added/personal work that has no prescription is appended as its
893
- * own blocks. No `raw` is needed to see logged results.
901
+ * (`summarizedSavedWorkout.saved_workout`). Prescription blocks are enriched with logged slots;
902
+ * athlete-added / personal work with no prescription is appended as its own block.
894
903
  */
895
- function presentAthleteWorkout(raw) {
904
+ function mergeAthleteWorkout(raw) {
896
905
  const rec = raw;
897
906
  const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
898
907
  const workout = isRecord(ssw.workout) ? ssw.workout : {};
899
908
  const saved = isRecord(ssw.saved_workout) ? ssw.saved_workout : {};
900
909
  const prescriptionSets = (Array.isArray(workout.workoutSets) ? workout.workoutSets : []).filter(isRecord);
901
910
  const logged = savedSets(saved);
902
- const performedById = performedByExerciseId(logged);
903
- const blocks = prescriptionSets.map((s) => presentBlock(s, performedById)).sort((a, b) => a.order - b.order);
911
+ const performedById = performedSlotsByExerciseId(logged);
912
+ const blocks = prescriptionSets.map((s) => mergePrescriptionBlock(s, performedById)).sort((a, b) => a.order - b.order);
904
913
  const prescribedIds = /* @__PURE__ */ new Set();
905
914
  for (const s of prescriptionSets) {
906
915
  const exs = Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : [];
@@ -912,11 +921,11 @@ function presentAthleteWorkout(raw) {
912
921
  }
913
922
  for (const { set, exercises } of logged) {
914
923
  const extra = exercises.filter((ex) => {
915
- if (performedSets(ex).length === 0) return false;
924
+ if (slotValues(ex, true).size === 0) return false;
916
925
  const id = coerceInt(ex.workout_set_exercise_id);
917
926
  return id === null || !prescribedIds.has(id);
918
927
  });
919
- if (extra.length > 0) blocks.push(presentSavedBlock(set, extra));
928
+ if (extra.length > 0) blocks.push(mergeSavedBlock(set, extra));
920
929
  }
921
930
  return {
922
931
  id: coerceInt(rec.id),
@@ -925,11 +934,58 @@ function presentAthleteWorkout(raw) {
925
934
  program: str(rec.program_title),
926
935
  team: str(rec.team_title),
927
936
  instruction: str(workout.instruction),
928
- logged: blocks.some((b) => b.exercises.some((e) => e.performed.length > 0)),
937
+ logged: blocks.some((b) => b.exercises.some((e) => e.sets.some((s) => s.performed !== null))),
929
938
  personal: isPersonalSession(rec),
930
939
  blocks
931
940
  };
932
941
  }
942
+ /** The per-set displays for one side (prescribed or performed), in slot order. */
943
+ function sideStrings(sets, pick) {
944
+ const out = [];
945
+ for (const s of sets) {
946
+ const slot = pick(s);
947
+ if (slot) out.push(fmtSlot(slot));
948
+ }
949
+ return out;
950
+ }
951
+ function toStringExercise(ex) {
952
+ return {
953
+ exerciseId: ex.exerciseId,
954
+ title: ex.title,
955
+ instruction: ex.instruction,
956
+ units: ex.units,
957
+ prescribed: sideStrings(ex.sets, (s) => s.prescribed),
958
+ performed: sideStrings(ex.sets, (s) => s.performed)
959
+ };
960
+ }
961
+ function toStringBlock(b) {
962
+ return {
963
+ order: b.order,
964
+ title: b.title,
965
+ instruction: b.instruction,
966
+ isTest: b.isTest,
967
+ exercises: b.exercises.map(toStringExercise)
968
+ };
969
+ }
970
+ /**
971
+ * Flatten one range item into a readable workout: each exercise carries its `prescribed` and
972
+ * `performed` sets as joined `"5 @ 225"` strings; athlete-added/personal work that has no
973
+ * prescription is appended as its own blocks. No `raw` is needed to see logged results.
974
+ */
975
+ function presentAthleteWorkout(raw) {
976
+ const w = mergeAthleteWorkout(raw);
977
+ return {
978
+ id: w.id,
979
+ date: w.date,
980
+ title: w.title,
981
+ program: w.program,
982
+ team: w.team,
983
+ instruction: w.instruction,
984
+ logged: w.logged,
985
+ personal: w.personal,
986
+ blocks: w.blocks.map(toStringBlock)
987
+ };
988
+ }
933
989
  function presentAthleteWorkouts(list) {
934
990
  return list.map(presentAthleteWorkout);
935
991
  }
@@ -989,8 +1045,8 @@ function presentLogTargets(list) {
989
1045
  savedWorkoutSetExerciseId: id,
990
1046
  title: exerciseTitle(ex),
991
1047
  units: exerciseUnits(ex.param_1_type, ex.param_2_type),
992
- prescribed: prescribedSets(ex),
993
- performed: performedSets(ex)
1048
+ prescribed: prescribedStrings(ex),
1049
+ performed: performedStrings(ex)
994
1050
  };
995
1051
  }).filter((e) => e !== null);
996
1052
  targets.push({
@@ -1009,10 +1065,11 @@ function presentLogTargets(list) {
1009
1065
  return targets;
1010
1066
  }
1011
1067
  /**
1012
- * Narrow a presented workout list for the common "what did I actually do" reads. `loggedOnly`
1013
- * keeps only workouts the athlete logged a set on (the reliable signal, not the API's
1014
- * completion flag). `limit` keeps the most recent N by date (newest first). Both are pure
1015
- * post-filters over the presented view; the raw API path is left untouched.
1068
+ * Narrow a workout list for the common "what did I actually do" reads. `loggedOnly` keeps only
1069
+ * workouts the athlete logged a set on (the reliable signal, not the API's completion flag).
1070
+ * `limit` keeps the most recent N by date (newest first). Both are pure post-filters; the raw API
1071
+ * path is left untouched. Generic over the presented (`AthleteWorkoutView`) and exported
1072
+ * (`WorkoutHistoryExport`) shapes, which both carry `date` and `logged`, so one rule serves both.
1016
1073
  */
1017
1074
  function selectWorkouts(list, opts = {}) {
1018
1075
  let out = opts.loggedOnly === true ? list.filter((w) => w.logged) : [...list];
@@ -1309,6 +1366,41 @@ async function logForAthlete(client, args) {
1309
1366
  };
1310
1367
  }
1311
1368
  /**
1369
+ * Swap the exercise prescribed in one of an athlete's saved-workout slots for a different
1370
+ * exercise — the API equivalent of the app's per-athlete "swap exercise":
1371
+ * `PUT /v5/savedWorkoutSetExercises/{savedWorkoutSetExerciseId}?exerciseId={exerciseId}` with an
1372
+ * empty body. The new exercise rides in the query string, not the body.
1373
+ *
1374
+ * This overrides only this athlete's copy of the slot; the underlying team/program prescription
1375
+ * (`workout_set_exercise.exercise_id`) is untouched, so other athletes on the same program keep
1376
+ * the original exercise. A coach's session token may write another user's row because the row
1377
+ * already carries its owner's `user_id`.
1378
+ *
1379
+ * `savedWorkoutSetExerciseId` is the same id {@link logForAthlete}/{@link logAthleteSet} use,
1380
+ * read off the athlete's saved workouts (the raw view). `exerciseId` is any exercise id the org
1381
+ * can use (resolve one via the exercise index).
1382
+ *
1383
+ * NOTE: as with logging, TrainHeroic's seeded *demo* athletes are read-only and return 401/403;
1384
+ * real (invited) athletes accept the swap.
1385
+ */
1386
+ async function swapAthleteExercise(client, args) {
1387
+ const res = await client.request("PUT", `/v5/savedWorkoutSetExercises/${args.savedWorkoutSetExerciseId}?exerciseId=${args.exerciseId}`);
1388
+ if (!res.ok) {
1389
+ const readOnly = res.status === 401 || res.status === 403 ? ` Athlete may be read-only for changes — TrainHeroic's seeded demo/sample athletes return ${res.status} here; swaps only persist for real (invited) athletes.` : "";
1390
+ throw new Error(`Swap failed (HTTP ${res.status}) for savedWorkoutSetExercise ${args.savedWorkoutSetExerciseId}.${readOnly}`);
1391
+ }
1392
+ const row = isRecord(res.data) ? res.data : {};
1393
+ const template = isRecord(row.workout_set_exercise) ? row.workout_set_exercise : {};
1394
+ const exercise = isRecord(row.exercise) ? row.exercise : {};
1395
+ return {
1396
+ savedWorkoutSetExerciseId: args.savedWorkoutSetExerciseId,
1397
+ athleteId: coerceInt(row.user_id),
1398
+ newExerciseId: coerceInt(row.exercise_id) ?? args.exerciseId,
1399
+ newExerciseTitle: str(exercise.title),
1400
+ originalTeamExerciseId: coerceInt(template.exercise_id)
1401
+ };
1402
+ }
1403
+ /**
1312
1404
  * Shared set-write behind {@link logAthleteSet}, {@link logForAthlete}, and
1313
1405
  * {@link prescribeForAthlete}. `target` selects the surface: `athlete` writes `/1.0/athlete/...`;
1314
1406
  * `coach` writes `/1.0/coach/...{athleteId}` and stamps `athleteId` into each body.
@@ -1889,6 +1981,31 @@ function registerLogTool(server, ctx) {
1889
1981
  }));
1890
1982
  }
1891
1983
  /**
1984
+ * The athlete's own per-exercise swap, in its own function so registerLogTool/registerSessionTools
1985
+ * stay under the oxlint max-lines-per-function cap. Lets an athlete substitute a prescribed
1986
+ * exercise in a coach-scheduled workout for a different one — the self-service equivalent of the
1987
+ * app's "swap exercise", overriding only the athlete's own copy of that slot.
1988
+ */
1989
+ function registerSwapTool(server, ctx) {
1990
+ server.registerTool("athlete_swap_exercise", {
1991
+ title: "Swap one exercise in your scheduled workout",
1992
+ description: "Athlete-facing write: substitute one prescribed exercise in your scheduled workout for a different one — the API equivalent of the app's \"swap exercise\". This is the way to change which movement a slot is BEFORE logging: session_add/remove only touch personal sessions, and athlete_log_set records results into a slot without changing its exercise, so use this when a COACH-SCHEDULED slot should be a different lift. It overrides only your copy of the slot; the team/program prescription is untouched. Give savedWorkoutSetExerciseId (the slot to change, from athlete_log_targets — each row lists it per exercise) and exerciseId (the replacement, from athlete_exercises). After swapping, log against the slot as usual with athlete_log_set. Requires confirmation (elicitation or confirm:true).",
1993
+ inputSchema: {
1994
+ ...swapAthleteExerciseArgsSchema.shape,
1995
+ confirm: z.boolean().optional()
1996
+ },
1997
+ annotations: DESTRUCTIVE
1998
+ }, ({ savedWorkoutSetExerciseId, exerciseId, confirm }, extra) => attempt(async () => {
1999
+ const sweId = toId(savedWorkoutSetExerciseId);
2000
+ const exId = toId(exerciseId);
2001
+ if (!await confirmGate(server, extra.requestId, `Swap the exercise in your saved workout slot ${sweId} to exercise ${exId}? This changes what that slot is prescribed (your copy only).`, confirm)) return errorResult(NOT_CONFIRMED);
2002
+ return jsonResult(await swapAthleteExercise(ctx.client, {
2003
+ savedWorkoutSetExerciseId: sweId,
2004
+ exerciseId: exId
2005
+ }));
2006
+ }));
2007
+ }
2008
+ /**
1892
2009
  * Live tools over the logged-in user's own training (history, scheduled/completed workouts,
1893
2010
  * PRs, working maxes), plus a gated set-logging write. The athlete user id is
1894
2011
  * resolved once from /user/simple and reused across tools.
@@ -1913,10 +2030,11 @@ function registerAthleteTrainingTools(server, ctx) {
1913
2030
  registerLogTargetsTool(server, ctx);
1914
2031
  registerSessionTools(server, ctx);
1915
2032
  registerLogTool(server, ctx);
2033
+ registerSwapTool(server, ctx);
1916
2034
  }
1917
2035
  //#endregion
1918
2036
  //#region package.json
1919
- var version = "1.6.0";
2037
+ var version = "1.7.0";
1920
2038
  //#endregion
1921
2039
  //#region src/server.ts
1922
2040
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/athlete-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,11 +21,11 @@
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.29.0",
23
23
  "zod": "^4.4.3",
24
- "@trainheroic-unofficial/core": "1.6.0",
25
- "@trainheroic-unofficial/js": "1.6.0"
24
+ "@trainheroic-unofficial/js": "1.7.0",
25
+ "@trainheroic-unofficial/core": "1.7.0"
26
26
  },
27
27
  "devDependencies": {
28
- "@types/node": "^26.0.0",
28
+ "@types/node": "^26.0.1",
29
29
  "tsdown": "^0.22.3",
30
30
  "tsx": "^4.22.4",
31
31
  "typescript": "^6.0.3",