@trainheroic-unofficial/athlete-mcp 3.3.3 → 3.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 +186 -15
  2. package/package.json +5 -5
package/dist/server.mjs CHANGED
@@ -257,6 +257,29 @@ const athleteSessionRemoveArgsSchema = z.object({
257
257
  programWorkoutId: idArgSchema,
258
258
  date: dateString
259
259
  });
260
+ /**
261
+ * Args for the athlete session-note write. `programWorkoutId` is the range item's top-level `id`
262
+ * (athlete_workouts); `date` locates that day so the SDK can resolve the saved-workout id the PUT
263
+ * targets. `notes` is the free-text box on the workout screen (empty string clears it). `rpe` is
264
+ * the session RPE (1–10). At least one of `notes` / `rpe` is required — a notes-only PUT leaves
265
+ * rpe untouched and vice versa.
266
+ */
267
+ const athleteWorkoutNoteObject = z.object({
268
+ date: dateString,
269
+ programWorkoutId: idArgSchema,
270
+ notes: z.string().optional(),
271
+ rpe: z.number().int().min(1).max(10).optional()
272
+ });
273
+ const athleteWorkoutNoteArgsSchema = athleteWorkoutNoteObject.refine((v) => v.notes !== void 0 || v.rpe !== void 0, { message: "Provide notes and/or rpe" });
274
+ /**
275
+ * Args for the athlete per-exercise note write (the "Add exercise note" box on the exercise
276
+ * screen — band color, etc.). `savedWorkoutSetExerciseId` is the slot id from athlete_log_targets.
277
+ * Empty `notes` clears the note. Distinct from {@link athleteWorkoutNoteArgsSchema} (session note).
278
+ */
279
+ const athleteExerciseNoteArgsSchema = z.object({
280
+ savedWorkoutSetExerciseId: idArgSchema,
281
+ notes: z.string()
282
+ });
260
283
  const presentedNullNumber = z.number().nullable();
261
284
  const presentedNullString = z.string().nullable();
262
285
  /** Flattened exercise within a presented workout: prescriptions, logged results, units. */
