@trainheroic-unofficial/athlete-mcp 1.6.1 → 1.7.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 +243 -107
- package/package.json +3 -3
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
|
-
|
|
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
|
});
|
|
@@ -331,6 +338,23 @@ const DEFAULT_RESULT_BUDGET = 6e4;
|
|
|
331
338
|
const MARKER_RESERVE = 300;
|
|
332
339
|
const DEFAULT_ARRAY_HINT = "Result was truncated to fit the size budget. Narrow it with a filter/search argument or paginate to see the rest.";
|
|
333
340
|
const DEFAULT_OBJECT_HINT = "Result was truncated to fit the size budget. Request a more specific id or sub-resource.";
|
|
341
|
+
/**
|
|
342
|
+
* Clip an array to its first `keep` items and attach the `__truncated` marker describing what was
|
|
343
|
+
* dropped. The marker is model-facing (tools instruct the model to key off `__truncated`), so its
|
|
344
|
+
* shape has exactly one definition here: the size-budget path (`boundedSerialize`) and any tool
|
|
345
|
+
* that deliberately caps a list (e.g. `athlete_exercises`) emit the same thing.
|
|
346
|
+
*/
|
|
347
|
+
function clipArray(items, keep, hint) {
|
|
348
|
+
return {
|
|
349
|
+
items: items.slice(0, keep),
|
|
350
|
+
__truncated: {
|
|
351
|
+
returned: keep,
|
|
352
|
+
total: items.length,
|
|
353
|
+
omitted: items.length - keep,
|
|
354
|
+
hint: hint ?? DEFAULT_ARRAY_HINT
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
334
358
|
/** Active budget. Overridable via TH_MCP_RESULT_BUDGET on Node; the default on workerd. */
|
|
335
359
|
function resultBudget() {
|
|
336
360
|
const raw = (globalThis.process?.env)?.TH_MCP_RESULT_BUDGET;
|
|
@@ -387,16 +411,7 @@ function boundedSerialize(data, budget, hint) {
|
|
|
387
411
|
}
|
|
388
412
|
if (Array.isArray(data)) {
|
|
389
413
|
const k = largestPrefixCount(data.map((el) => JSON.stringify(el) ?? "null"), budget - MARKER_RESERVE);
|
|
390
|
-
const
|
|
391
|
-
items: data.slice(0, k),
|
|
392
|
-
__truncated: {
|
|
393
|
-
returned: k,
|
|
394
|
-
total: data.length,
|
|
395
|
-
omitted: data.length - k,
|
|
396
|
-
hint: hint ?? DEFAULT_ARRAY_HINT
|
|
397
|
-
}
|
|
398
|
-
};
|
|
399
|
-
const out = JSON.stringify(wrapped);
|
|
414
|
+
const out = JSON.stringify(clipArray(data, k, hint));
|
|
400
415
|
if (out.length <= budget) return out;
|
|
401
416
|
} else if (isPlainObject(data)) {
|
|
402
417
|
const key = largestArrayValuedKey(data);
|
|
@@ -775,66 +790,40 @@ function nonEmpty(value) {
|
|
|
775
790
|
return value !== void 0 && value !== null && String(value).trim() !== "";
|
|
776
791
|
}
|
|
777
792
|
/**
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
793
|
+
* Slot-indexed values for one exercise. With `requireMade`, only slots the athlete logged
|
|
794
|
+
* (`param_i_made === 1`) — the performed reader; otherwise every slot carrying data — the
|
|
795
|
+
* prescription reader. Values stay raw; the projectors format or parse them.
|
|
781
796
|
*/
|
|
782
|
-
function
|
|
783
|
-
const
|
|
797
|
+
function slotValues(ex, requireMade) {
|
|
798
|
+
const map = /* @__PURE__ */ new Map();
|
|
784
799
|
for (let i = 1; i <= 10; i += 1) {
|
|
800
|
+
if (requireMade && coerceInt(ex[`param_${i}_made`]) !== 1) continue;
|
|
785
801
|
const p1 = ex[`param_1_data_${i}`];
|
|
786
802
|
const p2 = ex[`param_2_data_${i}`];
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
else out.push(`@ ${p2}`);
|
|
803
|
+
if (!nonEmpty(p1) && !nonEmpty(p2)) continue;
|
|
804
|
+
map.set(i, {
|
|
805
|
+
p1: nonEmpty(p1) ? p1 : null,
|
|
806
|
+
p2: nonEmpty(p2) ? p2 : null
|
|
807
|
+
});
|
|
793
808
|
}
|
|
794
|
-
return
|
|
809
|
+
return map;
|
|
795
810
|
}
|
|
796
|
-
/**
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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;
|
|
811
|
+
/** The joined display for a slot, kept verbatim (`"5 @ 225"`, `"AMRAP"`, `"@ 225"`). */
|
|
812
|
+
function fmtSlot(slot) {
|
|
813
|
+
const has1 = slot.p1 !== null;
|
|
814
|
+
const has2 = slot.p2 !== null;
|
|
815
|
+
if (has1 && has2) return `${slot.p1} @ ${slot.p2}`;
|
|
816
|
+
if (has1) return String(slot.p1);
|
|
817
|
+
if (has2) return `@ ${slot.p2}`;
|
|
818
|
+
return "";
|
|
816
819
|
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
};
|
|
820
|
+
/** Every slot carrying data, joined for display (the prescription reader): `["5 @ 225", "AMRAP"]`. */
|
|
821
|
+
function prescribedStrings(ex) {
|
|
822
|
+
return [...slotValues(ex, false).values()].map(fmtSlot);
|
|
828
823
|
}
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
};
|
|
824
|
+
/** The slots the athlete logged (`param_i_made === 1`), joined for display (the performed reader). */
|
|
825
|
+
function performedStrings(ex) {
|
|
826
|
+
return [...slotValues(ex, true).values()].map(fmtSlot);
|
|
838
827
|
}
|
|
839
828
|
/** Every logged set (programmed + athlete-added) in the saved copy, paired with its exercises. */
|
|
840
829
|
function savedSets(saved) {
|
|
@@ -853,54 +842,82 @@ function savedSets(saved) {
|
|
|
853
842
|
return out;
|
|
854
843
|
}
|
|
855
844
|
/**
|
|
856
|
-
* Map each prescription exercise id to the
|
|
857
|
-
* copy
|
|
858
|
-
* entered values live in the same `param_N_data` slots as a prescription
|
|
859
|
-
* prescription reader works on them unchanged.
|
|
845
|
+
* Map each prescription exercise id to the slot-indexed values the athlete logged against it. In
|
|
846
|
+
* the saved copy `workout_set_exercise_id` points back at the prescription exercise's `id`, and the
|
|
847
|
+
* entered values live in the same `param_N_data` slots as a prescription.
|
|
860
848
|
*/
|
|
861
|
-
function
|
|
849
|
+
function performedSlotsByExerciseId(sets) {
|
|
862
850
|
const map = /* @__PURE__ */ new Map();
|
|
863
851
|
for (const { exercises } of sets) for (const ex of exercises) {
|
|
864
852
|
const id = coerceInt(ex.workout_set_exercise_id);
|
|
865
853
|
if (id === null) continue;
|
|
866
|
-
const
|
|
867
|
-
if (
|
|
854
|
+
const slots = slotValues(ex, true);
|
|
855
|
+
if (slots.size > 0) map.set(id, slots);
|
|
868
856
|
}
|
|
869
857
|
return map;
|
|
870
858
|
}
|
|
871
|
-
/**
|
|
872
|
-
function
|
|
859
|
+
/** Align an exercise's prescribed slots with the slots the athlete logged, per set index. */
|
|
860
|
+
function mergeExercise(prescribed, performed, meta) {
|
|
861
|
+
const indices = [.../* @__PURE__ */ new Set([...prescribed.keys(), ...performed.keys()])].sort((a, b) => a - b);
|
|
862
|
+
return {
|
|
863
|
+
...meta,
|
|
864
|
+
sets: indices.map((i) => ({
|
|
865
|
+
set: i,
|
|
866
|
+
prescribed: prescribed.get(i) ?? null,
|
|
867
|
+
performed: performed.get(i) ?? null
|
|
868
|
+
}))
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
/** A prescription block, each exercise aligned with the slots the athlete logged against it. */
|
|
872
|
+
function mergePrescriptionBlock(set, performedById) {
|
|
873
|
+
const exercises = Array.isArray(set.workoutSetExercises) ? set.workoutSetExercises : [];
|
|
874
|
+
return {
|
|
875
|
+
order: coerceInt(set.order) ?? 0,
|
|
876
|
+
title: str(set.title),
|
|
877
|
+
instruction: str(set.instruction),
|
|
878
|
+
isTest: coerceInt(set.is_test) === 1,
|
|
879
|
+
exercises: exercises.filter(isRecord).map((ex) => {
|
|
880
|
+
const id = coerceInt(ex.id);
|
|
881
|
+
const performed = (id !== null ? performedById.get(id) : void 0) ?? /* @__PURE__ */ new Map();
|
|
882
|
+
return mergeExercise(slotValues(ex, false), performed, {
|
|
883
|
+
exerciseId: coerceInt(ex.exercise_id),
|
|
884
|
+
title: typeof ex.title === "string" ? ex.title : "",
|
|
885
|
+
instruction: str(ex.instruction),
|
|
886
|
+
units: exerciseUnits(ex.param_1_type, ex.param_2_type)
|
|
887
|
+
});
|
|
888
|
+
})
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
/** A logged block straight from the saved copy (athlete-added or personal work; no prescription). */
|
|
892
|
+
function mergeSavedBlock(set, exercises) {
|
|
873
893
|
return {
|
|
874
894
|
order: coerceInt(set.order) ?? 0,
|
|
875
|
-
title:
|
|
876
|
-
instruction:
|
|
895
|
+
title: str(set.title),
|
|
896
|
+
instruction: str(set.instruction),
|
|
877
897
|
isTest: coerceInt(set.is_test) === 1,
|
|
878
|
-
exercises: exercises.map((ex) => ({
|
|
898
|
+
exercises: exercises.map((ex) => mergeExercise(/* @__PURE__ */ new Map(), slotValues(ex, true), {
|
|
879
899
|
exerciseId: coerceInt(ex.exercise_id),
|
|
880
900
|
title: typeof ex.exercise_title === "string" ? ex.exercise_title : "",
|
|
881
|
-
instruction:
|
|
882
|
-
units: exerciseUnits(ex.param_1_type, ex.param_2_type)
|
|
883
|
-
prescribed: [],
|
|
884
|
-
performed: performedSets(ex)
|
|
901
|
+
instruction: str(ex.instruction),
|
|
902
|
+
units: exerciseUnits(ex.param_1_type, ex.param_2_type)
|
|
885
903
|
}))
|
|
886
904
|
};
|
|
887
905
|
}
|
|
888
906
|
/**
|
|
889
|
-
* Flatten one `/3.0/athlete/programworkout/range` item into
|
|
907
|
+
* Flatten one `/3.0/athlete/programworkout/range` item into the canonical merged form, joining the
|
|
890
908
|
* prescription (`summarizedSavedWorkout.workout`) with what the athlete logged
|
|
891
|
-
* (`summarizedSavedWorkout.saved_workout`).
|
|
892
|
-
*
|
|
893
|
-
* own blocks. No `raw` is needed to see logged results.
|
|
909
|
+
* (`summarizedSavedWorkout.saved_workout`). Prescription blocks are enriched with logged slots;
|
|
910
|
+
* athlete-added / personal work with no prescription is appended as its own block.
|
|
894
911
|
*/
|
|
895
|
-
function
|
|
912
|
+
function mergeAthleteWorkout(raw) {
|
|
896
913
|
const rec = raw;
|
|
897
914
|
const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
|
|
898
915
|
const workout = isRecord(ssw.workout) ? ssw.workout : {};
|
|
899
916
|
const saved = isRecord(ssw.saved_workout) ? ssw.saved_workout : {};
|
|
900
917
|
const prescriptionSets = (Array.isArray(workout.workoutSets) ? workout.workoutSets : []).filter(isRecord);
|
|
901
918
|
const logged = savedSets(saved);
|
|
902
|
-
const performedById =
|
|
903
|
-
const blocks = prescriptionSets.map((s) =>
|
|
919
|
+
const performedById = performedSlotsByExerciseId(logged);
|
|
920
|
+
const blocks = prescriptionSets.map((s) => mergePrescriptionBlock(s, performedById)).sort((a, b) => a.order - b.order);
|
|
904
921
|
const prescribedIds = /* @__PURE__ */ new Set();
|
|
905
922
|
for (const s of prescriptionSets) {
|
|
906
923
|
const exs = Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : [];
|
|
@@ -912,11 +929,11 @@ function presentAthleteWorkout(raw) {
|
|
|
912
929
|
}
|
|
913
930
|
for (const { set, exercises } of logged) {
|
|
914
931
|
const extra = exercises.filter((ex) => {
|
|
915
|
-
if (
|
|
932
|
+
if (slotValues(ex, true).size === 0) return false;
|
|
916
933
|
const id = coerceInt(ex.workout_set_exercise_id);
|
|
917
934
|
return id === null || !prescribedIds.has(id);
|
|
918
935
|
});
|
|
919
|
-
if (extra.length > 0) blocks.push(
|
|
936
|
+
if (extra.length > 0) blocks.push(mergeSavedBlock(set, extra));
|
|
920
937
|
}
|
|
921
938
|
return {
|
|
922
939
|
id: coerceInt(rec.id),
|
|
@@ -925,11 +942,58 @@ function presentAthleteWorkout(raw) {
|
|
|
925
942
|
program: str(rec.program_title),
|
|
926
943
|
team: str(rec.team_title),
|
|
927
944
|
instruction: str(workout.instruction),
|
|
928
|
-
logged: blocks.some((b) => b.exercises.some((e) => e.
|
|
945
|
+
logged: blocks.some((b) => b.exercises.some((e) => e.sets.some((s) => s.performed !== null))),
|
|
929
946
|
personal: isPersonalSession(rec),
|
|
930
947
|
blocks
|
|
931
948
|
};
|
|
932
949
|
}
|
|
950
|
+
/** The per-set displays for one side (prescribed or performed), in slot order. */
|
|
951
|
+
function sideStrings(sets, pick) {
|
|
952
|
+
const out = [];
|
|
953
|
+
for (const s of sets) {
|
|
954
|
+
const slot = pick(s);
|
|
955
|
+
if (slot) out.push(fmtSlot(slot));
|
|
956
|
+
}
|
|
957
|
+
return out;
|
|
958
|
+
}
|
|
959
|
+
function toStringExercise(ex) {
|
|
960
|
+
return {
|
|
961
|
+
exerciseId: ex.exerciseId,
|
|
962
|
+
title: ex.title,
|
|
963
|
+
instruction: ex.instruction,
|
|
964
|
+
units: ex.units,
|
|
965
|
+
prescribed: sideStrings(ex.sets, (s) => s.prescribed),
|
|
966
|
+
performed: sideStrings(ex.sets, (s) => s.performed)
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
function toStringBlock(b) {
|
|
970
|
+
return {
|
|
971
|
+
order: b.order,
|
|
972
|
+
title: b.title,
|
|
973
|
+
instruction: b.instruction,
|
|
974
|
+
isTest: b.isTest,
|
|
975
|
+
exercises: b.exercises.map(toStringExercise)
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Flatten one range item into a readable workout: each exercise carries its `prescribed` and
|
|
980
|
+
* `performed` sets as joined `"5 @ 225"` strings; athlete-added/personal work that has no
|
|
981
|
+
* prescription is appended as its own blocks. No `raw` is needed to see logged results.
|
|
982
|
+
*/
|
|
983
|
+
function presentAthleteWorkout(raw) {
|
|
984
|
+
const w = mergeAthleteWorkout(raw);
|
|
985
|
+
return {
|
|
986
|
+
id: w.id,
|
|
987
|
+
date: w.date,
|
|
988
|
+
title: w.title,
|
|
989
|
+
program: w.program,
|
|
990
|
+
team: w.team,
|
|
991
|
+
instruction: w.instruction,
|
|
992
|
+
logged: w.logged,
|
|
993
|
+
personal: w.personal,
|
|
994
|
+
blocks: w.blocks.map(toStringBlock)
|
|
995
|
+
};
|
|
996
|
+
}
|
|
933
997
|
function presentAthleteWorkouts(list) {
|
|
934
998
|
return list.map(presentAthleteWorkout);
|
|
935
999
|
}
|
|
@@ -989,8 +1053,8 @@ function presentLogTargets(list) {
|
|
|
989
1053
|
savedWorkoutSetExerciseId: id,
|
|
990
1054
|
title: exerciseTitle(ex),
|
|
991
1055
|
units: exerciseUnits(ex.param_1_type, ex.param_2_type),
|
|
992
|
-
prescribed:
|
|
993
|
-
performed:
|
|
1056
|
+
prescribed: prescribedStrings(ex),
|
|
1057
|
+
performed: performedStrings(ex)
|
|
994
1058
|
};
|
|
995
1059
|
}).filter((e) => e !== null);
|
|
996
1060
|
targets.push({
|
|
@@ -1009,10 +1073,11 @@ function presentLogTargets(list) {
|
|
|
1009
1073
|
return targets;
|
|
1010
1074
|
}
|
|
1011
1075
|
/**
|
|
1012
|
-
* Narrow a
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1076
|
+
* Narrow a workout list for the common "what did I actually do" reads. `loggedOnly` keeps only
|
|
1077
|
+
* workouts the athlete logged a set on (the reliable signal, not the API's completion flag).
|
|
1078
|
+
* `limit` keeps the most recent N by date (newest first). Both are pure post-filters; the raw API
|
|
1079
|
+
* path is left untouched. Generic over the presented (`AthleteWorkoutView`) and exported
|
|
1080
|
+
* (`WorkoutHistoryExport`) shapes, which both carry `date` and `logged`, so one rule serves both.
|
|
1016
1081
|
*/
|
|
1017
1082
|
function selectWorkouts(list, opts = {}) {
|
|
1018
1083
|
let out = opts.loggedOnly === true ? list.filter((w) => w.logged) : [...list];
|
|
@@ -1309,6 +1374,41 @@ async function logForAthlete(client, args) {
|
|
|
1309
1374
|
};
|
|
1310
1375
|
}
|
|
1311
1376
|
/**
|
|
1377
|
+
* Swap the exercise prescribed in one of an athlete's saved-workout slots for a different
|
|
1378
|
+
* exercise — the API equivalent of the app's per-athlete "swap exercise":
|
|
1379
|
+
* `PUT /v5/savedWorkoutSetExercises/{savedWorkoutSetExerciseId}?exerciseId={exerciseId}` with an
|
|
1380
|
+
* empty body. The new exercise rides in the query string, not the body.
|
|
1381
|
+
*
|
|
1382
|
+
* This overrides only this athlete's copy of the slot; the underlying team/program prescription
|
|
1383
|
+
* (`workout_set_exercise.exercise_id`) is untouched, so other athletes on the same program keep
|
|
1384
|
+
* the original exercise. A coach's session token may write another user's row because the row
|
|
1385
|
+
* already carries its owner's `user_id`.
|
|
1386
|
+
*
|
|
1387
|
+
* `savedWorkoutSetExerciseId` is the same id {@link logForAthlete}/{@link logAthleteSet} use,
|
|
1388
|
+
* read off the athlete's saved workouts (the raw view). `exerciseId` is any exercise id the org
|
|
1389
|
+
* can use (resolve one via the exercise index).
|
|
1390
|
+
*
|
|
1391
|
+
* NOTE: as with logging, TrainHeroic's seeded *demo* athletes are read-only and return 401/403;
|
|
1392
|
+
* real (invited) athletes accept the swap.
|
|
1393
|
+
*/
|
|
1394
|
+
async function swapAthleteExercise(client, args) {
|
|
1395
|
+
const res = await client.request("PUT", `/v5/savedWorkoutSetExercises/${args.savedWorkoutSetExerciseId}?exerciseId=${args.exerciseId}`);
|
|
1396
|
+
if (!res.ok) {
|
|
1397
|
+
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.` : "";
|
|
1398
|
+
throw new Error(`Swap failed (HTTP ${res.status}) for savedWorkoutSetExercise ${args.savedWorkoutSetExerciseId}.${readOnly}`);
|
|
1399
|
+
}
|
|
1400
|
+
const row = isRecord(res.data) ? res.data : {};
|
|
1401
|
+
const template = isRecord(row.workout_set_exercise) ? row.workout_set_exercise : {};
|
|
1402
|
+
const exercise = isRecord(row.exercise) ? row.exercise : {};
|
|
1403
|
+
return {
|
|
1404
|
+
savedWorkoutSetExerciseId: args.savedWorkoutSetExerciseId,
|
|
1405
|
+
athleteId: coerceInt(row.user_id),
|
|
1406
|
+
newExerciseId: coerceInt(row.exercise_id) ?? args.exerciseId,
|
|
1407
|
+
newExerciseTitle: str(exercise.title),
|
|
1408
|
+
originalTeamExerciseId: coerceInt(template.exercise_id)
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1312
1412
|
* Shared set-write behind {@link logAthleteSet}, {@link logForAthlete}, and
|
|
1313
1413
|
* {@link prescribeForAthlete}. `target` selects the surface: `athlete` writes `/1.0/athlete/...`;
|
|
1314
1414
|
* `coach` writes `/1.0/coach/...{athleteId}` and stamps `athleteId` into each body.
|
|
@@ -1679,7 +1779,7 @@ function registerProfileTools(server, ctx, whoami, userId) {
|
|
|
1679
1779
|
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 a high-level overview ('what's on my schedule this week', 'what have I been training lately') set summary:true to get one compact row per session (date, program, title, logged flag, and exerciseCount/performedCount — read performedCount against exerciseCount, e.g. 1 of 12 logged) instead of every set — a multi-program week of full detail is large, so prefer summary first, then re-query a single day without it to drill into that day's sets. 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.";
|
|
1680
1780
|
const ATHLETE_LOG_TARGETS_DESC = "The savedWorkoutSetId + savedWorkoutSetExerciseId that athlete_log_set needs, read straight off your scheduled/logged workouts in an inclusive YYYY-MM-DD window — no raw needed. This is the self-service path for logging into a COACH-SCHEDULED workout: read the ids here, then pass them to athlete_log_set. The default view is COMPACT: one row per saved set, each carrying its program/programId, the savedWorkoutSetId, and every exercise's savedWorkoutSetExerciseId with prescribed/performed values. When several workouts fall on the same day (you're on more than one program), narrow to one with program (a case-insensitive title substring, e.g. 'bodybuilding' — no id lookup needed), or programId/teamId if you have the id. raw:true returns the untouched API objects, but that blob is large and can be truncated when several workouts share a date — prefer the program filter + the default view. For reading what you actually did (not the log ids), use athlete_workouts.";
|
|
1681
1781
|
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. estimated1RM is a formula off the session's best set and reads high when a session mixes a heavy single with high-rep backdown work, so treat it as approximate, not a logged single. `liftPRs` are always all-time and ignore since/until, but each carries the date it was set, so filter those dates yourself to answer 'PRs set this year'. Set raw:true for the untouched API object. Get the exercise id from athlete_exercises.";
|
|
1682
|
-
const ATHLETE_EXERCISES_DESC = "The exercises the athlete has logged (id + title + positional units). Pass q to free-text search by name
|
|
1782
|
+
const ATHLETE_EXERCISES_DESC = "The exercises the athlete has logged (id + title + positional units). Call with no arguments to get the athlete's complete catalog, which often runs to several hundred entries; leave limit off when you want the whole list, since a no-q call returns everything by default. Pass q to free-text search by name (returns the top ranked matches, capped by limit, default 20). Use a returned id with athlete_exercise_history, athlete_personal_records, or athlete_exercise_stats (all of which require an exercise id). If a no-q result comes back wrapped with a __truncated marker, you passed a limit smaller than the catalog and are seeing a partial list; re-call without limit for all of it. A common name returns several variants (e.g. plain 'Bench Press' id 1162 vs 'BARBELL BENCH PRESS'); each variant keeps its own separate history and PR board, so prefer the plain canonical entry, and if a PR or trend looks incomplete, the work may be split across variants, so check the others.";
|
|
1683
1783
|
function runAthleteWorkouts(ctx, args) {
|
|
1684
1784
|
return attempt(async () => {
|
|
1685
1785
|
const workouts = await fetchAthleteWorkouts(ctx.client, args.startDate, args.endDate);
|
|
@@ -1692,6 +1792,24 @@ function runAthleteWorkouts(ctx, args) {
|
|
|
1692
1792
|
return jsonResult(selected, { hint: "Large? Set summary:true for one row per session, loggedOnly:true, pass limit, or narrow the dates." });
|
|
1693
1793
|
});
|
|
1694
1794
|
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Body of the athlete_exercises handler, hoisted to module scope so registerExerciseTools stays
|
|
1797
|
+
* under the oxlint max-lines-per-function cap (mirrors runAthleteWorkouts above).
|
|
1798
|
+
*/
|
|
1799
|
+
function runAthleteExercises(ctx, args) {
|
|
1800
|
+
const { q, limit } = args;
|
|
1801
|
+
return attempt(async () => {
|
|
1802
|
+
const searching = q !== void 0 && q.trim() !== "";
|
|
1803
|
+
const items = (searching ? await searchExerciseHistory(ctx.client, q, limit ?? 20) : await fetchExerciseHistoryList(ctx.client)).map((r) => ({
|
|
1804
|
+
id: r.id,
|
|
1805
|
+
title: r.title,
|
|
1806
|
+
isCircuit: r.isCircuit ?? false,
|
|
1807
|
+
units: exerciseUnits(r.param1Type, r.param2Type)
|
|
1808
|
+
}));
|
|
1809
|
+
if (!searching && limit !== void 0 && items.length > limit) return jsonResult(clipArray(items, limit, "Partial catalog: re-call athlete_exercises without limit to get all of them."));
|
|
1810
|
+
return jsonResult(items, { hint: searching ? "Ranked matches for q. Omit q for the full catalog." : "The athlete's full exercise catalog. Pass q to search by name." });
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1695
1813
|
/** Workouts, exercise catalog, per-exercise history/PRs/stats. */
|
|
1696
1814
|
function registerExerciseTools(server, ctx, userId) {
|
|
1697
1815
|
server.registerTool("athlete_workouts", {
|
|
@@ -1712,18 +1830,10 @@ function registerExerciseTools(server, ctx, userId) {
|
|
|
1712
1830
|
description: ATHLETE_EXERCISES_DESC,
|
|
1713
1831
|
inputSchema: {
|
|
1714
1832
|
q: z.string().optional(),
|
|
1715
|
-
limit: z.number().int().positive().
|
|
1833
|
+
limit: z.number().int().positive().optional()
|
|
1716
1834
|
},
|
|
1717
1835
|
annotations: READ
|
|
1718
|
-
}, (
|
|
1719
|
-
const items = (q !== void 0 && q.trim() !== "" ? await searchExerciseHistory(ctx.client, q, limit ?? 20) : await fetchExerciseHistoryList(ctx.client)).map((r) => ({
|
|
1720
|
-
id: r.id,
|
|
1721
|
-
title: r.title,
|
|
1722
|
-
isCircuit: r.isCircuit ?? false,
|
|
1723
|
-
units: exerciseUnits(r.param1Type, r.param2Type)
|
|
1724
|
-
}));
|
|
1725
|
-
return jsonResult(limit !== void 0 ? items.slice(0, limit) : items, { hint: "Pass q to search by name, or limit to cap the list." });
|
|
1726
|
-
}));
|
|
1836
|
+
}, (args) => runAthleteExercises(ctx, args));
|
|
1727
1837
|
server.registerTool("athlete_exercise_history", {
|
|
1728
1838
|
title: "Exercise history + PRs",
|
|
1729
1839
|
description: ATHLETE_EXERCISE_HISTORY_DESC,
|
|
@@ -1889,6 +1999,31 @@ function registerLogTool(server, ctx) {
|
|
|
1889
1999
|
}));
|
|
1890
2000
|
}
|
|
1891
2001
|
/**
|
|
2002
|
+
* The athlete's own per-exercise swap, in its own function so registerLogTool/registerSessionTools
|
|
2003
|
+
* stay under the oxlint max-lines-per-function cap. Lets an athlete substitute a prescribed
|
|
2004
|
+
* exercise in a coach-scheduled workout for a different one — the self-service equivalent of the
|
|
2005
|
+
* app's "swap exercise", overriding only the athlete's own copy of that slot.
|
|
2006
|
+
*/
|
|
2007
|
+
function registerSwapTool(server, ctx) {
|
|
2008
|
+
server.registerTool("athlete_swap_exercise", {
|
|
2009
|
+
title: "Swap one exercise in your scheduled workout",
|
|
2010
|
+
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).",
|
|
2011
|
+
inputSchema: {
|
|
2012
|
+
...swapAthleteExerciseArgsSchema.shape,
|
|
2013
|
+
confirm: z.boolean().optional()
|
|
2014
|
+
},
|
|
2015
|
+
annotations: DESTRUCTIVE
|
|
2016
|
+
}, ({ savedWorkoutSetExerciseId, exerciseId, confirm }, extra) => attempt(async () => {
|
|
2017
|
+
const sweId = toId(savedWorkoutSetExerciseId);
|
|
2018
|
+
const exId = toId(exerciseId);
|
|
2019
|
+
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);
|
|
2020
|
+
return jsonResult(await swapAthleteExercise(ctx.client, {
|
|
2021
|
+
savedWorkoutSetExerciseId: sweId,
|
|
2022
|
+
exerciseId: exId
|
|
2023
|
+
}));
|
|
2024
|
+
}));
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
1892
2027
|
* Live tools over the logged-in user's own training (history, scheduled/completed workouts,
|
|
1893
2028
|
* PRs, working maxes), plus a gated set-logging write. The athlete user id is
|
|
1894
2029
|
* resolved once from /user/simple and reused across tools.
|
|
@@ -1913,10 +2048,11 @@ function registerAthleteTrainingTools(server, ctx) {
|
|
|
1913
2048
|
registerLogTargetsTool(server, ctx);
|
|
1914
2049
|
registerSessionTools(server, ctx);
|
|
1915
2050
|
registerLogTool(server, ctx);
|
|
2051
|
+
registerSwapTool(server, ctx);
|
|
1916
2052
|
}
|
|
1917
2053
|
//#endregion
|
|
1918
2054
|
//#region package.json
|
|
1919
|
-
var version = "1.
|
|
2055
|
+
var version = "1.7.1";
|
|
1920
2056
|
//#endregion
|
|
1921
2057
|
//#region src/server.ts
|
|
1922
2058
|
async function main() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trainheroic-unofficial/athlete-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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": "1.
|
|
25
|
-
"@trainheroic-unofficial/js": "1.
|
|
24
|
+
"@trainheroic-unofficial/core": "1.7.1",
|
|
25
|
+
"@trainheroic-unofficial/js": "1.7.1"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^26.0.1",
|