@trainheroic-unofficial/athlete-mcp 1.1.0 → 1.2.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/README.md +15 -15
- package/dist/server.mjs +58 -31
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# @trainheroic-unofficial/athlete-mcp
|
|
2
2
|
|
|
3
|
-
Local single-user [MCP](https://modelcontextprotocol.io) server for a TrainHeroic **athlete
|
|
3
|
+
Local single-user [MCP](https://modelcontextprotocol.io) server for a TrainHeroic **athlete**, exposing one [TrainHeroic](https://www.trainheroic.com) athlete's own training to any MCP-capable AI assistant as callable tools. It runs on your machine and speaks the MCP stdio transport, so the assistant launches it as a subprocess.
|
|
4
4
|
|
|
5
|
-
It reads two required environment variables, `TRAINHEROIC_EMAIL` and `TRAINHEROIC_PASSWORD`
|
|
5
|
+
It reads two required environment variables, `TRAINHEROIC_EMAIL` and `TRAINHEROIC_PASSWORD` (your existing TrainHeroic login). These are real credentials in plaintext; the config below puts them in a file or shell history, so treat them as secrets.
|
|
6
6
|
|
|
7
|
-
It exposes the logged-in athlete's own training
|
|
7
|
+
It exposes the logged-in athlete's own training: scheduled and completed workouts, per-exercise history, PRs (personal records), working maxes, and lifetime totals, plus one confirmation-gated write that logs a completed set. Coaching capabilities (rosters, teams, programs, messaging) are available through [`@trainheroic-unofficial/coach-mcp`](../coach-mcp). A hosted version that holds credentials server-side behind OAuth and gives a coach login both the athlete and coaching tools is described in the [root README](../../README.md).
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -23,7 +23,7 @@ claude mcp add trainheroic-athlete \
|
|
|
23
23
|
|
|
24
24
|
### Claude Desktop / `.mcp.json` / other stdio clients
|
|
25
25
|
|
|
26
|
-
Put this in your client's MCP config
|
|
26
|
+
Put this in your client's MCP config. For Claude Desktop that's `claude_desktop_config.json`
|
|
27
27
|
(macOS: `~/Library/Application Support/Claude/`; Windows: `%APPDATA%\Claude\`); for a
|
|
28
28
|
project-scoped Claude Code setup it's `.mcp.json` at the repo root.
|
|
29
29
|
|
|
@@ -51,20 +51,20 @@ A coach account works here too: a TrainHeroic coach account also has its own ath
|
|
|
51
51
|
All read-only except `athlete_log_set`. The assistant fills in each tool's parameters (dates
|
|
52
52
|
are `YYYY-MM-DD`); your MCP client shows the full schema for each.
|
|
53
53
|
|
|
54
|
-
- `athlete_whoami
|
|
55
|
-
- `athlete_profile
|
|
56
|
-
- `athlete_prefs
|
|
57
|
-
- `athlete_workouts
|
|
58
|
-
- `athlete_exercises
|
|
59
|
-
- `athlete_exercise_history
|
|
60
|
-
- `athlete_personal_records
|
|
61
|
-
- `athlete_exercise_stats
|
|
62
|
-
- `athlete_working_maxes
|
|
63
|
-
- `athlete_leaderboard
|
|
54
|
+
- `athlete_whoami`: identity (id, name, roles)
|
|
55
|
+
- `athlete_profile`: lifetime totals + profile
|
|
56
|
+
- `athlete_prefs`: notification/display preferences
|
|
57
|
+
- `athlete_workouts`: scheduled + completed workouts in a date range, flattened into one list
|
|
58
|
+
- `athlete_exercises`: search the exercises you've logged
|
|
59
|
+
- `athlete_exercise_history`: per-exercise PRs + dated session history
|
|
60
|
+
- `athlete_personal_records`: exercise personal records
|
|
61
|
+
- `athlete_exercise_stats`: last performance + PR as of a date
|
|
62
|
+
- `athlete_working_maxes`: working max per exercise
|
|
63
|
+
- `athlete_leaderboard`: benchmark/test workout leaderboard
|
|
64
64
|
|
|
65
65
|
**Write (confirmation-gated):**
|
|
66
66
|
|
|
67
|
-
- `athlete_log_set
|
|
67
|
+
- `athlete_log_set`: logs completed set results to your training log. This is a real write
|
|
68
68
|
that your coach can see. It confirms before running: the server asks the client to confirm,
|
|
69
69
|
falling back to an explicit `confirm: true` argument when the client can't prompt.
|
|
70
70
|
|
package/dist/server.mjs
CHANGED
|
@@ -173,6 +173,11 @@ const logSetArgsSchema = z.object({
|
|
|
173
173
|
})).min(1)
|
|
174
174
|
});
|
|
175
175
|
logSetArgsSchema.extend({ athleteId: idArgSchema });
|
|
176
|
+
logSetArgsSchema.extend({ athleteId: idArgSchema });
|
|
177
|
+
z.object({
|
|
178
|
+
savedWorkoutSetExerciseId: idArgSchema,
|
|
179
|
+
exerciseId: idArgSchema
|
|
180
|
+
});
|
|
176
181
|
/**
|
|
177
182
|
* Args for logging a whole session by exercise (rather than by saved-workout-set id). Each
|
|
178
183
|
* exercise carries its entered sets and an optional 1-based `order`. The athlete path creates
|
|
@@ -891,29 +896,37 @@ function summarizeAthleteWorkouts(list) {
|
|
|
891
896
|
}
|
|
892
897
|
const MAX_PARAM_SLOTS = 10;
|
|
893
898
|
/**
|
|
894
|
-
* Build the body for `PUT /1.0/
|
|
895
|
-
*
|
|
896
|
-
* `
|
|
897
|
-
*
|
|
899
|
+
* Build the body for `PUT /1.0/{role}/savedworkoutsetexercise/{id}`. The body uses snake_case
|
|
900
|
+
* keys matching the live API response shape. Each set slot (1-10) carries `param_1_data_N` /
|
|
901
|
+
* `param_2_data_N` string values plus a `param_N_made` flag.
|
|
902
|
+
*
|
|
903
|
+
* `mode` selects which write this is — the same endpoint serves both:
|
|
904
|
+
* - `"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.
|
|
906
|
+
* - `"prescribe"`: the values are prescribed targets, written with every `param_N_made` and
|
|
907
|
+
* `completed` left at 0 so the set is not marked done. This matches what the app sends when a
|
|
908
|
+
* coach edits an athlete's prescribed reps/weight.
|
|
898
909
|
*
|
|
899
910
|
* Only `savedWorkoutSetExerciseId`, `savedWorkoutSetId`, and `workoutSetExerciseId` are
|
|
900
911
|
* required from the live exercise record; everything else is derived from `results`.
|
|
901
912
|
*
|
|
902
|
-
* Exported for unit testing — callers should use `logAthleteSet` instead.
|
|
913
|
+
* Exported for unit testing — callers should use `logAthleteSet` / `prescribeForAthlete` instead.
|
|
903
914
|
*/
|
|
904
|
-
function
|
|
915
|
+
function buildExerciseSetPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results, mode) {
|
|
905
916
|
if (results.length > MAX_PARAM_SLOTS) throw new Error(`At most ${MAX_PARAM_SLOTS} sets are supported per exercise; got ${results.length}.`);
|
|
917
|
+
const performed = mode === "log";
|
|
918
|
+
const hasData = results.some((s) => s.param1 !== void 0 || s.param2 !== void 0);
|
|
906
919
|
const body = {
|
|
907
920
|
id: savedWorkoutSetExerciseId,
|
|
908
921
|
saved_workout_set_id: savedWorkoutSetId,
|
|
909
922
|
workout_set_exercise_id: workoutSetExerciseId,
|
|
910
|
-
completed:
|
|
923
|
+
completed: performed && hasData ? 1 : 0
|
|
911
924
|
};
|
|
912
925
|
for (let i = 1; i <= MAX_PARAM_SLOTS; i += 1) {
|
|
913
926
|
const slot = results[i - 1];
|
|
914
927
|
const p1 = slot?.param1 !== void 0 ? String(slot.param1) : "";
|
|
915
928
|
const p2 = slot?.param2 !== void 0 ? String(slot.param2) : "";
|
|
916
|
-
body[`param_${i}_made`] = p1 !== "" || p2 !== "" ? 1 : 0;
|
|
929
|
+
body[`param_${i}_made`] = performed && (p1 !== "" || p2 !== "") ? 1 : 0;
|
|
917
930
|
body[`param_1_data_${i}`] = p1;
|
|
918
931
|
body[`param_2_data_${i}`] = p2;
|
|
919
932
|
}
|
|
@@ -1014,7 +1027,11 @@ function buildSetCompletePayload(rawSet, exerciseIds, complete) {
|
|
|
1014
1027
|
* by the respective PUT bodies.
|
|
1015
1028
|
*/
|
|
1016
1029
|
async function logAthleteSet(client, args) {
|
|
1017
|
-
|
|
1030
|
+
const r = await writeSetResults(client, { role: "athlete" }, await fetchAthleteWorkouts(client, args.date, args.date), args.savedWorkoutSetId, args.results, "log");
|
|
1031
|
+
return {
|
|
1032
|
+
savedWorkoutSetId: r.savedWorkoutSetId,
|
|
1033
|
+
exercisesLogged: r.exercisesWritten
|
|
1034
|
+
};
|
|
1018
1035
|
}
|
|
1019
1036
|
/**
|
|
1020
1037
|
* Coach "Log for Athlete": record set results for a roster athlete on their behalf, via the
|
|
@@ -1028,24 +1045,32 @@ async function logAthleteSet(client, args) {
|
|
|
1028
1045
|
*/
|
|
1029
1046
|
async function logForAthlete(client, args) {
|
|
1030
1047
|
const workouts = await fetchCoachAthleteWorkouts(client, args.athleteId, args.date, args.date);
|
|
1031
|
-
|
|
1048
|
+
const r = await writeSetResults(client, {
|
|
1032
1049
|
role: "coach",
|
|
1033
1050
|
athleteId: args.athleteId
|
|
1034
|
-
}, workouts, args.savedWorkoutSetId, args.results);
|
|
1051
|
+
}, workouts, args.savedWorkoutSetId, args.results, "log");
|
|
1052
|
+
return {
|
|
1053
|
+
savedWorkoutSetId: r.savedWorkoutSetId,
|
|
1054
|
+
exercisesLogged: r.exercisesWritten
|
|
1055
|
+
};
|
|
1035
1056
|
}
|
|
1036
1057
|
/**
|
|
1037
|
-
* Shared
|
|
1038
|
-
* `target` selects the surface: `athlete` writes `/1.0/athlete/...`;
|
|
1039
|
-
* `/1.0/coach/...{athleteId}` and stamps `athleteId` into each body.
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1058
|
+
* Shared set-write behind {@link logAthleteSet}, {@link logForAthlete}, and
|
|
1059
|
+
* {@link prescribeForAthlete}. `target` selects the surface: `athlete` writes `/1.0/athlete/...`;
|
|
1060
|
+
* `coach` writes `/1.0/coach/...{athleteId}` and stamps `athleteId` into each body.
|
|
1061
|
+
*
|
|
1062
|
+
* Step 1 PUTs each exercise's per-set values to its own endpoint (the only path that actually
|
|
1063
|
+
* stores reps and weight). `mode` decides what those values mean: `"log"` records them as a
|
|
1064
|
+
* performed result and runs Step 2, which marks the set completed (that body needs the app's
|
|
1065
|
+
* camelCase in-memory shape and the full list of savedWorkoutSetExercise IDs in the set, not only
|
|
1066
|
+
* the written ones); `"prescribe"` writes them as a prescription and skips Step 2, leaving the set
|
|
1067
|
+
* open.
|
|
1043
1068
|
*/
|
|
1044
|
-
async function writeSetResults(client, target, workouts, savedWorkoutSetId, results) {
|
|
1069
|
+
async function writeSetResults(client, target, workouts, savedWorkoutSetId, results, mode) {
|
|
1045
1070
|
const { exercises, rawSet } = findSavedWorkoutSet(workouts, savedWorkoutSetId);
|
|
1046
1071
|
const suffix = target.role === "coach" ? `/${target.athleteId}` : "";
|
|
1047
1072
|
const extra = target.role === "coach" ? { athleteId: target.athleteId } : {};
|
|
1048
|
-
let
|
|
1073
|
+
let exercisesWritten = 0;
|
|
1049
1074
|
for (const result of results) {
|
|
1050
1075
|
const ex = exercises.find((e) => coerceInt(e.id) === result.savedWorkoutSetExerciseId);
|
|
1051
1076
|
if (!ex) {
|
|
@@ -1059,25 +1084,27 @@ async function writeSetResults(client, target, workouts, savedWorkoutSetId, resu
|
|
|
1059
1084
|
const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
|
|
1060
1085
|
if (!workoutSetExerciseId) throw new Error(`Could not resolve workout_set_exercise_id for exercise ${result.savedWorkoutSetExerciseId}.`);
|
|
1061
1086
|
const body = {
|
|
1062
|
-
...
|
|
1087
|
+
...buildExerciseSetPayload(result.savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, result.sets, mode),
|
|
1063
1088
|
...extra
|
|
1064
1089
|
};
|
|
1065
1090
|
const res = await client.request("PUT", `/1.0/${target.role}/savedworkoutsetexercise/${result.savedWorkoutSetExerciseId}${suffix}`, { body });
|
|
1066
1091
|
if (!res.ok) {
|
|
1067
|
-
const readOnly = target.role === "coach" && (res.status === 401 || res.status === 403) ? ` Athlete ${target.athleteId} appears to be read-only for
|
|
1068
|
-
throw new Error(`Failed to
|
|
1092
|
+
const readOnly = target.role === "coach" && (res.status === 401 || res.status === 403) ? ` Athlete ${target.athleteId} appears to be read-only for changes — TrainHeroic's seeded demo/sample athletes return ${res.status} here; writes only persist for real (invited) athletes.` : "";
|
|
1093
|
+
throw new Error(`Failed to write exercise ${result.savedWorkoutSetExerciseId} (HTTP ${res.status}).${readOnly}`);
|
|
1069
1094
|
}
|
|
1070
|
-
|
|
1095
|
+
exercisesWritten += 1;
|
|
1096
|
+
}
|
|
1097
|
+
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}).`);
|
|
1071
1104
|
}
|
|
1072
|
-
const setBody = {
|
|
1073
|
-
...buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true),
|
|
1074
|
-
...extra
|
|
1075
|
-
};
|
|
1076
|
-
const setRes = await client.request("PUT", `/1.0/${target.role}/savedworkoutset/${savedWorkoutSetId}${suffix}`, { body: setBody });
|
|
1077
|
-
if (!setRes.ok) throw new Error(`Failed to mark workout set ${savedWorkoutSetId} completed (HTTP ${setRes.status}).`);
|
|
1078
1105
|
return {
|
|
1079
1106
|
savedWorkoutSetId,
|
|
1080
|
-
|
|
1107
|
+
exercisesWritten
|
|
1081
1108
|
};
|
|
1082
1109
|
}
|
|
1083
1110
|
/**
|
|
@@ -1521,7 +1548,7 @@ function registerAthleteTrainingTools(server, ctx) {
|
|
|
1521
1548
|
}
|
|
1522
1549
|
//#endregion
|
|
1523
1550
|
//#region package.json
|
|
1524
|
-
var version = "1.
|
|
1551
|
+
var version = "1.2.0";
|
|
1525
1552
|
//#endregion
|
|
1526
1553
|
//#region src/server.ts
|
|
1527
1554
|
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.2.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.2.0",
|
|
25
|
+
"@trainheroic-unofficial/js": "1.2.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^26.0.0",
|