@@ -264,6 +287,7 @@ const athleteWorkoutExerciseSchema = z.object({
264
287
  exerciseId: presentedNullNumber,
265
288
  title: z.string(),
266
289
  instruction: presentedNullString,
290
+ notes: presentedNullString,
267
291
  units: z.array(presentedNullString),
268
292
  prescribed: z.array(z.string()),
269
293
  performed: z.array(z.string())
@@ -282,6 +306,8 @@ const athleteWorkoutViewSchema = z.object({
282
306
  program: presentedNullString,
283
307
  team: presentedNullString,
284
308
  instruction: presentedNullString,
309
+ notes: presentedNullString,
310
+ rpe: presentedNullNumber,
285
311
  logged: z.boolean(),
286
312
  personal: z.boolean(),
287
313
  blocks: z.array(athleteWorkoutBlockSchema)
@@ -371,6 +397,7 @@ z.array(rosterActivityRowSchema);
371
397
  const presentedExerciseSessionSchema = z.object({
372
398
  date: z.string(),
373
399
  abr: presentedNullString,
400
+ notes: presentedNullString,
374
401
  estimated1RM: presentedNullNumber,
375
402
  sets: z.array(z.object({
376
403
  setNumber: z.number(),
@@ -507,6 +534,7 @@ const logSetTargetOutputSchema = z.object({
507
534
  savedWorkoutSetExerciseId: z.number(),
508
535
  title: z.string(),
509
536
  units: z.array(nullableString),
537
+ notes: nullableString,
510
538
  prescribed: z.array(z.string()),
511
539
  performed: z.array(z.string())
512
540
  }))
@@ -574,6 +602,17 @@ const personalWorkoutCreatedOutputSchema = z.object({
574
602
  groupId: z.number(),
575
603
  date: z.string()
576
604
  });
605
+ const athleteWorkoutNoteOutputSchema = z.object({
606
+ programWorkoutId: z.number(),
607
+ savedWorkoutId: z.number(),
608
+ date: z.string(),
609
+ notes: z.string(),
610
+ rpe: nullableNumber
611
+ });
612
+ const athleteExerciseNoteOutputSchema = z.object({
613
+ savedWorkoutSetExerciseId: z.number(),
614
+ notes: z.string()
615
+ });
577
616
  const workoutReadExerciseOutputSchema = z.object({
578
617
  order: z.number(),
579
618
  title: z.string(),
@@ -1202,6 +1241,15 @@ function isPersonalSession(pw) {
1202
1241
  return isRecord(pw) && pw.personal_cal === true;
1203
1242
  }
1204
1243
  /**
1244
+ * The `saved_workout` blob on a program-workout range item, or null if the athlete has no saved
1245
+ * copy yet. Session notes, RPE, and set writes all resolve through this.
1246
+ */
1247
+ function savedWorkoutOf(pw) {
1248
+ if (!isRecord(pw)) return null;
1249
+ const ssw = isRecord(pw.summarizedSavedWorkout) ? pw.summarizedSavedWorkout : {};
1250
+ return isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
1251
+ }
1252
+ /**
1205
1253
  * Rank candidate rows for a free-text query (FTS5 replacement). Higher is better:
1206
1254
  * exact title, then prefix, then count of matched tokens, with shorter titles and
1207
1255
  * standard (non-custom) exercises preferred on ties.
@@ -1278,6 +1326,15 @@ function fetchExerciseStats(client, exerciseId, userId, date) {
1278
1326
  function fetchAthleteWorkouts(client, startDate, endDate) {
1279
1327
  return getArray(client, `/3.0/athlete/programworkout/range?startDate=${startDate}&endDate=${endDate}`, "athlete workouts");
1280
1328
  }
1329
+ /**
1330
+ * The program-workout row with this id on this date. Throws if that id is not on the day's range;
1331
+ * callers should take the id from `athlete_workouts`.
1332
+ */
1333
+ async function programWorkoutOnDate(client, date, programWorkoutId) {
1334
+ const target = (await fetchAthleteWorkouts(client, date, date)).find((pw) => coerceInt(pw.id) === programWorkoutId);
1335
+ if (target === void 0) throw new Error(`No workout with id ${programWorkoutId} on ${date}. Get the id and date from athlete_workouts.`);
1336
+ return target;
1337
+ }
1281
1338
  function fetchLeaderboard(client, workoutId, opts = {}) {
1282
1339
  const qs = new URLSearchParams();
1283
1340
  if (opts.page !== void 0) qs.set("page", String(opts.page));
@@ -1356,6 +1413,16 @@ function performedSlotsByExerciseId(sets) {
1356
1413
  }
1357
1414
  return map;
1358
1415
  }
1416
+ /** Per-exercise athlete notes keyed by the prescription template id (`workout_set_exercise_id`). */
1417
+ function notesByTemplateId(sets) {
1418
+ const map = /* @__PURE__ */ new Map();
1419
+ for (const { exercises } of sets) for (const ex of exercises) {
1420
+ const id = coerceInt(ex.workout_set_exercise_id);
1421
+ const notes = str(ex.notes);
1422
+ if (id !== null && notes !== null) map.set(id, notes);
1423
+ }
1424
+ return map;
1425
+ }
1359
1426
  /** Align an exercise's prescribed slots with the slots the athlete logged, per set index. */
1360
1427
  function mergeExercise(prescribed, performed, meta) {
1361
1428
  const indices = [.../* @__PURE__ */ new Set([...prescribed.keys(), ...performed.keys()])].sort((a, b) => a - b);
@@ -1369,7 +1436,7 @@ function mergeExercise(prescribed, performed, meta) {
1369
1436
  };
1370
1437
  }
1371
1438
  /** A prescription block, each exercise aligned with the slots the athlete logged against it. */
1372
- function mergePrescriptionBlock(set, performedById) {
1439
+ function mergePrescriptionBlock(set, performedById, notesById) {
1373
1440
  const exercises = Array.isArray(set.workoutSetExercises) ? set.workoutSetExercises : [];
1374
1441
  return {
1375
1442
  order: coerceInt(set.order) ?? 0,
@@ -1383,6 +1450,7 @@ function mergePrescriptionBlock(set, performedById) {
1383
1450
  exerciseId: coerceInt(ex.exercise_id),
1384
1451
  title: typeof ex.title === "string" ? ex.title : "",
1385
1452
  instruction: str(ex.instruction),
1453
+ notes: id !== null ? notesById.get(id) ?? null : null,
1386
1454
  units: exerciseUnits(ex.param_1_type, ex.param_2_type)
1387
1455
  });
1388
1456
  })
@@ -1399,6 +1467,7 @@ function mergeSavedBlock(set, exercises) {
1399
1467
  exerciseId: coerceInt(ex.exercise_id),
1400
1468
  title: typeof ex.exercise_title === "string" ? ex.exercise_title : "",
1401
1469
  instruction: str(ex.instruction),
1470
+ notes: str(ex.notes),
1402
1471
  units: exerciseUnits(ex.param_1_type, ex.param_2_type)
1403
1472
  }))
1404
1473
  };
@@ -1413,11 +1482,12 @@ function mergeAthleteWorkout(raw) {
1413
1482
  const rec = raw;
1414
1483
  const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
1415
1484
  const workout = isRecord(ssw.workout) ? ssw.workout : {};
1416
- const saved = isRecord(ssw.saved_workout) ? ssw.saved_workout : {};
1485
+ const saved = savedWorkoutOf(rec) ?? {};
1417
1486
  const prescriptionSets = (Array.isArray(workout.workoutSets) ? workout.workoutSets : []).filter(isRecord);
1418
1487
  const logged = savedSets(saved);
1419
1488
  const performedById = performedSlotsByExerciseId(logged);
1420
- const blocks = prescriptionSets.map((s) => mergePrescriptionBlock(s, performedById)).sort((a, b) => a.order - b.order);
1489
+ const notesById = notesByTemplateId(logged);
1490
+ const blocks = prescriptionSets.map((s) => mergePrescriptionBlock(s, performedById, notesById)).sort((a, b) => a.order - b.order);
1421
1491
  const prescribedIds = /* @__PURE__ */ new Set();
1422
1492
  for (const s of prescriptionSets) {
1423
1493
  const exs = Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises : [];
@@ -1442,6 +1512,8 @@ function mergeAthleteWorkout(raw) {
1442
1512
  program: str(rec.program_title),
1443
1513
  team: str(rec.team_title),
1444
1514
  instruction: str(workout.instruction),
1515
+ notes: str(saved.notes),
1516
+ rpe: coerceInt(saved.rpe),
1445
1517
  logged: blocks.some((b) => b.exercises.some((e) => e.sets.some((s) => s.performed !== null))),
1446
1518
  personal: isPersonalSession(rec),
1447
1519
  blocks
@@ -1461,6 +1533,7 @@ function toStringExercise(ex) {
1461
1533
  exerciseId: ex.exerciseId,
1462
1534
  title: ex.title,
1463
1535
  instruction: ex.instruction,
1536
+ notes: ex.notes,
1464
1537
  units: ex.units,
1465
1538
  prescribed: sideStrings(ex.sets, (s) => s.prescribed),
1466
1539
  performed: sideStrings(ex.sets, (s) => s.performed)
@@ -1489,6 +1562,8 @@ function presentAthleteWorkout(raw) {
1489
1562
  program: w.program,
1490
1563
  team: w.team,
1491
1564
  instruction: w.instruction,
1565
+ notes: w.notes,
1566
+ rpe: w.rpe,
1492
1567
  logged: w.logged,
1493
1568
  personal: w.personal,
1494
1569
  blocks: w.blocks.map(toStringBlock)
@@ -1553,6 +1628,7 @@ function presentLogTargets(list) {
1553
1628
  savedWorkoutSetExerciseId: id,
1554
1629
  title: exerciseTitle(ex),
1555
1630
  units: exerciseUnits(ex.param_1_type, ex.param_2_type),
1631
+ notes: str(ex.notes),
1556
1632
  prescribed: prescribedStrings(ex),
1557
1633
  performed: performedStrings(ex)
1558
1634
  };
@@ -1620,6 +1696,7 @@ function presentExerciseHistory(detail) {
1620
1696
  sessions: (detail.history ?? []).map((h) => ({
1621
1697
  date: h.dateCompleted,
1622
1698
  abr: h.abr ?? null,
1699
+ notes: str(h.notes),
1623
1700
  estimated1RM: h.bestEstimated1RM ?? null,
1624
1701
  sets: (h.sets ?? []).map((s) => ({
1625
1702
  setNumber: s.setNumber,
@@ -1751,9 +1828,7 @@ function assertUniqueExerciseResults(results) {
1751
1828
  function findSavedWorkoutSet(workouts, savedWorkoutSetId) {
1752
1829
  const available = [];
1753
1830
  for (const pw of workouts) {
1754
- const rec = pw;
1755
- const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
1756
- const sw = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
1831
+ const sw = savedWorkoutOf(pw);
1757
1832
  if (!sw) continue;
1758
1833
  const sets = Array.isArray(sw.workoutSets) ? sw.workoutSets : [];
1759
1834
  for (const s of sets) {
@@ -2002,9 +2077,7 @@ async function addExercisesToWorkout(client, workoutId, exercises) {
2002
2077
  * Throws on a non-ok response.
2003
2078
  */
2004
2079
  async function removePersonalWorkout(client, args) {
2005
- const target = (await fetchAthleteWorkouts(client, args.date, args.date)).find((pw) => coerceInt(pw.id) === args.programWorkoutId);
2006
- if (target === void 0) throw new Error(`No workout with id ${args.programWorkoutId} on ${args.date}. Get the id and date from athlete_workouts.`);
2007
- 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.`);
2080
+ if (!isPersonalSession(await programWorkoutOnDate(client, args.date, args.programWorkoutId))) 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.`);
2008
2081
  const res = await client.request("DELETE", `/v5/programWorkouts/${args.programWorkoutId}`);
2009
2082
  if (!res.ok) throw new Error(`Remove personal workout failed (HTTP ${res.status}).`);
2010
2083
  }
@@ -2057,8 +2130,7 @@ function eachPrescribedExercise(workouts) {
2057
2130
  const rows = [];
2058
2131
  for (const pw of workouts) {
2059
2132
  const rec = pw;
2060
- const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
2061
- const sw = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
2133
+ const sw = savedWorkoutOf(rec);
2062
2134
  if (!sw) continue;
2063
2135
  const sets = [...Array.isArray(sw.workoutSets) ? sw.workoutSets : [], ...Array.isArray(sw.addedWorkoutSets) ? sw.addedWorkoutSets : []];
2064
2136
  for (const s of sets) {
@@ -2182,6 +2254,59 @@ function definedProps(obj) {
2182
2254
  return result;
2183
2255
  }
2184
2256
  //#endregion
2257
+ //#region ../js/src/athlete-workout-note.ts
2258
+ /**
2259
+ * PUT /1.0/athlete/savedworkout/{id} — set the athlete's session note (the free-text box on the
2260
+ * workout screen) and/or session RPE. The date + programWorkoutId (`athlete_workouts` `id`) locate
2261
+ * the saved workout; GET on that path 405s, so the range read is the lookup. A notes-only body
2262
+ * leaves rpe untouched and vice versa. Empty `notes` clears the note. Coach-visible.
2263
+ */
2264
+ async function setAthleteWorkoutNote(client, args) {
2265
+ if (args.notes === void 0 && args.rpe === void 0) throw new Error("Provide notes and/or rpe.");
2266
+ const programWorkoutId = coerceInt(args.programWorkoutId);
2267
+ if (programWorkoutId === null) throw new Error(`Invalid programWorkoutId ${String(args.programWorkoutId)}.`);
2268
+ const saved = savedWorkoutOf(await programWorkoutOnDate(client, args.date, programWorkoutId));
2269
+ const savedWorkoutId = saved !== null ? coerceInt(saved.id) : null;
2270
+ if (savedWorkoutId === null) throw new Error(`Workout ${programWorkoutId} on ${args.date} has no saved workout to attach a note to.`);
2271
+ const body = definedProps({
2272
+ id: savedWorkoutId,
2273
+ notes: args.notes,
2274
+ rpe: args.rpe
2275
+ });
2276
+ const res = await client.request("PUT", `/1.0/athlete/savedworkout/${savedWorkoutId}`, { body });
2277
+ if (!res.ok) throw new Error(`Set workout note failed (HTTP ${res.status}).`);
2278
+ const data = isRecord(res.data) ? res.data : {};
2279
+ return {
2280
+ programWorkoutId,
2281
+ savedWorkoutId,
2282
+ date: args.date,
2283
+ notes: typeof data.notes === "string" ? data.notes : args.notes ?? "",
2284
+ rpe: coerceInt(data.rpe) ?? (args.rpe !== void 0 ? args.rpe : null)
2285
+ };
2286
+ }
2287
+ //#endregion
2288
+ //#region ../js/src/athlete-exercise-note.ts
2289
+ /**
2290
+ * PUT /1.0/athlete/savedworkoutsetexercise/{id} — set the athlete's per-exercise note (the
2291
+ * "Add exercise note" box on the exercise screen). A notes-only body leaves logged reps/weight
2292
+ * untouched. Empty `notes` clears the note. GET on that path 405s; the range read and exercise
2293
+ * history both echo the stored string. Coach-visible.
2294
+ */
2295
+ async function setAthleteExerciseNote(client, args) {
2296
+ const savedWorkoutSetExerciseId = coerceInt(args.savedWorkoutSetExerciseId);
2297
+ if (savedWorkoutSetExerciseId === null) throw new Error(`Invalid savedWorkoutSetExerciseId ${String(args.savedWorkoutSetExerciseId)}.`);
2298
+ const res = await client.request("PUT", `/1.0/athlete/savedworkoutsetexercise/${savedWorkoutSetExerciseId}`, { body: {
2299
+ id: savedWorkoutSetExerciseId,
2300
+ notes: args.notes
2301
+ } });
2302
+ if (!res.ok) throw new Error(`Set exercise note failed (HTTP ${res.status}).`);
2303
+ const data = isRecord(res.data) ? res.data : {};
2304
+ return {
2305
+ savedWorkoutSetExerciseId,
2306
+ notes: typeof data.notes === "string" ? data.notes : args.notes
2307
+ };
2308
+ }
2309
+ //#endregion
2185
2310
  //#region ../core/src/history.ts
2186
2311
  /**
2187
2312
  * Trim a presented exercise history's session time-series to an inclusive YYYY-MM-DD window.
@@ -2261,9 +2386,9 @@ function registerProfileTools(server, ctx, whoami, userId) {
2261
2386
  return jsonResult(await fetchLeaderboard(ctx.client, toId(workoutId), opts));
2262
2387
  }));
2263
2388
  }
2264
- const ATHLETE_WORKOUTS_DESC = "Workouts on the AUTHENTICATED user's own athlete calendar in an inclusive YYYY-MM-DD window, flattened to blocks/exercises. When a coach account calls this, it returns the coach's own training schedule — not a roster athlete's. To inspect a roster athlete use athlete_saved_workouts (or athlete_training for a month overview); to verify a coach-published team session from the calendar side use workout_read. 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.";
2265
- 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.";
2266
- 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.";
2389
+ const ATHLETE_WORKOUTS_DESC = "Workouts on the AUTHENTICATED user's own athlete calendar in an inclusive YYYY-MM-DD window, flattened to blocks/exercises. When a coach account calls this, it returns the coach's own training schedule — not a roster athlete's. To inspect a roster athlete use athlete_saved_workouts (or athlete_training for a month overview); to verify a coach-published team session from the calendar side use workout_read. 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. Session `notes` and `rpe` are the athlete's own note/RPE on that workout (null when unset), distinct from coach `instruction`. Each exercise may also carry `notes` (the per-exercise box on the exercise screen; null when unset). 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.";
2390
+ 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 and any per-exercise `notes`. 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.";
2391
+ 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. Each session may carry `notes` (the per-exercise athlete note). `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.";
2267
2392
  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.";
2268
2393
  function runAthleteWorkouts(ctx, args) {
2269
2394
  return attempt(async () => {
@@ -2432,6 +2557,50 @@ function registerSessionTools(server, ctx) {
2432
2557
  });
2433
2558
  }));
2434
2559
  }
2560
+ /** Set the athlete's session note / RPE on a saved workout (coach-visible). */
2561
+ function registerWorkoutNoteTool(server, ctx) {
2562
+ server.registerTool("athlete_workout_note", {
2563
+ title: "Set your session note or RPE",
2564
+ description: "Athlete-facing write: set the free-text note (and/or session RPE 1–10) on a workout. This is the note box on the workout screen, not the coach's Instructions and not the per-exercise note (use athlete_exercise_note for that). programWorkoutId is the `id` from athlete_workouts for that date. Pass notes (empty string clears) and/or rpe; a notes-only write leaves rpe untouched and vice versa. Visible to your coach. Requires confirmation (elicitation or confirm:true).",
2565
+ inputSchema: {
2566
+ ...athleteWorkoutNoteObject.shape,
2567
+ confirm: z.boolean().optional()
2568
+ },
2569
+ outputSchema: toolOutputSchema(athleteWorkoutNoteOutputSchema),
2570
+ annotations: DESTRUCTIVE
2571
+ }, ({ date, programWorkoutId, notes, rpe, confirm }, extra) => attempt(async () => {
2572
+ const patch = athleteWorkoutNoteArgsSchema.parse(definedProps({
2573
+ date,
2574
+ programWorkoutId,
2575
+ notes,
2576
+ rpe
2577
+ }));
2578
+ const blocked = confirmGate(extra, `Set the session note on workout ${toId(patch.programWorkoutId)} on ${patch.date}? This is visible to your coach.`, confirm);
2579
+ if (blocked) return blocked;
2580
+ return jsonResult(await setAthleteWorkoutNote(ctx.client, patch));
2581
+ }));
2582
+ }
2583
+ /** Set the athlete's per-exercise note on a saved slot (coach-visible). */
2584
+ function registerExerciseNoteTool(server, ctx) {
2585
+ server.registerTool("athlete_exercise_note", {
2586
+ title: "Set a per-exercise note",
2587
+ description: "Athlete-facing write: set the free-text note on one exercise in a workout (the 'Add exercise note' box on the exercise screen). Use this for things the weight field cannot hold, such as which band you used. Distinct from athlete_workout_note, which writes the session-level note. savedWorkoutSetExerciseId is the slot id from athlete_log_targets. Empty notes clears the note. Visible to your coach. Requires confirmation (elicitation or confirm:true).",
2588
+ inputSchema: {
2589
+ ...athleteExerciseNoteArgsSchema.shape,
2590
+ confirm: z.boolean().optional()
2591
+ },
2592
+ outputSchema: toolOutputSchema(athleteExerciseNoteOutputSchema),
2593
+ annotations: DESTRUCTIVE
2594
+ }, ({ savedWorkoutSetExerciseId, notes, confirm }, extra) => attempt(async () => {
2595
+ const patch = athleteExerciseNoteArgsSchema.parse({
2596
+ savedWorkoutSetExerciseId,
2597
+ notes
2598
+ });
2599
+ const blocked = confirmGate(extra, `Set the exercise note on slot ${toId(patch.savedWorkoutSetExerciseId)}? This is visible to your coach.`, confirm);
2600
+ if (blocked) return blocked;
2601
+ return jsonResult(await setAthleteExerciseNote(ctx.client, patch));
2602
+ }));
2603
+ }
2435
2604
  /** Map validated logSession exercises to the SDK's SessionExercise[] (ids coerced, slots trimmed). */
2436
2605
  function mapSessionExercises(exercises) {
2437
2606
  return exercises.map((e) => {
@@ -2583,12 +2752,14 @@ function registerAthleteTrainingTools(server, ctx) {
2583
2752
  registerCatalogReads(server, ctx);
2584
2753
  registerLogTargetsTool(server, ctx);
2585
2754
  registerSessionTools(server, ctx);
2755
+ registerWorkoutNoteTool(server, ctx);
2756
+ registerExerciseNoteTool(server, ctx);
2586
2757
  registerLogTool(server, ctx);
2587
2758
  registerSwapTool(server, ctx);
2588
2759
  }
2589
2760
  //#endregion
2590
2761
  //#region package.json
2591
- var version = "3.3.3";
2762
+ var version = "3.5.0";
2592
2763
  //#endregion
2593
2764
  //#region src/server.ts
2594
2765
  function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/athlete-mcp",
3
- "version": "3.3.3",
3
+ "version": "3.5.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,15 +21,15 @@
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/server": "2.0.0",
23
23
  "zod": "^4.4.3",
24
- "@trainheroic-unofficial/core": "3.3.3",
25
- "@trainheroic-unofficial/js": "3.3.3"
24
+ "@trainheroic-unofficial/core": "3.5.0",
25
+ "@trainheroic-unofficial/js": "3.5.0"
26
26
  },
27
27
  "devDependencies": {
28
- "@types/node": "^26.2.0",
28
+ "@types/node": "^26.3.0",
29
29
  "tsdown": "^0.22.14",
30
30
  "tsx": "^4.23.12",
31
31
  "typescript": "^7.0.2",
32
- "vitest": "^4.1.10"
32
+ "vitest": "^4.1.11"
33
33
  },
34
34
  "scripts": {
35
35
  "start": "tsx src/server.ts",