@trainheroic-unofficial/athlete-mcp 1.5.0 → 1.6.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.
- package/dist/server.mjs +252 -16
- package/package.json +3 -3
package/dist/server.mjs
CHANGED
|
@@ -214,6 +214,16 @@ const logSessionArgsSchema = z.object({
|
|
|
214
214
|
})).min(1)
|
|
215
215
|
});
|
|
216
216
|
logSessionArgsSchema.extend({ athleteId: idArgSchema });
|
|
217
|
+
/**
|
|
218
|
+
* Args for removing a personal (athlete-created) workout session. `programWorkoutId` is the
|
|
219
|
+
* range item's top-level `id`; `date` is that session's day, used to look the item back up so the
|
|
220
|
+
* write can confirm it is a personal session (`personal_cal === true`) before deleting. A
|
|
221
|
+
* coach-scheduled workout is never removed this way.
|
|
222
|
+
*/
|
|
223
|
+
const athleteSessionRemoveArgsSchema = z.object({
|
|
224
|
+
programWorkoutId: idArgSchema,
|
|
225
|
+
date: dateString
|
|
226
|
+
});
|
|
217
227
|
z.looseObject({
|
|
218
228
|
title: z.string().min(1),
|
|
219
229
|
param_1_type: z.number().optional(),
|
|
@@ -661,6 +671,22 @@ function str(v) {
|
|
|
661
671
|
return typeof v === "string" && v !== "" ? v : null;
|
|
662
672
|
}
|
|
663
673
|
/**
|
|
674
|
+
* The display title of a saved-copy exercise row, preferring `exercise_title`, then `title`, then
|
|
675
|
+
* the given fallback. The saved-workout shape carries the title under either key depending on the
|
|
676
|
+
* endpoint, so this is the one place that reconciles them.
|
|
677
|
+
*/
|
|
678
|
+
function exerciseTitle(ex, fallback = "") {
|
|
679
|
+
return typeof ex.exercise_title === "string" && ex.exercise_title || typeof ex.title === "string" && ex.title || fallback;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* True when a program-workout range item is a personal (athlete-created) session rather than a
|
|
683
|
+
* coach-scheduled one. The API marks these with `personal_cal === true`; this is the single
|
|
684
|
+
* definition every consumer (presenter, find-or-create, the remove guard) reads through.
|
|
685
|
+
*/
|
|
686
|
+
function isPersonalSession(pw) {
|
|
687
|
+
return isRecord(pw) && pw.personal_cal === true;
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
664
690
|
* Rank candidate rows for a free-text query (FTS5 replacement). Higher is better:
|
|
665
691
|
* exact title, then prefix, then count of matched tokens, with shorter titles and
|
|
666
692
|
* standard (non-custom) exercises preferred on ties.
|
|
@@ -900,6 +926,7 @@ function presentAthleteWorkout(raw) {
|
|
|
900
926
|
team: str(rec.team_title),
|
|
901
927
|
instruction: str(workout.instruction),
|
|
902
928
|
logged: blocks.some((b) => b.exercises.some((e) => e.performed.length > 0)),
|
|
929
|
+
personal: isPersonalSession(rec),
|
|
903
930
|
blocks
|
|
904
931
|
};
|
|
905
932
|
}
|
|
@@ -907,6 +934,81 @@ function presentAthleteWorkouts(list) {
|
|
|
907
934
|
return list.map(presentAthleteWorkout);
|
|
908
935
|
}
|
|
909
936
|
/**
|
|
937
|
+
* Narrow a coach-athlete workout range to a single program or team. The range endpoint returns
|
|
938
|
+
* every program the athlete is enrolled in on a date; for a high-enrollment athlete that is many
|
|
939
|
+
* workouts, so a caller wanting one program's session narrows it here. Match by `programId`/`teamId`
|
|
940
|
+
* (exact, on the raw `program_id`/`team_id`) or by `programTitle` (case-insensitive substring on the
|
|
941
|
+
* `program_title`/`team_title`) — the title match lets a caller target a program by name without
|
|
942
|
+
* first resolving its id. Any one match keeps the workout; with no filter the list is unchanged.
|
|
943
|
+
*/
|
|
944
|
+
function selectWorkoutsByProgram(list, filter) {
|
|
945
|
+
const { programId, teamId, programTitle } = filter;
|
|
946
|
+
const needle = programTitle?.trim().toLowerCase();
|
|
947
|
+
if (programId === void 0 && teamId === void 0 && !needle) return [...list];
|
|
948
|
+
return list.filter((pw) => {
|
|
949
|
+
const rec = pw;
|
|
950
|
+
if (programId !== void 0 && coerceInt(rec.program_id) === programId) return true;
|
|
951
|
+
if (teamId !== void 0 && coerceInt(rec.team_id) === teamId) return true;
|
|
952
|
+
if (needle) {
|
|
953
|
+
if (`${str(rec.program_title) ?? ""} ${str(rec.team_title) ?? ""}`.toLowerCase().includes(needle)) return true;
|
|
954
|
+
}
|
|
955
|
+
return false;
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Project a workout range to just the ids a set-log write needs, read from the SAME saved-copy
|
|
960
|
+
* location {@link findSavedWorkoutSet} matches against (`summarizedSavedWorkout.saved_workout`).
|
|
961
|
+
* This is the self-service path for logging: instead of grepping the multi-KB `--raw` blob for
|
|
962
|
+
* which of several id fields maps to `--set`, callers read `savedWorkoutSetId` and each
|
|
963
|
+
* `savedWorkoutSetExerciseId` straight off these rows. Each target also carries its program/team
|
|
964
|
+
* so a caller can pick the right session when the athlete is on several. One row per saved set,
|
|
965
|
+
* dropping any set with no resolvable id.
|
|
966
|
+
*/
|
|
967
|
+
function presentLogTargets(list) {
|
|
968
|
+
const targets = [];
|
|
969
|
+
for (const pw of list) {
|
|
970
|
+
const rec = pw;
|
|
971
|
+
const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
|
|
972
|
+
const saved = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
|
|
973
|
+
if (!saved) continue;
|
|
974
|
+
const date = str(rec.date) ?? "";
|
|
975
|
+
const workoutTitle = str(rec.workout_title) ?? "";
|
|
976
|
+
const program = str(rec.program_title);
|
|
977
|
+
const programId = coerceInt(rec.program_id);
|
|
978
|
+
const team = str(rec.team_title);
|
|
979
|
+
const teamId = coerceInt(rec.team_id);
|
|
980
|
+
const sets = Array.isArray(saved.workoutSets) ? saved.workoutSets : [];
|
|
981
|
+
for (const s of sets) {
|
|
982
|
+
if (!isRecord(s)) continue;
|
|
983
|
+
const savedWorkoutSetId = coerceInt(s.id);
|
|
984
|
+
if (savedWorkoutSetId === null) continue;
|
|
985
|
+
const exercises = (Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : []).filter(isRecord).map((ex) => {
|
|
986
|
+
const id = coerceInt(ex.id);
|
|
987
|
+
if (id === null) return null;
|
|
988
|
+
return {
|
|
989
|
+
savedWorkoutSetExerciseId: id,
|
|
990
|
+
title: exerciseTitle(ex),
|
|
991
|
+
units: exerciseUnits(ex.param_1_type, ex.param_2_type),
|
|
992
|
+
prescribed: prescribedSets(ex),
|
|
993
|
+
performed: performedSets(ex)
|
|
994
|
+
};
|
|
995
|
+
}).filter((e) => e !== null);
|
|
996
|
+
targets.push({
|
|
997
|
+
date,
|
|
998
|
+
workoutTitle,
|
|
999
|
+
program,
|
|
1000
|
+
programId,
|
|
1001
|
+
team,
|
|
1002
|
+
teamId,
|
|
1003
|
+
savedWorkoutSetId,
|
|
1004
|
+
setTitle: str(s.title),
|
|
1005
|
+
exercises
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return targets;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
910
1012
|
* Narrow a presented workout list for the common "what did I actually do" reads. `loggedOnly`
|
|
911
1013
|
* keeps only workouts the athlete logged a set on (the reliable signal, not the API's
|
|
912
1014
|
* completion flag). `limit` keeps the most recent N by date (newest first). Both are pure
|
|
@@ -934,6 +1036,7 @@ function summarizeAthleteWorkouts(list) {
|
|
|
934
1036
|
program: w.program,
|
|
935
1037
|
team: w.team,
|
|
936
1038
|
logged: w.logged,
|
|
1039
|
+
personal: w.personal,
|
|
937
1040
|
exerciseCount,
|
|
938
1041
|
performedCount
|
|
939
1042
|
};
|
|
@@ -1112,8 +1215,7 @@ function findSavedWorkoutSet(workouts, savedWorkoutSetId) {
|
|
|
1112
1215
|
if (setId !== null) {
|
|
1113
1216
|
const exLabels = exercises.map((ex) => {
|
|
1114
1217
|
const exId = coerceInt(ex.id);
|
|
1115
|
-
|
|
1116
|
-
return exId === null ? null : `${exId} (${title})`;
|
|
1218
|
+
return exId === null ? null : `${exId} (${exerciseTitle(ex, "exercise")})`;
|
|
1117
1219
|
}).filter((x) => x !== null);
|
|
1118
1220
|
available.push(`set ${setId} → exercise ids: ${exLabels.join(", ") || "none"}`);
|
|
1119
1221
|
}
|
|
@@ -1228,8 +1330,7 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
|
|
|
1228
1330
|
if (!ex) {
|
|
1229
1331
|
const valid = exercises.map((e) => {
|
|
1230
1332
|
const id = coerceInt(e.id);
|
|
1231
|
-
|
|
1232
|
-
return id === null ? null : `${id} (${title})`;
|
|
1333
|
+
return id === null ? null : `${id} (${exerciseTitle(e, "exercise")})`;
|
|
1233
1334
|
}).filter((x) => x !== null);
|
|
1234
1335
|
throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${savedWorkoutSetId}. Exercises in this set: ${valid.join(", ") || "none"}.`);
|
|
1235
1336
|
}
|
|
@@ -1304,17 +1405,30 @@ async function addExercisesToWorkout(client, workoutId, exercises) {
|
|
|
1304
1405
|
return res.data;
|
|
1305
1406
|
}
|
|
1306
1407
|
/**
|
|
1408
|
+
* DELETE /v5/programWorkouts/{programWorkoutId} — remove a personal (athlete-created) workout
|
|
1409
|
+
* session from the athlete's own calendar. Re-reads the day and removes ONLY a personal session
|
|
1410
|
+
* (`personal_cal`): it throws if no workout with that id is on the date, or if the target is a
|
|
1411
|
+
* coach-scheduled workout (which is not the athlete's to delete). This guard lives here, at the
|
|
1412
|
+
* delete itself, so no caller can issue the DELETE against a coach workout by skipping a check.
|
|
1413
|
+
* Throws on a non-ok response.
|
|
1414
|
+
*/
|
|
1415
|
+
async function removePersonalWorkout(client, args) {
|
|
1416
|
+
const target = (await fetchAthleteWorkouts(client, args.date, args.date)).find((pw) => coerceInt(pw.id) === args.programWorkoutId);
|
|
1417
|
+
if (target === void 0) throw new Error(`No workout with id ${args.programWorkoutId} on ${args.date}. Get the id and date from athlete_workouts.`);
|
|
1418
|
+
if (!isPersonalSession(target)) throw new Error(`Workout ${args.programWorkoutId} on ${args.date} is a coach-scheduled workout, not a personal session, so it can't be removed. To change logged results on a scheduled workout, use athlete_log_set.`);
|
|
1419
|
+
const res = await client.request("DELETE", `/v5/programWorkouts/${args.programWorkoutId}`);
|
|
1420
|
+
if (!res.ok) throw new Error(`Remove personal workout failed (HTTP ${res.status}).`);
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1307
1423
|
* Find a personal-calendar session already on the given day. The range marks these with
|
|
1308
1424
|
* `personal_cal === true`; the addable id is the program workout's `workout_id`. Returns the
|
|
1309
1425
|
* first one's workoutId, or null when the day has no personal session.
|
|
1310
1426
|
*/
|
|
1311
1427
|
function findPersonalSessionWorkoutId(workouts) {
|
|
1312
1428
|
for (const pw of workouts) {
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
if (workoutId !== null) return workoutId;
|
|
1317
|
-
}
|
|
1429
|
+
if (!isPersonalSession(pw)) continue;
|
|
1430
|
+
const workoutId = coerceInt(pw.workout_id);
|
|
1431
|
+
if (workoutId !== null) return workoutId;
|
|
1318
1432
|
}
|
|
1319
1433
|
return null;
|
|
1320
1434
|
}
|
|
@@ -1345,6 +1459,59 @@ function indexAddedExercises(added) {
|
|
|
1345
1459
|
}
|
|
1346
1460
|
return out;
|
|
1347
1461
|
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Flatten a day's saved sets into `{ pw, savedWorkoutSetId, ex }` rows (both saved-set keys),
|
|
1464
|
+
* carrying the parent program-workout so a caller can read its identity (`personal_cal`, program
|
|
1465
|
+
* title) without re-walking. The one saved-copy traversal the by-exercise paths share.
|
|
1466
|
+
*/
|
|
1467
|
+
function eachPrescribedExercise(workouts) {
|
|
1468
|
+
const rows = [];
|
|
1469
|
+
for (const pw of workouts) {
|
|
1470
|
+
const rec = pw;
|
|
1471
|
+
const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
|
|
1472
|
+
const sw = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
|
|
1473
|
+
if (!sw) continue;
|
|
1474
|
+
const sets = [...Array.isArray(sw.workoutSets) ? sw.workoutSets : [], ...Array.isArray(sw.addedWorkoutSets) ? sw.addedWorkoutSets : []];
|
|
1475
|
+
for (const s of sets) {
|
|
1476
|
+
if (!isRecord(s)) continue;
|
|
1477
|
+
const savedWorkoutSetId = coerceInt(s.id);
|
|
1478
|
+
if (savedWorkoutSetId === null) continue;
|
|
1479
|
+
const exercises = Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : [];
|
|
1480
|
+
for (const ex of exercises) if (isRecord(ex)) rows.push({
|
|
1481
|
+
pw: rec,
|
|
1482
|
+
savedWorkoutSetId,
|
|
1483
|
+
ex
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return rows;
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Find prescribed exercises on coach-scheduled (non-personal) workouts that day matching any of the
|
|
1491
|
+
* given exercise ids. Used to warn after an ad-hoc log that the same lift was already on the
|
|
1492
|
+
* athlete's scheduled workout — a caller can then point at {@link logAthleteSet}. Personal sessions
|
|
1493
|
+
* (`personal_cal === true`) are skipped: the ad-hoc log itself lands there, so they are not an
|
|
1494
|
+
* alternative. One row per matching saved-set exercise, dropping any with an unresolvable id.
|
|
1495
|
+
*/
|
|
1496
|
+
function findScheduledMatches(workouts, exerciseIds) {
|
|
1497
|
+
const out = [];
|
|
1498
|
+
for (const { pw, savedWorkoutSetId, ex } of eachPrescribedExercise(workouts)) {
|
|
1499
|
+
if (isPersonalSession(pw)) continue;
|
|
1500
|
+
const exerciseId = coerceInt(ex.exercise_id);
|
|
1501
|
+
const savedWorkoutSetExerciseId = coerceInt(ex.id);
|
|
1502
|
+
if (exerciseId === null || savedWorkoutSetExerciseId === null) continue;
|
|
1503
|
+
if (!exerciseIds.has(exerciseId)) continue;
|
|
1504
|
+
out.push({
|
|
1505
|
+
exerciseId,
|
|
1506
|
+
title: exerciseTitle(ex),
|
|
1507
|
+
program: str(pw.program_title),
|
|
1508
|
+
workoutTitle: str(pw.workout_title) ?? "",
|
|
1509
|
+
savedWorkoutSetId,
|
|
1510
|
+
savedWorkoutSetExerciseId
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
return out;
|
|
1514
|
+
}
|
|
1348
1515
|
/** Group resolved exercises by saved set and write each set via the given log target. */
|
|
1349
1516
|
async function logResolvedExercises(client, target, date, resolved) {
|
|
1350
1517
|
const bySet = /* @__PURE__ */ new Map();
|
|
@@ -1384,7 +1551,8 @@ async function logResolvedExercises(client, target, date, resolved) {
|
|
|
1384
1551
|
*/
|
|
1385
1552
|
async function logAdHocSession(client, args) {
|
|
1386
1553
|
if (args.exercises.length === 0) throw new Error("Provide at least one exercise to log.");
|
|
1387
|
-
const
|
|
1554
|
+
const day = await fetchAthleteWorkouts(client, args.date, args.date);
|
|
1555
|
+
const existing = findPersonalSessionWorkoutId(day);
|
|
1388
1556
|
const created = existing === null;
|
|
1389
1557
|
const workoutId = existing ?? (await createPersonalWorkout(client, args.date)).workoutId;
|
|
1390
1558
|
const withOrder = args.exercises.map((e, i) => ({
|
|
@@ -1408,11 +1576,30 @@ async function logAdHocSession(client, args) {
|
|
|
1408
1576
|
};
|
|
1409
1577
|
});
|
|
1410
1578
|
const sets = await logResolvedExercises(client, { role: "athlete" }, args.date, resolved);
|
|
1411
|
-
|
|
1579
|
+
const result = {
|
|
1412
1580
|
date: args.date,
|
|
1413
1581
|
created,
|
|
1414
1582
|
sets
|
|
1415
1583
|
};
|
|
1584
|
+
const scheduledAlternatives = findScheduledMatches(day, new Set(args.exercises.map((e) => e.exerciseId)));
|
|
1585
|
+
if (scheduledAlternatives.length > 0) result.scheduledAlternatives = scheduledAlternatives;
|
|
1586
|
+
return result;
|
|
1587
|
+
}
|
|
1588
|
+
//#endregion
|
|
1589
|
+
//#region ../js/src/util.ts
|
|
1590
|
+
/**
|
|
1591
|
+
* Drop the keys whose value is `undefined`, so the result can be passed to a function or
|
|
1592
|
+
* spread into an object under `exactOptionalPropertyTypes` without the per-key
|
|
1593
|
+
* `...(x !== undefined ? { x } : {})` dance. Required keys stay required in the result type;
|
|
1594
|
+
* optional ones lose `undefined` from their value type.
|
|
1595
|
+
*
|
|
1596
|
+
* Example:
|
|
1597
|
+
* definedProps({ metric, teamId: maybeId, date }) // omits teamId/date if they're undefined
|
|
1598
|
+
*/
|
|
1599
|
+
function definedProps(obj) {
|
|
1600
|
+
const result = {};
|
|
1601
|
+
for (const [key, value] of Object.entries(obj)) if (value !== void 0) result[key] = value;
|
|
1602
|
+
return result;
|
|
1416
1603
|
}
|
|
1417
1604
|
//#endregion
|
|
1418
1605
|
//#region ../core/src/history.ts
|
|
@@ -1490,6 +1677,7 @@ function registerProfileTools(server, ctx, whoami, userId) {
|
|
|
1490
1677
|
}));
|
|
1491
1678
|
}
|
|
1492
1679
|
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
|
+
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.";
|
|
1493
1681
|
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.";
|
|
1494
1682
|
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). 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 — check the others.";
|
|
1495
1683
|
function runAthleteWorkouts(ctx, args) {
|
|
@@ -1567,7 +1755,31 @@ function registerExerciseTools(server, ctx, userId) {
|
|
|
1567
1755
|
annotations: READ
|
|
1568
1756
|
}, ({ exerciseId, date }) => attempt(async () => jsonResult(await fetchExerciseStats(ctx.client, toId(exerciseId), await userId(), date))));
|
|
1569
1757
|
}
|
|
1570
|
-
/**
|
|
1758
|
+
/** The compact log-id read: savedWorkoutSetId + savedWorkoutSetExerciseId for athlete_log_set. */
|
|
1759
|
+
function registerLogTargetsTool(server, ctx) {
|
|
1760
|
+
server.registerTool("athlete_log_targets", {
|
|
1761
|
+
title: "Saved-workout log ids (for athlete_log_set)",
|
|
1762
|
+
description: ATHLETE_LOG_TARGETS_DESC,
|
|
1763
|
+
inputSchema: {
|
|
1764
|
+
startDate: dateString,
|
|
1765
|
+
endDate: dateString,
|
|
1766
|
+
program: z.string().optional(),
|
|
1767
|
+
programId: idParam.optional(),
|
|
1768
|
+
teamId: idParam.optional(),
|
|
1769
|
+
raw: z.boolean().optional()
|
|
1770
|
+
},
|
|
1771
|
+
annotations: READ
|
|
1772
|
+
}, ({ startDate, endDate, program, programId, teamId, raw }) => attempt(async () => {
|
|
1773
|
+
const workouts = selectWorkoutsByProgram(await fetchAthleteWorkouts(ctx.client, startDate, endDate), definedProps({
|
|
1774
|
+
programTitle: program,
|
|
1775
|
+
programId: programId === void 0 ? void 0 : toId(programId),
|
|
1776
|
+
teamId: teamId === void 0 ? void 0 : toId(teamId)
|
|
1777
|
+
}));
|
|
1778
|
+
if (raw === true) return jsonResult(workouts, { hint: "Each set's id is the savedWorkoutSetId; each savedWorkoutSetExercises[].id is the savedWorkoutSetExerciseId. Large — if truncated, pass program (a title substring) or use the default compact view." });
|
|
1779
|
+
return jsonResult(presentLogTargets(workouts), { hint: "One row per saved set: savedWorkoutSetId plus each savedWorkoutSetExerciseId, with the program it belongs to. Pass both to athlete_log_set. Filter with program (title substring), programId, or teamId if you're on several programs." });
|
|
1780
|
+
}));
|
|
1781
|
+
}
|
|
1782
|
+
/** Create a personal workout session, add exercises, and remove a stray personal session. */
|
|
1571
1783
|
function registerSessionTools(server, ctx) {
|
|
1572
1784
|
server.registerTool("athlete_session_create", {
|
|
1573
1785
|
title: "Create personal workout session",
|
|
@@ -1601,6 +1813,27 @@ function registerSessionTools(server, ctx) {
|
|
|
1601
1813
|
}));
|
|
1602
1814
|
return jsonResult(await addExercisesToWorkout(ctx.client, toId(workoutId), mapped), { hint: "Each top-level id is a savedWorkoutSetId; savedWorkoutSetExercises[].id is the savedWorkoutSetExerciseId for athlete_log_set." });
|
|
1603
1815
|
}));
|
|
1816
|
+
server.registerTool("athlete_session_remove", {
|
|
1817
|
+
title: "Remove a personal workout session",
|
|
1818
|
+
description: "Delete a personal (self-created) workout session from your calendar — use it to clean up a stray ad-hoc session, e.g. one athlete_log_session created when you meant to log into a coach-scheduled workout. Give the programWorkoutId (the `id` of the session in athlete_workouts, where personal:true marks a personal session) and its date. ONLY personal sessions can be removed: the tool re-reads that day and refuses a coach-scheduled workout. Requires confirmation (elicitation or confirm:true).",
|
|
1819
|
+
inputSchema: {
|
|
1820
|
+
...athleteSessionRemoveArgsSchema.shape,
|
|
1821
|
+
confirm: z.boolean().optional()
|
|
1822
|
+
},
|
|
1823
|
+
annotations: DESTRUCTIVE
|
|
1824
|
+
}, ({ programWorkoutId, date, confirm }, extra) => attempt(async () => {
|
|
1825
|
+
const id = toId(programWorkoutId);
|
|
1826
|
+
if (!await confirmGate(server, extra.requestId, `Permanently remove personal session ${id} on ${date}? This deletes the session and anything logged in it.`, confirm)) return errorResult(NOT_CONFIRMED);
|
|
1827
|
+
await removePersonalWorkout(ctx.client, {
|
|
1828
|
+
programWorkoutId: id,
|
|
1829
|
+
date
|
|
1830
|
+
});
|
|
1831
|
+
return jsonResult({
|
|
1832
|
+
removed: true,
|
|
1833
|
+
programWorkoutId: id,
|
|
1834
|
+
date
|
|
1835
|
+
});
|
|
1836
|
+
}));
|
|
1604
1837
|
}
|
|
1605
1838
|
/** Map validated logSession exercises to the SDK's SessionExercise[] (ids coerced, slots trimmed). */
|
|
1606
1839
|
function mapSessionExercises(exercises) {
|
|
@@ -1631,14 +1864,16 @@ function registerLogTool(server, ctx) {
|
|
|
1631
1864
|
annotations: DESTRUCTIVE
|
|
1632
1865
|
}, ({ date, exercises, confirm }, extra) => attempt(async () => {
|
|
1633
1866
|
if (!await confirmGate(server, extra.requestId, `Log a session of ${exercises.length} exercise(s) on ${date}? This writes to your coach-visible training log.`, confirm)) return errorResult(NOT_CONFIRMED);
|
|
1634
|
-
|
|
1867
|
+
const res = await logAdHocSession(ctx.client, {
|
|
1635
1868
|
date,
|
|
1636
1869
|
exercises: mapSessionExercises(exercises)
|
|
1637
|
-
})
|
|
1870
|
+
});
|
|
1871
|
+
if (res.scheduledAlternatives !== void 0 && res.scheduledAlternatives.length > 0) return jsonResult(res, { hint: "Heads up: one or more of these lifts were already on a coach-scheduled workout today (see scheduledAlternatives). This logged a SEPARATE personal session. To log into the scheduled workout instead, use athlete_log_set with the savedWorkoutSetId / savedWorkoutSetExerciseId shown there — and athlete_session_remove to delete this personal session if it was a mistake." });
|
|
1872
|
+
return jsonResult(res);
|
|
1638
1873
|
}));
|
|
1639
1874
|
server.registerTool("athlete_log_set", {
|
|
1640
1875
|
title: "Log completed set results",
|
|
1641
|
-
description: "Athlete-facing write: record entered results (reps/weight per set) for a saved workout set on a given day. Writes to the athlete's (coach-visible) training log and shows in exercise history. Each set fills the next position in order; give a set a 1-based `slot` to place it at a specific position — e.g. log three top singles into positions 4-6 of an 8,5,3,1,1,1 ramp. A partial log records only the positions you send (plus any logged in an earlier call) and leaves the rest unlogged, so it does not mark untouched sets as performed. In a superset/circuit the block is marked done only once every exercise in it has logged results, so logging one exercise leaves its siblings untouched (the response's `setCompleted` reports whether the block was completed). Get savedWorkoutSetId + savedWorkoutSetExerciseId from
|
|
1876
|
+
description: "Athlete-facing write: record entered results (reps/weight per set) for a saved workout set on a given day. Writes to the athlete's (coach-visible) training log and shows in exercise history. Each set fills the next position in order; give a set a 1-based `slot` to place it at a specific position — e.g. log three top singles into positions 4-6 of an 8,5,3,1,1,1 ramp. A partial log records only the positions you send (plus any logged in an earlier call) and leaves the rest unlogged, so it does not mark untouched sets as performed. In a superset/circuit the block is marked done only once every exercise in it has logged results, so logging one exercise leaves its siblings untouched (the response's `setCompleted` reports whether the block was completed). Get savedWorkoutSetId + savedWorkoutSetExerciseId from athlete_log_targets (filter by program when several workouts share a date). Requires confirmation (elicitation or confirm:true).",
|
|
1642
1877
|
inputSchema: {
|
|
1643
1878
|
...logSetArgsSchema.shape,
|
|
1644
1879
|
confirm: z.boolean().optional()
|
|
@@ -1675,12 +1910,13 @@ function registerAthleteTrainingTools(server, ctx) {
|
|
|
1675
1910
|
};
|
|
1676
1911
|
registerProfileTools(server, ctx, whoami, userId);
|
|
1677
1912
|
registerExerciseTools(server, ctx, userId);
|
|
1913
|
+
registerLogTargetsTool(server, ctx);
|
|
1678
1914
|
registerSessionTools(server, ctx);
|
|
1679
1915
|
registerLogTool(server, ctx);
|
|
1680
1916
|
}
|
|
1681
1917
|
//#endregion
|
|
1682
1918
|
//#region package.json
|
|
1683
|
-
var version = "1.
|
|
1919
|
+
var version = "1.6.0";
|
|
1684
1920
|
//#endregion
|
|
1685
1921
|
//#region src/server.ts
|
|
1686
1922
|
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.6.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
23
23
|
"zod": "^4.4.3",
|
|
24
|
-
"@trainheroic-unofficial/core": "1.
|
|
25
|
-
"@trainheroic-unofficial/js": "1.
|
|
24
|
+
"@trainheroic-unofficial/core": "1.6.0",
|
|
25
|
+
"@trainheroic-unofficial/js": "1.6.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^26.0.0",
|