@trainheroic-unofficial/athlete-mcp 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/server.mjs +202 -72
  2. package/package.json +3 -3
package/dist/server.mjs CHANGED
@@ -160,20 +160,41 @@ const loggedSetSchema = z.object({
160
160
  param2: z.union([z.number(), z.string()]).optional()
161
161
  });
162
162
  /**
163
+ * A logged set that can name the prescribed position it fills. `slot` is the 1-based set
164
+ * index in the prescription (10 max) the result should land in; omit it to fill the next
165
+ * sequential position (the first entry is set 1, the second set 2, and so on). Targeting a
166
+ * slot lets a partial log land in the right positions of a multi-set prescription — e.g.
167
+ * three top singles into the 4th/5th/6th positions of an `8,5,3,1,1,1` ramp. Used by the
168
+ * by-set logging write (not the by-exercise session log, where each exercise's sets are
169
+ * always sequential).
170
+ */
171
+ const loggedSetWithSlotSchema = loggedSetSchema.extend({ slot: z.number().int().min(1).max(10).optional() });
172
+ /**
163
173
  * Args for the set-logging write. `date` (the workout's day) locates the saved
164
174
  * workout via the range endpoint; `savedWorkoutSetId` picks the set to complete; `results`
165
- * gives, per exercise in it, the entered value of each set (param 1 / param 2 by entry slot).
175
+ * gives, per exercise in it, the entered value of each set. Each set fills the next position by
176
+ * default, or names its `slot` (1-based) to place a partial log at specific positions. A partial
177
+ * log keeps positions already logged in an earlier call and leaves the positions it does not
178
+ * write empty.
166
179
  */
167
180
  const logSetArgsSchema = z.object({
168
181
  date: dateString,
169
182
  savedWorkoutSetId: idArgSchema,
170
183
  results: z.array(z.object({
171
184
  savedWorkoutSetExerciseId: idArgSchema,
172
- sets: z.array(loggedSetSchema).min(1)
185
+ sets: z.array(loggedSetWithSlotSchema).min(1)
173
186
  })).min(1)
174
187
  });
175
188
  logSetArgsSchema.extend({ athleteId: idArgSchema });
