@trainheroic-unofficial/athlete-mcp 1.7.0 → 1.7.2
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 +54 -31
- package/package.json +3 -3
package/dist/server.mjs
CHANGED
|
@@ -338,6 +338,23 @@ const DEFAULT_RESULT_BUDGET = 6e4;
|
|
|
338
338
|
const MARKER_RESERVE = 300;
|
|
339
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.";
|
|
340
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
|
+
}
|
|
341
358
|
/** Active budget. Overridable via TH_MCP_RESULT_BUDGET on Node; the default on workerd. */
|
|
342
359
|
function resultBudget() {
|
|
343
360
|
const raw = (globalThis.process?.env)?.TH_MCP_RESULT_BUDGET;
|
|
@@ -394,16 +411,7 @@ function boundedSerialize(data, budget, hint) {
|
|
|
394
411
|
}
|
|
395
412
|
if (Array.isArray(data)) {
|
|
396
413
|
const k = largestPrefixCount(data.map((el) => JSON.stringify(el) ?? "null"), budget - MARKER_RESERVE);
|
|
397
|
-
const
|
|
398
|
-
items: data.slice(0, k),
|
|
399
|
-
__truncated: {
|
|
400
|
-
returned: k,
|
|
401
|
-
total: data.length,
|
|
402
|
-
omitted: data.length - k,
|
|
403
|
-
hint: hint ?? DEFAULT_ARRAY_HINT
|
|
404
|
-
}
|
|
405
|
-
};
|
|
406
|
-
const out = JSON.stringify(wrapped);
|
|
414
|
+
const out = JSON.stringify(clipArray(data, k, hint));
|
|
407
415
|
if (out.length <= budget) return out;
|
|
408
416
|
} else if (isPlainObject(data)) {
|
|
409
417
|
const key = largestArrayValuedKey(data);
|
|
@@ -1151,8 +1159,10 @@ function toSetResults(results) {
|
|
|
1151
1159
|
* `param_2_data_N` string values plus a `param_N_made` flag.
|
|
1152
1160
|
*
|
|
1153
1161
|
* `mode` selects which write this is — the same endpoint serves both:
|
|
1154
|
-
* - `"log"`: the values ARE a performed result, so `param_N_made` is 1 where the slot has
|
|
1155
|
-
* and the exercise `completed` flag is 1 when any set
|
|
1162
|
+
* - `"log"`: the values ARE a performed result, so `param_N_made` is 1 where the slot has reps
|
|
1163
|
+
* entered (`param1`) and the exercise `completed` flag is 1 when any set is performed. A slot
|
|
1164
|
+
* carrying only a weight (`param2`) with no reps is a target, not a performed set, so it stays
|
|
1165
|
+
* un-made — matching the app, which shows no completion checkmark until reps are logged.
|
|
1156
1166
|
* - `"prescribe"`: the values are prescribed targets, written with every `param_N_made` and
|
|
1157
1167
|
* `completed` left at 0 so the set is not marked done. This matches what the app sends when a
|
|
1158
1168
|
* coach edits an athlete's prescribed reps/weight.
|
|
@@ -1197,7 +1207,7 @@ function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, w
|
|
|
1197
1207
|
if (target) {
|
|
1198
1208
|
p1 = target.param1 !== void 0 ? String(target.param1) : "";
|
|
1199
1209
|
p2 = target.param2 !== void 0 ? String(target.param2) : "";
|
|
1200
|
-
made = performed &&
|
|
1210
|
+
made = performed && p1 !== "" ? 1 : 0;
|
|
1201
1211
|
} else if (carryOver && coerceInt(existing?.[`param_${i}_made`]) === 1) {
|
|
1202
1212
|
p1 = existingSlotData(existing, `param_1_data_${i}`);
|
|
1203
1213
|
p2 = existingSlotData(existing, `param_2_data_${i}`);
|
|
@@ -1225,16 +1235,19 @@ function exerciseHasLoggedData(ex) {
|
|
|
1225
1235
|
for (let i = 1; i <= 10; i += 1) if (coerceInt(ex[`param_${i}_made`]) === 1) return true;
|
|
1226
1236
|
return false;
|
|
1227
1237
|
}
|
|
1228
|
-
/** True when a result carries at least one
|
|
1229
|
-
|
|
1230
|
-
|
|
1238
|
+
/** True when a result carries at least one set with reps entered (so it produces a performed slot).
|
|
1239
|
+
* A weight-only set (`param2` set, `param1` blank) is a target, not a performed result, and so does
|
|
1240
|
+
* not count — mirroring the `made` rule in {@link buildExerciseSetPayload}. */
|
|
1241
|
+
function resultHasPerformedSet(result) {
|
|
1242
|
+
return result.sets.some((s) => s.param1 !== void 0 && String(s.param1) !== "");
|
|
1231
1243
|
}
|
|
1232
1244
|
/**
|
|
1233
1245
|
* Whether every exercise in a saved workout set now has logged data — either written with data in
|
|
1234
1246
|
* this call (`loggedIds`) or already carrying a performed slot. Gates the set-completion PUT: a
|
|
1235
1247
|
* superset/circuit stays open until the last exercise is logged, so completing it on a partial log
|
|
1236
|
-
* does not flip its still-empty siblings to "done". An exercise written with
|
|
1237
|
-
* not count (it would not be marked performed),
|
|
1248
|
+
* does not flip its still-empty siblings to "done". An exercise written with no reps — empty values,
|
|
1249
|
+
* or a weight (`param2`) with no reps (`param1`) — does not count (it would not be marked performed),
|
|
1250
|
+
* so a no-reps log never completes the set.
|
|
1238
1251
|
*/
|
|
1239
1252
|
function isSetFullyLogged(exercises, loggedIds) {
|
|
1240
1253
|
return exercises.every((ex) => {
|
|
@@ -1441,7 +1454,7 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
|
|
|
1441
1454
|
}
|
|
1442
1455
|
let setCompleted = false;
|
|
1443
1456
|
if (mode === "log") {
|
|
1444
|
-
if (isSetFullyLogged(exercises, new Set(results.filter(
|
|
1457
|
+
if (isSetFullyLogged(exercises, new Set(results.filter(resultHasPerformedSet).map((r) => r.savedWorkoutSetExerciseId)))) {
|
|
1445
1458
|
const setBody = {
|
|
1446
1459
|
...buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true),
|
|
1447
1460
|
...extra
|
|
@@ -1771,7 +1784,7 @@ function registerProfileTools(server, ctx, whoami, userId) {
|
|
|
1771
1784
|
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.";
|
|
1772
1785
|
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.";
|
|
1773
1786
|
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.";
|
|
1774
|
-
const ATHLETE_EXERCISES_DESC = "The exercises the athlete has logged (id + title + positional units). Pass q to free-text search by name
|
|
1787
|
+
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.";
|
|
1775
1788
|
function runAthleteWorkouts(ctx, args) {
|
|
1776
1789
|
return attempt(async () => {
|
|
1777
1790
|
const workouts = await fetchAthleteWorkouts(ctx.client, args.startDate, args.endDate);
|
|
@@ -1784,6 +1797,24 @@ function runAthleteWorkouts(ctx, args) {
|
|
|
1784
1797
|
return jsonResult(selected, { hint: "Large? Set summary:true for one row per session, loggedOnly:true, pass limit, or narrow the dates." });
|
|
1785
1798
|
});
|
|
1786
1799
|
}
|
|
1800
|
+
/**
|
|
1801
|
+
* Body of the athlete_exercises handler, hoisted to module scope so registerExerciseTools stays
|
|
1802
|
+
* under the oxlint max-lines-per-function cap (mirrors runAthleteWorkouts above).
|
|
1803
|
+
*/
|
|
1804
|
+
function runAthleteExercises(ctx, args) {
|
|
1805
|
+
const { q, limit } = args;
|
|
1806
|
+
return attempt(async () => {
|
|
1807
|
+
const searching = q !== void 0 && q.trim() !== "";
|
|
1808
|
+
const items = (searching ? await searchExerciseHistory(ctx.client, q, limit ?? 20) : await fetchExerciseHistoryList(ctx.client)).map((r) => ({
|
|
1809
|
+
id: r.id,
|
|
1810
|
+
title: r.title,
|
|
1811
|
+
isCircuit: r.isCircuit ?? false,
|
|
1812
|
+
units: exerciseUnits(r.param1Type, r.param2Type)
|
|
1813
|
+
}));
|
|
1814
|
+
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."));
|
|
1815
|
+
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." });
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1787
1818
|
/** Workouts, exercise catalog, per-exercise history/PRs/stats. */
|
|
1788
1819
|
function registerExerciseTools(server, ctx, userId) {
|
|
1789
1820
|
server.registerTool("athlete_workouts", {
|
|
@@ -1804,18 +1835,10 @@ function registerExerciseTools(server, ctx, userId) {
|
|
|
1804
1835
|
description: ATHLETE_EXERCISES_DESC,
|
|
1805
1836
|
inputSchema: {
|
|
1806
1837
|
q: z.string().optional(),
|
|
1807
|
-
limit: z.number().int().positive().
|
|
1838
|
+
limit: z.number().int().positive().optional()
|
|
1808
1839
|
},
|
|
1809
1840
|
annotations: READ
|
|
1810
|
-
}, (
|
|
1811
|
-
const items = (q !== void 0 && q.trim() !== "" ? await searchExerciseHistory(ctx.client, q, limit ?? 20) : await fetchExerciseHistoryList(ctx.client)).map((r) => ({
|
|
1812
|
-
id: r.id,
|
|
1813
|
-
title: r.title,
|
|
1814
|
-
isCircuit: r.isCircuit ?? false,
|
|
1815
|
-
units: exerciseUnits(r.param1Type, r.param2Type)
|
|
1816
|
-
}));
|
|
1817
|
-
return jsonResult(limit !== void 0 ? items.slice(0, limit) : items, { hint: "Pass q to search by name, or limit to cap the list." });
|
|
1818
|
-
}));
|
|
1841
|
+
}, (args) => runAthleteExercises(ctx, args));
|
|
1819
1842
|
server.registerTool("athlete_exercise_history", {
|
|
1820
1843
|
title: "Exercise history + PRs",
|
|
1821
1844
|
description: ATHLETE_EXERCISE_HISTORY_DESC,
|
|
@@ -2034,7 +2057,7 @@ function registerAthleteTrainingTools(server, ctx) {
|
|
|
2034
2057
|
}
|
|
2035
2058
|
//#endregion
|
|
2036
2059
|
//#region package.json
|
|
2037
|
-
var version = "1.7.
|
|
2060
|
+
var version = "1.7.2";
|
|
2038
2061
|
//#endregion
|
|
2039
2062
|
//#region src/server.ts
|
|
2040
2063
|
async function main() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trainheroic-unofficial/athlete-mcp",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
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/
|
|
25
|
-
"@trainheroic-unofficial/
|
|
24
|
+
"@trainheroic-unofficial/core": "1.7.2",
|
|
25
|
+
"@trainheroic-unofficial/js": "1.7.2"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^26.0.1",
|