@trainheroic-unofficial/athlete-mcp 0.6.0 → 0.6.2

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/README.md CHANGED
@@ -72,7 +72,7 @@ are `YYYY-MM-DD`); your MCP client shows the full schema for each.
72
72
 
73
73
  ## Develop
74
74
 
75
- Run `pnpm install` once at the repo root (Node >= 22, pnpm 10), then from this package. The
75
+ Run `pnpm install` once at the repo root (Node >= 24, pnpm 11), then from this package. The
76
76
  `pnpm start`/`pnpm inspect` commands need `TRAINHEROIC_EMAIL` and `TRAINHEROIC_PASSWORD`
77
77
  exported in your shell. "MCP Inspector" is the [official MCP debugging UI](https://github.com/modelcontextprotocol/inspector).
78
78
 
package/dist/server.mjs CHANGED
@@ -684,35 +684,134 @@ function prescribedSets(ex) {
684
684
  }
685
685
  return out;
686
686
  }
687
- function presentExercise(ex) {
687
+ /**
688
+ * The per-set values the athlete actually logged, read from a saved-copy exercise. A set
689
+ * counts as performed only when its `param_{i}_made` flag is 1: the saved copy pre-fills the
690
+ * `param_N_data` slots with the prescription, so the presence of data alone does not mean a
691
+ * set was done. `param_{i}_made` is the same per-set flag the logging write sets, and is the
692
+ * only reliable signal (the `completed` flags are often left at 0 on a logged session).
693
+ */
694
+ function performedSets(ex) {
695
+ const out = [];
696
+ for (let i = 1; i <= SLOTS; i += 1) {
697
+ if (coerceInt(ex[`param_${i}_made`]) !== 1) continue;
698
+ const p1 = ex[`param_1_data_${i}`];
699
+ const p2 = ex[`param_2_data_${i}`];
700
+ const has1 = nonEmpty(p1);
701
+ const has2 = nonEmpty(p2);
702
+ if (has1 && has2) out.push(`${p1} @ ${p2}`);
703
+ else if (has1) out.push(String(p1));
704
+ else if (has2) out.push(`@ ${p2}`);
705
+ }
706
+ return out;
707
+ }
708
+ function presentExercise(ex, performedById) {
688
709
  const instruction = typeof ex.instruction === "string" && ex.instruction !== "" ? ex.instruction : null;
710
+ const id = coerceInt(ex.id);
689
711
  return {
690
712
  exerciseId: coerceInt(ex.exercise_id),
691
713
  title: typeof ex.title === "string" ? ex.title : "",
692
714
  instruction,
693
715
  units: exerciseUnits(ex.param_1_type, ex.param_2_type),
694
- prescribed: prescribedSets(ex)
716
+ prescribed: prescribedSets(ex),
717
+ performed: (id !== null ? performedById.get(id) : void 0) ?? []
695
718
  };
696
719
  }
697
- function presentBlock(set) {
720
+ function presentBlock(set, performedById) {
698
721
  const exercises = Array.isArray(set.workoutSetExercises) ? set.workoutSetExercises : [];
699
722
  return {
700
723
  order: coerceInt(set.order) ?? 0,
701
724
  title: typeof set.title === "string" && set.title !== "" ? set.title : null,
702
725
  instruction: typeof set.instruction === "string" && set.instruction !== "" ? set.instruction : null,
703
726
  isTest: coerceInt(set.is_test) === 1,
704
- exercises: exercises.filter(isRecord).map(presentExercise)
727
+ exercises: exercises.filter(isRecord).map((ex) => presentExercise(ex, performedById))
705
728
  };
706
729
  }
707
730
  function str(v) {
708
731
  return typeof v === "string" && v !== "" ? v : null;
709
732
  }
710
- /** Flatten one `/3.0/athlete/programworkout/range` item into a readable workout. */
733
+ /** Every logged set (programmed + athlete-added) in the saved copy, paired with its exercises. */
734
+ function savedSets(saved) {
735
+ const out = [];
736
+ for (const key of ["workoutSets", "addedWorkoutSets"]) {
737
+ const sets = Array.isArray(saved[key]) ? saved[key] : [];
738
+ for (const set of sets) {
739
+ if (!isRecord(set)) continue;
740
+ const exercises = (Array.isArray(set.workoutSetExercises) ? set.workoutSetExercises : []).filter(isRecord);
741
+ out.push({
742
+ set,
743
+ exercises
744
+ });
745
+ }
746
+ }
747
+ return out;
748
+ }
749
+ /**
750
+ * Map each prescription exercise id to the per-set values the athlete logged. In the saved
751
+ * copy, `workout_set_exercise_id` points back at the prescription exercise's `id`, and the
752
+ * entered values live in the same `param_N_data` slots as a prescription — so the
753
+ * prescription reader works on them unchanged.
754
+ */
755
+ function performedByExerciseId(sets) {
756
+ const map = /* @__PURE__ */ new Map();
757
+ for (const { exercises } of sets) for (const ex of exercises) {
758
+ const id = coerceInt(ex.workout_set_exercise_id);
759
+ if (id === null) continue;
760
+ const values = performedSets(ex);
761
+ if (values.length > 0) map.set(id, values);
762
+ }
763
+ return map;
764
+ }
765
+ /** Present a logged set straight from the saved copy (athlete-added or personal work). */
766
+ function presentSavedBlock(set, exercises) {
767
+ return {
768
+ order: coerceInt(set.order) ?? 0,
769
+ title: typeof set.title === "string" && set.title !== "" ? set.title : null,
770
+ instruction: typeof set.instruction === "string" && set.instruction !== "" ? set.instruction : null,
771
+ isTest: coerceInt(set.is_test) === 1,
772
+ exercises: exercises.map((ex) => ({
773
+ exerciseId: coerceInt(ex.exercise_id),
774
+ title: typeof ex.exercise_title === "string" ? ex.exercise_title : "",
775
+ instruction: typeof ex.instruction === "string" && ex.instruction !== "" ? ex.instruction : null,
776
+ units: exerciseUnits(ex.param_1_type, ex.param_2_type),
777
+ prescribed: [],
778
+ performed: performedSets(ex)
779
+ }))
780
+ };
781
+ }
782
+ /**
783
+ * Flatten one `/3.0/athlete/programworkout/range` item into a readable workout, merging the
784
+ * prescription (`summarizedSavedWorkout.workout`) with what the athlete logged
785
+ * (`summarizedSavedWorkout.saved_workout`). Each exercise carries both its `prescribed` and
786
+ * `performed` sets; athlete-added/personal work that has no prescription is appended as its
787
+ * own blocks. No `raw` is needed to see logged results.
788
+ */
711
789
  function presentAthleteWorkout(raw) {
712
790
  const rec = raw;
713
791
  const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
714
792
  const workout = isRecord(ssw.workout) ? ssw.workout : {};
715
- const sets = Array.isArray(workout.workoutSets) ? workout.workoutSets : [];
793
+ const saved = isRecord(ssw.saved_workout) ? ssw.saved_workout : {};
794
+ const prescriptionSets = (Array.isArray(workout.workoutSets) ? workout.workoutSets : []).filter(isRecord);
795
+ const logged = savedSets(saved);
796
+ const performedById = performedByExerciseId(logged);
797
+ const blocks = prescriptionSets.map((s) => presentBlock(s, performedById)).sort((a, b) => a.order - b.order);
798
+ const prescribedIds = /* @__PURE__ */ new Set();
799
+ for (const s of prescriptionSets) {
800
+ const exs = Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : [];
801
+ for (const ex of exs) {
802
+ if (!isRecord(ex)) continue;
803
+ const id = coerceInt(ex.id);
804
+ if (id !== null) prescribedIds.add(id);
805
+ }
806
+ }
807
+ for (const { set, exercises } of logged) {
808
+ const extra = exercises.filter((ex) => {
809
+ if (performedSets(ex).length === 0) return false;
810
+ const id = coerceInt(ex.workout_set_exercise_id);
811
+ return id === null || !prescribedIds.has(id);
812
+ });
813
+ if (extra.length > 0) blocks.push(presentSavedBlock(set, extra));
814
+ }
716
815
  return {
717
816
  id: coerceInt(rec.id),
718
817
  date: str(rec.date) ?? "",
@@ -720,12 +819,24 @@ function presentAthleteWorkout(raw) {
720
819
  program: str(rec.program_title),
721
820
  team: str(rec.team_title),
722
821
  instruction: str(workout.instruction),
723
- blocks: sets.filter(isRecord).map(presentBlock).sort((a, b) => a.order - b.order)
822
+ logged: blocks.some((b) => b.exercises.some((e) => e.performed.length > 0)),
823
+ blocks
724
824
  };
725
825
  }
726
826
  function presentAthleteWorkouts(list) {
727
827
  return list.map(presentAthleteWorkout);
728
828
  }
829
+ /**
830
+ * Narrow a presented workout list for the common "what did I actually do" reads. `loggedOnly`
831
+ * keeps only workouts the athlete logged a set on (the reliable signal, not the API's
832
+ * completion flag). `limit` keeps the most recent N by date (newest first). Both are pure
833
+ * post-filters over the presented view; the raw API path is left untouched.
834
+ */
835
+ function selectWorkouts(list, opts = {}) {
836
+ let out = opts.loggedOnly === true ? list.filter((w) => w.logged) : [...list];
837
+ if (opts.limit !== void 0) out = [...out].sort((a, b) => a.date < b.date ? 1 : a.date > b.date ? -1 : 0).slice(0, opts.limit);
838
+ return out;
839
+ }
729
840
  const MAX_PARAM_SLOTS = 10;
730
841
  /**
731
842
  * Build the body for `PUT /1.0/athlete/savedworkoutsetexercise/{id}`. The body uses
@@ -919,6 +1030,27 @@ function presentExerciseHistory(detail) {
919
1030
  };
920
1031
  }
921
1032
  //#endregion
1033
+ //#region ../core/src/history.ts
1034
+ /**
1035
+ * Trim a presented exercise history's session time-series to an inclusive YYYY-MM-DD window.
1036
+ * The `liftPRs` board stays all-time (PRs are not a windowed concept). Dates compare as their
1037
+ * first 10 chars so both "YYYY-MM-DD" and "YYYY-MM-DDThh:mm" values filter correctly. Shared by
1038
+ * the athlete's own history tool and the coach's per-roster-athlete history tool.
1039
+ */
1040
+ function historyInRange(presented, since, until) {
1041
+ if (since === void 0 && until === void 0) return presented;
1042
+ const sessions = presented.sessions.filter((s) => {
1043
+ const d = (s.date ?? "").slice(0, 10);
1044
+ if (since !== void 0 && d < since) return false;
1045
+ if (until !== void 0 && d > until) return false;
1046
+ return true;
1047
+ });
1048
+ return {
1049
+ ...presented,
1050
+ sessions
1051
+ };
1052
+ }
1053
+ //#endregion
922
1054
  //#region ../core/src/tools/athlete-training.ts
923
1055
  /** Identity, profile, prefs, working maxes, leaderboard. */
924
1056
  function registerProfileTools(server, ctx, whoami, userId) {
@@ -930,7 +1062,7 @@ function registerProfileTools(server, ctx, whoami, userId) {
930
1062
  }, () => attempt(async () => jsonResult(await whoami())));
931
1063
  server.registerTool("athlete_profile", {
932
1064
  title: "Athlete profile + lifetime totals",
933
- description: "Lifetime training totals (reps, volume, sessions, first/last logged) plus the profile (name, units, dob). Set useMetric for kg/metric totals.",
1065
+ description: "Lifetime training totals in one call — all-time session count (summary.sessions_count), total reps and volume, first/last logged date — plus the profile (name, units, dob). Use this for any 'how many sessions all-time / total volume ever' question rather than summing athlete_workouts windows. Set useMetric for kg/metric totals.",
934
1066
  inputSchema: { useMetric: z.boolean().optional() },
935
1067
  annotations: READ
936
1068
  }, ({ useMetric }) => attempt(async () => {
@@ -949,7 +1081,7 @@ function registerProfileTools(server, ctx, whoami, userId) {
949
1081
  }, () => attempt(async () => jsonResult(await fetchAthletePrefs(ctx.client))));
950
1082
  server.registerTool("athlete_working_maxes", {
951
1083
  title: "Working maxes",
952
- description: "The athlete's working max per exercise (drives % prescriptions).",
1084
+ description: "The athlete's working max per exercise (drives % prescriptions). An entry can carry a null value: the exercise has a working-max slot but no number has been set yet, which means there is effectively no working max for it.",
953
1085
  inputSchema: {},
954
1086
  annotations: READ
955
1087
  }, () => attempt(async () => jsonResult(await fetchWorkingMaxes(ctx.client))));
@@ -971,58 +1103,71 @@ function registerProfileTools(server, ctx, whoami, userId) {
971
1103
  return jsonResult(await fetchLeaderboard(ctx.client, toId(workoutId), opts));
972
1104
  }));
973
1105
  }
1106
+ const ATHLETE_WORKOUTS_DESC = "Workouts in an inclusive YYYY-MM-DD window, flattened to blocks/exercises. Each exercise carries both its `prescribed` sets (what the program called for) and `performed` sets (what the athlete actually logged); each workout has a top-level `logged` flag. Use `performed`/`logged` to tell what was recorded or done. That is the reliable signal: a session can hold logged sets while the API's own completion flags stay 0, and an empty `performed` means nothing was logged for that exercise. For 'did I record anything / what did I do', set loggedOnly:true to return only sessions with logged sets (it keeps whole sessions that have any logged set, so individual exercises inside can still show empty `performed`; it also shrinks a large result); limit returns the most recent N workouts (newest first). For 'what's my next workout', query a forward window from today; if it comes back empty, the most recent session is likely yesterday's still-unlogged one, so widen the window backward a day or two. Both filters apply to the presented view, not raw. raw:true returns the untouched API objects. This is a date-windowed fetch, NOT an aggregate: for lifetime totals (all-time session count, total volume, first/last logged date) call athlete_profile instead of summing windows — a multi-year range here can time out. Narrow the window if the result is truncated.";
1107
+ const ATHLETE_EXERCISE_HISTORY_DESC = "Per-exercise PRs and the dated session time-series (sets performed, estimated 1RM). The returned `sessions` run all-time, newest first; pass since/until (YYYY-MM-DD, inclusive) to keep only sessions in that window — use it for 'last 3 months' / 'this year' questions so the result stays small. `liftPRs` are always all-time and ignore since/until. Set raw:true for the untouched API object. Get the exercise id from athlete_exercises.";
1108
+ const ATHLETE_EXERCISES_DESC = "The exercises the athlete has logged (id + title + positional units). Pass q to free-text search by name; use the returned id with athlete_exercise_history, athlete_personal_records, or athlete_exercise_stats (all of which require an exercise id).";
974
1109
  /** Workouts, exercise catalog, per-exercise history/PRs/stats. */
975
1110
  function registerExerciseTools(server, ctx, userId) {
976
1111
  server.registerTool("athlete_workouts", {
977
1112
  title: "Workouts in a date range",
978
- description: "Scheduled + completed workouts in an inclusive YYYY-MM-DD window, flattened to blocks/exercises with per-set prescriptions and positional units. Set raw:true for the untouched API objects. Narrow the window if the result is truncated.",
1113
+ description: ATHLETE_WORKOUTS_DESC,
979
1114
  inputSchema: {
980
1115
  startDate: dateString,
981
1116
  endDate: dateString,
982
- raw: z.boolean().optional()
1117
+ raw: z.boolean().optional(),
1118
+ loggedOnly: z.boolean().optional(),
1119
+ limit: z.number().int().positive().max(200).optional()
983
1120
  },
984
1121
  annotations: READ
985
- }, ({ startDate, endDate, raw }) => attempt(async () => {
1122
+ }, ({ startDate, endDate, raw, loggedOnly, limit }) => attempt(async () => {
986
1123
  const workouts = await fetchAthleteWorkouts(ctx.client, startDate, endDate);
987
- return jsonResult(raw === true ? workouts : presentAthleteWorkouts(workouts), { hint: "Narrow startDate/endDate to shrink this result." });
1124
+ if (raw === true) return jsonResult(workouts, { hint: "Narrow startDate/endDate to shrink this result." });
1125
+ const opts = {};
1126
+ if (loggedOnly !== void 0) opts.loggedOnly = loggedOnly;
1127
+ if (limit !== void 0) opts.limit = limit;
1128
+ return jsonResult(selectWorkouts(presentAthleteWorkouts(workouts), opts), { hint: "Large? Set loggedOnly:true, pass limit, or narrow startDate/endDate." });
988
1129
  }));
989
1130
  server.registerTool("athlete_exercises", {
990
1131
  title: "Search logged exercises",
991
- description: "The exercises the athlete has logged (id + title + positional units). Pass q to free-text search by name; use the returned id with athlete_exercise_history / _stats.",
1132
+ description: ATHLETE_EXERCISES_DESC,
992
1133
  inputSchema: {
993
1134
  q: z.string().optional(),
994
1135
  limit: z.number().int().positive().max(200).optional()
995
1136
  },
996
1137
  annotations: READ
997
1138
  }, ({ q, limit }) => attempt(async () => {
998
- return jsonResult((q !== void 0 && q.trim() !== "" ? await searchExerciseHistory(ctx.client, q, limit ?? 20) : await fetchExerciseHistoryList(ctx.client)).map((r) => ({
1139
+ const items = (q !== void 0 && q.trim() !== "" ? await searchExerciseHistory(ctx.client, q, limit ?? 20) : await fetchExerciseHistoryList(ctx.client)).map((r) => ({
999
1140
  id: r.id,
1000
1141
  title: r.title,
1001
1142
  isCircuit: r.isCircuit ?? false,
1002
1143
  units: exerciseUnits(r.param1Type, r.param2Type)
1003
- })), { hint: "Pass q to search by name, or limit to cap the list." });
1144
+ }));
1145
+ return jsonResult(limit !== void 0 ? items.slice(0, limit) : items, { hint: "Pass q to search by name, or limit to cap the list." });
1004
1146
  }));
1005
1147
  server.registerTool("athlete_exercise_history", {
1006
1148
  title: "Exercise history + PRs",
1007
- description: "Per-exercise PRs and the dated session time-series (sets performed, estimated 1RM). Set raw:true for the untouched API object. Get the exercise id from athlete_exercises.",
1149
+ description: ATHLETE_EXERCISE_HISTORY_DESC,
1008
1150
  inputSchema: {
1009
1151
  exerciseId: idParam,
1010
- raw: z.boolean().optional()
1152
+ raw: z.boolean().optional(),
1153
+ since: dateString.optional(),
1154
+ until: dateString.optional()
1011
1155
  },
1012
1156
  annotations: READ
1013
- }, ({ exerciseId, raw }) => attempt(async () => {
1157
+ }, ({ exerciseId, raw, since, until }) => attempt(async () => {
1014
1158
  const detail = await fetchExerciseHistoryDetail(ctx.client, toId(exerciseId), await userId());
1015
- return jsonResult(raw === true ? detail : presentExerciseHistory(detail));
1159
+ if (raw === true) return jsonResult(detail);
1160
+ return jsonResult(historyInRange(presentExerciseHistory(detail), since, until));
1016
1161
  }));
1017
1162
  server.registerTool("athlete_personal_records", {
1018
1163
  title: "Exercise personal records",
1019
- description: "Personal records for an exercise (reps/weight, strength-standard filters).",
1164
+ description: "The all-time PR board for an exercise (reps/weight per rep-max, strength-standard filters). Get the exercise id from athlete_exercises. For a point-in-time snapshot use athlete_exercise_stats; for the dated session trend use athlete_exercise_history.",
1020
1165
  inputSchema: { exerciseId: idParam },
1021
1166
  annotations: READ
1022
1167
  }, ({ exerciseId }) => attempt(async () => jsonResult(await fetchPersonalRecords(ctx.client, toId(exerciseId)))));
1023
1168
  server.registerTool("athlete_exercise_stats", {
1024
1169
  title: "Exercise stats (last performance + PR)",
1025
- description: "Last performance and PR for an exercise as of a date (YYYY-MM-DD, required by the API).",
1170
+ description: "A point-in-time snapshot for an exercise: last performance and PR as of a date (YYYY-MM-DD, required by the API). Not a range query — for the all-time board use athlete_personal_records, for progress over time use athlete_exercise_history. Get the exercise id from athlete_exercises.",
1026
1171
  inputSchema: {
1027
1172
  exerciseId: idParam,
1028
1173
  date: dateString
@@ -1120,7 +1265,7 @@ function registerAthleteTrainingTools(server, ctx) {
1120
1265
  }
1121
1266
  //#endregion
1122
1267
  //#region package.json
1123
- var version = "0.4.1";
1268
+ var version = "0.6.2";
1124
1269
  //#endregion
1125
1270
  //#region src/server.ts
1126
1271
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/athlete-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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.0",
25
- "@trainheroic-unofficial/js": "0.6.0"
24
+ "@trainheroic-unofficial/core": "0.6.2",
25
+ "@trainheroic-unofficial/js": "0.6.2"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^26.0.0",