176
- logSetArgsSchema.extend({ athleteId: idArgSchema });
189
+ z.object({
190
+ date: dateString,
191
+ savedWorkoutSetId: idArgSchema,
192
+ athleteId: idArgSchema,
193
+ results: z.array(z.object({
194
+ savedWorkoutSetExerciseId: idArgSchema,
195
+ sets: z.array(loggedSetSchema).min(1)
196
+ })).min(1)
197
+ });
177
198
  z.object({
178
199
  savedWorkoutSetExerciseId: idArgSchema,
179
200
  exerciseId: idArgSchema
@@ -453,7 +474,20 @@ async function confirmGate(server, requestId, message, confirmArg) {
453
474
  const SERVER_INSTRUCTIONS = "Speak to the user in plain, everyday language about their training. Describe what you are doing in the TrainHeroic app's own terms (for example, say you are creating a workout rather than naming a tool). Do not surface internal tool names (the snake_case identifiers such as athlete_session_create), raw parameter names, or numeric ids in your replies unless the user explicitly asks for them; they are implementation details. The tool descriptions cross-reference each other by name only so you can chain them correctly. Keep that wiring to yourself.";
454
475
  //#endregion
455
476
  //#region ../js/src/auth.ts
456
- const AUTH_URL = "https://apis.trainheroic.com/auth";
477
+ const DEFAULT_AUTH_URL = "https://apis.trainheroic.com/auth";
478
+ /**
479
+ * The login endpoint, allowing an env override so a test harness can authenticate against a local
480
+ * fake backend. Precedence: an explicit `TH_AUTH_URL`, else `${TH_APIS_BASE}/auth` (login lives on
481
+ * the apis host, so it follows that base), else the real endpoint. Read via `globalThis.process`
482
+ * (no `import process`) to keep this module workerd-safe, and per call so a child-env override wins.
483
+ */
484
+ function authUrl() {
485
+ const env = globalThis.process?.env;
486
+ if (env?.TH_AUTH_URL && env.TH_AUTH_URL.length > 0) return env.TH_AUTH_URL;
487
+ const apis = env?.TH_APIS_BASE;
488
+ if (apis && apis.length > 0) return `${apis.replace(/\/$/, "")}/auth`;
489
+ return DEFAULT_AUTH_URL;
490
+ }
457
491
  /**
458
492
  * Authenticate against TrainHeroic. Returns the session bundle, or null on bad
459
493
  * credentials. TrainHeroic returns only { id, scope, role, session_id } (verified in
@@ -461,7 +495,7 @@ const AUTH_URL = "https://apis.trainheroic.com/auth";
461
495
  * is sent as the `session-token` header and works against both API hosts.
462
496
  */
463
497
  async function loginTrainHeroic(email, password) {
464
- const res = await fetch(AUTH_URL, {
498
+ const res = await fetch(authUrl(), {
465
499
  method: "POST",
466
500
  headers: {
467
501
  "content-type": "application/x-www-form-urlencoded",
@@ -484,8 +518,19 @@ async function loginTrainHeroic(email, password) {
484
518
  }
485
519
  //#endregion
486
520
  //#region ../js/src/client.ts
487
- const COACH_BASE = "https://api.trainheroic.com";
488
- const APIS_BASE = "https://apis.trainheroic.com";
521
+ const DEFAULT_COACH_BASE = "https://api.trainheroic.com";
522
+ const DEFAULT_APIS_BASE = "https://apis.trainheroic.com";
523
+ /**
524
+ * Resolve an API host, allowing an env override. The override exists so a test harness can point
525
+ * the client at a local fake backend (and it doubles as a staging knob); production leaves these
526
+ * unset and gets the real hosts. Read through `globalThis.process?.env` — not an `import process`
527
+ * — so the runtime-agnostic `.` entry stays free of `node:*` and runs unchanged on workerd, and
528
+ * read per request (not at module load) so a value the harness sets in the child env always wins.
529
+ */
530
+ function envBase(key, fallback) {
531
+ const v = (globalThis.process?.env)?.[key];
532
+ return v && v.length > 0 ? v : fallback;
533
+ }
489
534
  var TrainHeroicAuthError = class extends Error {
490
535
  name = "TrainHeroicAuthError";
491
536
  };
@@ -524,7 +569,7 @@ var TrainHeroicClient = class {
524
569
  return this.#sessionId;
525
570
  }
526
571
  async request(method, path, options = {}) {
527
- const url = `${options.base === "apis" ? APIS_BASE : COACH_BASE}/${path.replace(/^\//, "")}`;
572
+ const url = `${options.base === "apis" ? envBase("TH_APIS_BASE", DEFAULT_APIS_BASE) : envBase("TH_COACH_BASE", DEFAULT_COACH_BASE)}/${path.replace(/^\//, "")}`;
528
573
  let session = await this.#ensureSession();
529
574
  let res = await this.#send(method, url, session, options.body);
530
575
  if (res.status === 401 || res.status === 403) {
@@ -611,6 +656,10 @@ function exerciseUnits(param1, param2) {
611
656
  function isRecord(x) {
612
657
  return typeof x === "object" && x !== null && !Array.isArray(x);
613
658
  }
659
+ /** A non-empty string, or null. Narrows a loosely-typed API field to a usable string. */
660
+ function str(v) {
661
+ return typeof v === "string" && v !== "" ? v : null;
662
+ }
614
663
  /**
615
664
  * Rank candidate rows for a free-text query (FTS5 replacement). Higher is better:
616
665
  * exact title, then prefix, then count of matched tokens, with shorter titles and
@@ -696,7 +745,6 @@ function fetchLeaderboard(client, workoutId, opts = {}) {
696
745
  const query = qs.toString();
697
746
  return getJson(client, `/3.0/athlete/leaderboard/${workoutId}${query ? `?${query}` : ""}`, "athlete leaderboard");
698
747
  }
699
- const SLOTS = 10;
700
748
  function nonEmpty(value) {
701
749
  return value !== void 0 && value !== null && String(value).trim() !== "";
702
750
  }
@@ -707,7 +755,7 @@ function nonEmpty(value) {
707
755
  */
708
756
  function prescribedSets(ex) {
709
757
  const out = [];
710
- for (let i = 1; i <= SLOTS; i += 1) {
758
+ for (let i = 1; i <= 10; i += 1) {
711
759
  const p1 = ex[`param_1_data_${i}`];
712
760
  const p2 = ex[`param_2_data_${i}`];
713
761
  const has1 = nonEmpty(p1);
@@ -728,7 +776,7 @@ function prescribedSets(ex) {
728
776
  */
729
777
  function performedSets(ex) {
730
778
  const out = [];
731
- for (let i = 1; i <= SLOTS; i += 1) {
779
+ for (let i = 1; i <= 10; i += 1) {
732
780
  if (coerceInt(ex[`param_${i}_made`]) !== 1) continue;
733
781
  const p1 = ex[`param_1_data_${i}`];
734
782
  const p2 = ex[`param_2_data_${i}`];
@@ -762,9 +810,6 @@ function presentBlock(set, performedById) {
762
810
  exercises: exercises.filter(isRecord).map((ex) => presentExercise(ex, performedById))
763
811
  };
764
812
  }
765
- function str(v) {
766
- return typeof v === "string" && v !== "" ? v : null;
767
- }
768
813
  /** Every logged set (programmed + athlete-added) in the saved copy, paired with its exercises. */
769
814
  function savedSets(saved) {
770
815
  const out = [];
@@ -894,7 +939,52 @@ function summarizeAthleteWorkouts(list) {
894
939
  };
895
940
  });
896
941
  }
897
- const MAX_PARAM_SLOTS = 10;
942
+ /** Flatten `/v5/exercises/{id}/history` into PRs + a session time-series. */
943
+ function presentExerciseHistory(detail) {
944
+ return {
945
+ liftPRs: (detail.liftPRs ?? []).map((p) => ({
946
+ description: p.description ?? null,
947
+ reps: p.reps ?? null,
948
+ weight: p.weight ?? null,
949
+ units: p.units ?? null,
950
+ date: p.dateCompleted ?? null
951
+ })),
952
+ sessions: (detail.history ?? []).map((h) => ({
953
+ date: h.dateCompleted,
954
+ abr: h.abr ?? null,
955
+ estimated1RM: h.bestEstimated1RM ?? null,
956
+ sets: (h.sets ?? []).map((s) => ({
957
+ setNumber: s.setNumber,
958
+ value: s.formattedValue ?? null
959
+ }))
960
+ }))
961
+ };
962
+ }
963
+ //#endregion
964
+ //#region ../js/src/athlete-set-write.ts
965
+ /**
966
+ * Coerce the loosely-typed `results` from a validated log/prescribe args object into the SDK's
967
+ * {@link SetResult}[]. The dto schemas validate ids as a number or a numeric string and leave
968
+ * empty param slots optional; this narrows the id to a number and copies only the present per-set
969
+ * values (so the per-set type stays free of `undefined` under exactOptionalPropertyTypes). Shared
970
+ * by the MCP tools and the CLI so the mapping lives in one place rather than once per surface.
971
+ */
972
+ function toSetResults(results) {
973
+ return results.map((r) => {
974
+ const id = coerceInt(r.savedWorkoutSetExerciseId);
975
+ if (id === null) throw new Error(`Invalid savedWorkoutSetExerciseId: ${String(r.savedWorkoutSetExerciseId)}`);
976
+ return {
977
+ savedWorkoutSetExerciseId: id,
978
+ sets: r.sets.map((s) => {
979
+ const set = {};
980
+ if (s.param1 !== void 0) set.param1 = s.param1;
981
+ if (s.param2 !== void 0) set.param2 = s.param2;
982
+ if (s.slot !== void 0) set.slot = s.slot;
983
+ return set;
984
+ })
985
+ };
986
+ });
987
+ }
898
988
  /**
899
989
  * Build the body for `PUT /1.0/{role}/savedworkoutsetexercise/{id}`. The body uses snake_case
900
990
  * keys matching the live API response shape. Each set slot (1-10) carries `param_1_data_N` /
@@ -902,36 +992,96 @@ const MAX_PARAM_SLOTS = 10;
902
992
  *
903
993
  * `mode` selects which write this is — the same endpoint serves both:
904
994
  * - `"log"`: the values ARE a performed result, so `param_N_made` is 1 where the slot has data
905
- * and the exercise `completed` flag is 1 when any set has data.
995
+ * and the exercise `completed` flag is 1 when any set has logged data.
906
996
  * - `"prescribe"`: the values are prescribed targets, written with every `param_N_made` and
907
997
  * `completed` left at 0 so the set is not marked done. This matches what the app sends when a
908
998
  * coach edits an athlete's prescribed reps/weight.
909
999
  *
1000
+ * Each set fills a 1-based slot: its explicit `slot`, or its sequential position in `results`
1001
+ * when `slot` is omitted. A `log` carrying the live exercise record in `existing` keeps the slots
1002
+ * it does not write that were ALREADY performed (`param_N_made === 1`), so logging a second part of
1003
+ * a set does not wipe the earlier-logged sets. A slot holding only un-logged prescription pre-fill
1004
+ * (`param_N_made === 0`) is left blank rather than carried over: marking the set completed makes
1005
+ * the server flag every data-bearing slot performed, so preserving that pre-fill would fabricate
1006
+ * sets the athlete never did. The prescription is unaffected (it lives in the separate `workout`
1007
+ * copy, not this saved copy). A `prescribe` ignores `existing` and replaces the whole prescription.
1008
+ *
910
1009
  * Only `savedWorkoutSetExerciseId`, `savedWorkoutSetId`, and `workoutSetExerciseId` are
911
- * required from the live exercise record; everything else is derived from `results`.
1010
+ * required from the live exercise record; everything else is derived from `results` and the
1011
+ * preserved slots of `existing`.
912
1012
  *
913
1013
  * Exported for unit testing — callers should use `logAthleteSet` / `prescribeForAthlete` instead.
914
1014
  */
915
- function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results, mode) {
916
- if (results.length > MAX_PARAM_SLOTS) throw new Error(`At most ${MAX_PARAM_SLOTS} sets are supported per exercise; got ${results.length}.`);
1015
+ function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results, mode, existing) {
1016
+ if (results.length > 10) throw new Error(`At most 10 sets are supported per exercise; got ${results.length}.`);
1017
+ const bySlot = /* @__PURE__ */ new Map();
1018
+ results.forEach((set, i) => {
1019
+ const slot = set.slot ?? i + 1;
1020
+ if (slot < 1 || slot > 10) throw new Error(`Set slot ${slot} is out of range; slots are 1–10.`);
1021
+ if (bySlot.has(slot)) throw new Error(`Two sets target slot ${slot}; each slot can be written once.`);
1022
+ bySlot.set(slot, set);
1023
+ });
917
1024
  const performed = mode === "log";
918
- const hasData = results.some((s) => s.param1 !== void 0 || s.param2 !== void 0);
1025
+ const carryOver = performed && existing !== void 0;
919
1026
  const body = {
920
1027
  id: savedWorkoutSetExerciseId,
921
1028
  saved_workout_set_id: savedWorkoutSetId,
922
- workout_set_exercise_id: workoutSetExerciseId,
923
- completed: performed && hasData ? 1 : 0
1029
+ workout_set_exercise_id: workoutSetExerciseId
924
1030
  };
925
- for (let i = 1; i <= MAX_PARAM_SLOTS; i += 1) {
926
- const slot = results[i - 1];
927
- const p1 = slot?.param1 !== void 0 ? String(slot.param1) : "";
928
- const p2 = slot?.param2 !== void 0 ? String(slot.param2) : "";
929
- body[`param_${i}_made`] = performed && (p1 !== "" || p2 !== "") ? 1 : 0;
1031
+ let anyMade = false;
1032
+ for (let i = 1; i <= 10; i += 1) {
1033
+ const target = bySlot.get(i);
1034
+ let p1;
1035
+ let p2;
1036
+ let made;
1037
+ if (target) {
1038
+ p1 = target.param1 !== void 0 ? String(target.param1) : "";
1039
+ p2 = target.param2 !== void 0 ? String(target.param2) : "";
1040
+ made = performed && (p1 !== "" || p2 !== "") ? 1 : 0;
1041
+ } else if (carryOver && coerceInt(existing?.[`param_${i}_made`]) === 1) {
1042
+ p1 = existingSlotData(existing, `param_1_data_${i}`);
1043
+ p2 = existingSlotData(existing, `param_2_data_${i}`);
1044
+ made = 1;
1045
+ } else {
1046
+ p1 = "";
1047
+ p2 = "";
1048
+ made = 0;
1049
+ }
1050
+ if (made === 1) anyMade = true;
1051
+ body[`param_${i}_made`] = made;
930
1052
  body[`param_1_data_${i}`] = p1;
931
1053
  body[`param_2_data_${i}`] = p2;
932
1054
  }
1055
+ body.completed = performed && anyMade ? 1 : 0;
933
1056
  return body;
934
1057
  }
1058
+ /** Read a saved-copy slot value (`param_1_data_N` / `param_2_data_N`) as the string the body uses. */
1059
+ function existingSlotData(existing, key) {
1060
+ const v = existing?.[key];
1061
+ return v === void 0 || v === null ? "" : String(v);
1062
+ }
1063
+ /** True when a saved-copy exercise already carries a performed slot (any `param_N_made` === 1). */
1064
+ function exerciseHasLoggedData(ex) {
1065
+ for (let i = 1; i <= 10; i += 1) if (coerceInt(ex[`param_${i}_made`]) === 1) return true;
1066
+ return false;
1067
+ }
1068
+ /** True when a result carries at least one non-empty param value (so it produces a performed slot). */
1069
+ function resultHasData(result) {
1070
+ return result.sets.some((s) => s.param1 !== void 0 && String(s.param1) !== "" || s.param2 !== void 0 && String(s.param2) !== "");
1071
+ }
1072
+ /**
1073
+ * Whether every exercise in a saved workout set now has logged data — either written with data in
1074
+ * this call (`loggedIds`) or already carrying a performed slot. Gates the set-completion PUT: a
1075
+ * superset/circuit stays open until the last exercise is logged, so completing it on a partial log
1076
+ * does not flip its still-empty siblings to "done". An exercise written with only empty values does
1077
+ * not count (it would not be marked performed), so an all-empty log never completes the set.
1078
+ */
1079
+ function isSetFullyLogged(exercises, loggedIds) {
1080
+ return exercises.every((ex) => {
1081
+ const id = coerceInt(ex.id);
1082
+ return id !== null && loggedIds.has(id) || exerciseHasLoggedData(ex);
1083
+ });
1084
+ }
935
1085
  /**
936
1086
  * Locate the target saved workout set across all program workouts on the given day.
937
1087
  * Returns the `savedWorkoutId`, the matching set's `workoutSetExercises` array so callers
@@ -1030,7 +1180,8 @@ async function logAthleteSet(client, args) {
1030
1180
  const r = await writeSetResults(client, { role: "athlete" }, await fetchAthleteWorkouts(client, args.date, args.date), args.savedWorkoutSetId, args.results, "log");
1031
1181
  return {
1032
1182
  savedWorkoutSetId: r.savedWorkoutSetId,
1033
- exercisesLogged: r.exercisesWritten
1183
+ exercisesLogged: r.exercisesWritten,
1184
+ setCompleted: r.setCompleted
1034
1185
  };
1035
1186
  }
1036
1187
  /**
@@ -1051,7 +1202,8 @@ async function logForAthlete(client, args) {
1051
1202
  }, workouts, args.savedWorkoutSetId, args.results, "log");
1052
1203
  return {
1053
1204
  savedWorkoutSetId: r.savedWorkoutSetId,
1054
- exercisesLogged: r.exercisesWritten
1205
+ exercisesLogged: r.exercisesWritten,
1206
+ setCompleted: r.setCompleted
1055
1207
  };
1056
1208
  }
1057
1209
  /**
@@ -1082,9 +1234,9 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
1082
1234
  throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${savedWorkoutSetId}. Exercises in this set: ${valid.join(", ") || "none"}.`);
1083
1235
  }
1084
1236
  const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
1085
- if (!workoutSetExerciseId) throw new Error(`Could not resolve workout_set_exercise_id for exercise ${result.savedWorkoutSetExerciseId}.`);
1237
+ if (!workoutSetExerciseId) throw new Error(`savedWorkoutSetExercise ${result.savedWorkoutSetExerciseId} is missing its workout_set_exercise_id (the prescription-template pointer the write needs). This is the savedWorkoutSetExerciseId, not an exercise_id — re-read the ids from athlete_saved_workouts.`);
1086
1238
  const body = {
1087
- ...buildExerciseSetPayload(result.savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, result.sets, mode),
1239
+ ...buildExerciseSetPayload(result.savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, result.sets, mode, ex),
1088
1240
  ...extra
1089
1241
  };
1090
1242
  const res = await client.request("PUT", `/1.0/${target.role}/savedworkoutsetexercise/${result.savedWorkoutSetExerciseId}${suffix}`, { body });
@@ -1094,17 +1246,22 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
1094
1246
  }
1095
1247
  exercisesWritten += 1;
1096
1248
  }
1249
+ let setCompleted = false;
1097
1250
  if (mode === "log") {
1098
- const setBody = {
1099
- ...buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true),
1100
- ...extra
1101
- };
1102
- const setRes = await client.request("PUT", `/1.0/${target.role}/savedworkoutset/${savedWorkoutSetId}${suffix}`, { body: setBody });
1103
- if (!setRes.ok) throw new Error(`Failed to mark workout set ${savedWorkoutSetId} completed (HTTP ${setRes.status}).`);
1251
+ if (isSetFullyLogged(exercises, new Set(results.filter(resultHasData).map((r) => r.savedWorkoutSetExerciseId)))) {
1252
+ const setBody = {
1253
+ ...buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true),
1254
+ ...extra
1255
+ };
1256
+ const setRes = await client.request("PUT", `/1.0/${target.role}/savedworkoutset/${savedWorkoutSetId}${suffix}`, { body: setBody });
1257
+ if (!setRes.ok) throw new Error(`Failed to mark workout set ${savedWorkoutSetId} completed (HTTP ${setRes.status}).`);
1258
+ setCompleted = true;
1259
+ }
1104
1260
  }
1105
1261
  return {
1106
1262
  savedWorkoutSetId,
1107
- exercisesWritten
1263
+ exercisesWritten,
1264
+ setCompleted
1108
1265
  };
1109
1266
  }
1110
1267
  /**
@@ -1211,7 +1368,10 @@ async function logResolvedExercises(client, target, date, resolved) {
1211
1368
  savedWorkoutSetId,
1212
1369
  results
1213
1370
  });
1214
- out.push(written);
1371
+ out.push({
1372
+ savedWorkoutSetId: written.savedWorkoutSetId,
1373
+ exercisesLogged: written.exercisesLogged
1374
+ });
1215
1375
  }
1216
1376
  return out;
1217
1377
  }
@@ -1254,27 +1414,6 @@ async function logAdHocSession(client, args) {
1254
1414
  sets
1255
1415
  };
1256
1416
  }
1257
- /** Flatten `/v5/exercises/{id}/history` into PRs + a session time-series. */
1258
- function presentExerciseHistory(detail) {
1259
- return {
1260
- liftPRs: (detail.liftPRs ?? []).map((p) => ({
1261
- description: p.description ?? null,
1262
- reps: p.reps ?? null,
1263
- weight: p.weight ?? null,
1264
- units: p.units ?? null,
1265
- date: p.dateCompleted ?? null
1266
- })),
1267
- sessions: (detail.history ?? []).map((h) => ({
1268
- date: h.dateCompleted,
1269
- abr: h.abr ?? null,
1270
- estimated1RM: h.bestEstimated1RM ?? null,
1271
- sets: (h.sets ?? []).map((s) => ({
1272
- setNumber: s.setNumber,
1273
- value: s.formattedValue ?? null
1274
- }))
1275
- }))
1276
- };
1277
- }
1278
1417
  //#endregion
1279
1418
  //#region ../core/src/history.ts
1280
1419
  /**
@@ -1499,7 +1638,7 @@ function registerLogTool(server, ctx) {
1499
1638
  }));
1500
1639
  server.registerTool("athlete_log_set", {
1501
1640
  title: "Log completed set results",
1502
- description: "Athlete-facing write: record entered results (reps/weight per set) for a saved workout set on a given day, marking the set completed. Writes to the athlete's (coach-visible) training log and shows in exercise history. Get savedWorkoutSetId + savedWorkoutSetExerciseId from athlete_workouts (raw:true). Requires confirmation (elicitation or confirm:true).",
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 athlete_workouts (raw:true). Requires confirmation (elicitation or confirm:true).",
1503
1642
  inputSchema: {
1504
1643
  ...logSetArgsSchema.shape,
1505
1644
  confirm: z.boolean().optional()
@@ -1507,19 +1646,10 @@ function registerLogTool(server, ctx) {
1507
1646
  annotations: DESTRUCTIVE
1508
1647
  }, ({ date, savedWorkoutSetId, results, confirm }, extra) => attempt(async () => {
1509
1648
  if (!await confirmGate(server, extra.requestId, `Log results to saved workout set ${toId(savedWorkoutSetId)} on ${date}? This writes to your coach-visible training log.`, confirm)) return errorResult(NOT_CONFIRMED);
1510
- const mapped = results.map((r) => ({
1511
- savedWorkoutSetExerciseId: toId(r.savedWorkoutSetExerciseId),
1512
- sets: r.sets.map((s) => {
1513
- const slot = {};
1514
- if (s.param1 !== void 0) slot.param1 = s.param1;
1515
- if (s.param2 !== void 0) slot.param2 = s.param2;
1516
- return slot;
1517
- })
1518
- }));
1519
1649
  return jsonResult(await logAthleteSet(ctx.client, {
1520
1650
  date,
1521
1651
  savedWorkoutSetId: toId(savedWorkoutSetId),
1522
- results: mapped
1652
+ results: toSetResults(results)
1523
1653
  }));
1524
1654
  }));
1525
1655
  }
@@ -1550,7 +1680,7 @@ function registerAthleteTrainingTools(server, ctx) {
1550
1680
  }
1551
1681
  //#endregion
1552
1682
  //#region package.json
1553
- var version = "1.3.0";
1683
+ var version = "1.5.0";
1554
1684
  //#endregion
1555
1685
  //#region src/server.ts
1556
1686
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/athlete-mcp",
3
- "version": "1.3.0",
3
+ "version": "1.5.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.3.0",
25
- "@trainheroic-unofficial/js": "1.3.0"
24
+ "@trainheroic-unofficial/core": "1.5.0",
25
+ "@trainheroic-unofficial/js": "1.5.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^26.0.0",