@trainheroic-unofficial/athlete-mcp 0.5.0 → 0.6.1
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/dist/server.mjs +168 -23
- package/package.json +3 -3
package/dist/server.mjs
CHANGED
|
@@ -684,35 +684,134 @@ function prescribedSets(ex) {
|
|
|
684
684
|
}
|
|
685
685
|
return out;
|
|
686
686
|
}
|
|
687
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
}))
|
|
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:
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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.
|
|
1268
|
+
var version = "0.6.1";
|
|
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.
|
|
3
|
+
"version": "0.6.1",
|
|
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.
|
|
25
|
-
"@trainheroic-unofficial/js": "0.
|
|
24
|
+
"@trainheroic-unofficial/core": "0.6.1",
|
|
25
|
+
"@trainheroic-unofficial/js": "0.6.1"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^26.0.0",
|