@trainheroic-unofficial/athlete-mcp 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Alan Cohen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # @trainheroic-unofficial/athlete-mcp
2
+
3
+ Local single-user MCP server for a TrainHeroic **athlete**. Runs on your machine over stdio; credentials come from the environment.
4
+
5
+ It exposes the logged-in athlete's own training — scheduled and completed workouts, per-exercise history, PRs, working maxes, and lifetime totals — plus a confirmation-gated workout-logging write. For coaching (rosters, teams, programs, messaging), use [`@trainheroic-unofficial/coach-mcp`](../coach-mcp). For the hosted version (no install, OAuth login, both surfaces), see the [root README](../../README.md).
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ### Claude Code
12
+
13
+ ```bash
14
+ claude mcp add trainheroic-athlete \
15
+ -e TRAINHEROIC_EMAIL=athlete@example.com \
16
+ -e TRAINHEROIC_PASSWORD=yourpassword \
17
+ -- npx -y @trainheroic-unofficial/athlete-mcp
18
+ ```
19
+
20
+ ### Claude Desktop / `.mcp.json` / other stdio clients
21
+
22
+ ```jsonc
23
+ {
24
+ "mcpServers": {
25
+ "trainheroic-athlete": {
26
+ "command": "npx",
27
+ "args": ["-y", "@trainheroic-unofficial/athlete-mcp"],
28
+ "env": {
29
+ "TRAINHEROIC_EMAIL": "athlete@example.com",
30
+ "TRAINHEROIC_PASSWORD": "yourpassword",
31
+ },
32
+ },
33
+ },
34
+ }
35
+ ```
36
+
37
+ A coach account works here too: a coach login also carries athlete scope, so it can read its own training through these tools.
38
+
39
+ ---
40
+
41
+ ## Tools
42
+
43
+ - `athlete_whoami` — identity (id, name, roles)
44
+ - `athlete_profile` — lifetime totals + profile
45
+ - `athlete_prefs` — notification/display preferences
46
+ - `athlete_workouts` — scheduled + completed workouts in a date range (flattened)
47
+ - `athlete_exercises` — search the exercises you've logged
48
+ - `athlete_exercise_history` — per-exercise PRs + dated session history
49
+ - `athlete_personal_records` — exercise personal records
50
+ - `athlete_exercise_stats` — last performance + PR as of a date
51
+ - `athlete_working_maxes` — working max per exercise
52
+ - `athlete_leaderboard` — benchmark/test workout leaderboard
53
+ - `athlete_log_set` — gated: log completed set results to your (coach-visible) training log
54
+
55
+ ---
56
+
57
+ ## Develop
58
+
59
+ ```bash
60
+ pnpm start # run from source (needs TRAINHEROIC_EMAIL and TRAINHEROIC_PASSWORD)
61
+ pnpm inspect # MCP Inspector UI against the source server
62
+ pnpm build # tsdown → dist/server.mjs
63
+ pnpm typecheck
64
+ pnpm test
65
+ ```
@@ -0,0 +1,1054 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import process from "node:process";
4
+ import { z } from "zod";
5
+ //#region ../dto/src/common.ts
6
+ /** An entity id as it arrives over the wire — a number or a numeric string. */
7
+ const idSchema = z.union([z.string(), z.number()]);
8
+ /**
9
+ * An id as a tool argument: a number, or a string of digits. Unlike idSchema (which
10
+ * tolerates whatever the API sends back), this rejects non-numeric strings up front so
11
+ * a bad argument fails validation instead of becoming NaN in a URL or a query.
12
+ */
13
+ const idArgSchema = z.union([z.number().int(), z.string().regex(/^\d+$/u)]);
14
+ //#endregion
15
+ //#region ../dto/src/athlete.ts
16
+ const intLike$1 = z.union([z.number(), z.string()]);
17
+ const intLikeOrNull$1 = z.union([
18
+ z.number(),
19
+ z.string(),
20
+ z.null()
21
+ ]);
22
+ const numLikeOrNull = z.union([
23
+ z.number(),
24
+ z.string(),
25
+ z.null()
26
+ ]);
27
+ z.looseObject({
28
+ id: intLike$1,
29
+ roles: z.array(z.string()).optional(),
30
+ org_id: intLikeOrNull$1.optional()
31
+ });
32
+ z.looseObject({
33
+ reps_sum: z.number().optional(),
34
+ volume_sum: z.number().optional(),
35
+ sessions_count: z.number().optional(),
36
+ first_logged_date: z.string().optional(),
37
+ last_logged_date: z.string().optional(),
38
+ duration_hours: z.number().optional()
39
+ });
40
+ z.looseObject({
41
+ id: intLike$1,
42
+ email: z.string().optional(),
43
+ name_first: z.string().optional(),
44
+ name_last: z.string().optional(),
45
+ username: z.string().optional(),
46
+ gender: z.string().optional(),
47
+ date_of_birth: z.string().optional(),
48
+ use_metric: z.boolean().optional()
49
+ });
50
+ z.looseObject({ id: intLike$1 });
51
+ /** One item of `/2.0/athlete/workingMax` — the athlete's working max for an exercise. */
52
+ const athleteWorkingMaxSchema = z.looseObject({
53
+ exercise_id: intLike$1,
54
+ title: z.string().optional(),
55
+ param_type: intLikeOrNull$1.optional(),
56
+ value: numLikeOrNull.optional(),
57
+ type_suffix: z.string().optional(),
58
+ working_max_id: intLikeOrNull$1.optional()
59
+ });
60
+ z.array(athleteWorkingMaxSchema);
61
+ /** One item of `/v5/users/exercises/history` — an exercise the athlete has logged. */
62
+ const exerciseHistoryListItemSchema = z.looseObject({
63
+ id: intLike$1,
64
+ title: z.string(),
65
+ isCircuit: z.boolean().optional(),
66
+ prescription: z.string().optional(),
67
+ param1Type: intLikeOrNull$1.optional(),
68
+ param2Type: intLikeOrNull$1.optional()
69
+ });
70
+ z.array(exerciseHistoryListItemSchema);
71
+ /** A single completed set inside a history entry (`/v5/exercises/{id}/history`). */
72
+ const historySetSchema = z.looseObject({
73
+ setNumber: z.number(),
74
+ formattedValue: z.string().optional(),
75
+ rawValue1: numLikeOrNull.optional(),
76
+ rawValue2: numLikeOrNull.optional(),
77
+ savedWorkoutSetExerciseId: intLike$1.optional()
78
+ });
79
+ /** A best rep-max derived for a history entry. */
80
+ const repMaxSchema = z.looseObject({
81
+ reps: z.number(),
82
+ weight: z.number()
83
+ });
84
+ /** One performed session of an exercise (`/v5/exercises/{id}/history` → `history[]`). */
85
+ const historyEntrySchema = z.looseObject({
86
+ dateCompleted: z.string(),
87
+ notes: z.string().nullable().optional(),
88
+ isLift: z.boolean().optional(),
89
+ param1Type: intLikeOrNull$1.optional(),
90
+ param2Type: intLikeOrNull$1.optional(),
91
+ savedWorkoutSetExerciseId: intLike$1.optional(),
92
+ teamId: intLikeOrNull$1.optional(),
93
+ programWorkoutId: intLikeOrNull$1.optional(),
94
+ abr: z.string().optional(),
95
+ bestEstimated1RM: z.number().optional(),
96
+ repMaxes: z.array(repMaxSchema).optional(),
97
+ sets: z.array(historySetSchema).optional()
98
+ });
99
+ /** A lifetime PR row from `/v5/exercises/{id}/history` → `liftPRs[]`. */
100
+ const liftPRSchema = z.looseObject({
101
+ weight: z.number().optional(),
102
+ savedWorkoutSetExerciseId: intLike$1.optional(),
103
+ setNumber: z.number().optional(),
104
+ dateCompleted: z.string().optional(),
105
+ reps: z.number().optional(),
106
+ units: z.string().optional(),
107
+ isMetric: z.boolean().optional(),
108
+ description: z.string().optional()
109
+ });
110
+ z.looseObject({
111
+ liftPRs: z.array(liftPRSchema).optional(),
112
+ singleParamPRs: z.array(z.unknown()).optional(),
113
+ history: z.array(historyEntrySchema).optional()
114
+ });
115
+ /** One item of `/v5/exercises/{id}/personalRecords` — a standards-filtered PR. */
116
+ const personalRecordSchema = z.looseObject({
117
+ id: intLike$1.optional(),
118
+ savedWorkoutSetExerciseId: intLike$1.optional(),
119
+ setNumber: z.number().optional(),
120
+ reps: z.number().optional(),
121
+ weight: z.number().optional(),
122
+ scaledWeight: z.number().optional(),
123
+ units: z.string().optional(),
124
+ isMetric: z.boolean().optional()
125
+ });
126
+ z.array(personalRecordSchema);
127
+ z.looseObject({
128
+ isLift: z.boolean().optional(),
129
+ lastPerformance: z.unknown().optional(),
130
+ personalRecord: z.unknown().optional()
131
+ });
132
+ /**
133
+ * One item of `/3.0/athlete/programworkout/range` — a scheduled/completed workout. The deep
134
+ * `summarizedSavedWorkout` tree is left loose: the presenter in `js` flattens it, so dto only
135
+ * pins the top-level fields the warehouse and presenter key off.
136
+ */
137
+ const programWorkoutSchema = z.looseObject({
138
+ id: intLike$1,
139
+ date: z.string().optional(),
140
+ workout_title: z.string().optional(),
141
+ program_id: intLikeOrNull$1.optional(),
142
+ program_title: z.string().optional(),
143
+ team_id: intLikeOrNull$1.optional(),
144
+ team_title: z.string().optional(),
145
+ summarizedSavedWorkout: z.unknown().optional()
146
+ });
147
+ z.array(programWorkoutSchema);
148
+ /** A `YYYY-MM-DD` date argument. The single definition reused across athlete tool inputs. */
149
+ const dateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/u, "expected YYYY-MM-DD");
150
+ z.object({
151
+ startDate: dateString,
152
+ endDate: dateString
153
+ });
154
+ /**
155
+ * Args for the set-logging write. `date` (the workout's day) locates the saved
156
+ * workout via the range endpoint; `savedWorkoutSetId` picks the set to complete; `results`
157
+ * gives, per exercise in it, the entered value of each set (param 1 / param 2 by entry slot).
158
+ */
159
+ const logSetArgsSchema = z.object({
160
+ date: dateString,
161
+ savedWorkoutSetId: idArgSchema,
162
+ results: z.array(z.object({
163
+ savedWorkoutSetExerciseId: idArgSchema,
164
+ sets: z.array(z.object({
165
+ param1: z.union([z.number(), z.string()]).optional(),
166
+ param2: z.union([z.number(), z.string()]).optional()
167
+ })).min(1)
168
+ })).min(1)
169
+ });
170
+ z.looseObject({
171
+ title: z.string().min(1),
172
+ param_1_type: z.number().optional(),
173
+ param_2_type: z.number().optional()
174
+ });
175
+ z.object({
176
+ streamId: idSchema,
177
+ text: z.string().min(1),
178
+ replyTo: idSchema.optional()
179
+ });
180
+ //#endregion
181
+ //#region ../dto/src/responses.ts
182
+ const intLike = z.union([z.number(), z.string()]);
183
+ const intLikeOrNull = z.union([
184
+ z.number(),
185
+ z.string(),
186
+ z.null()
187
+ ]);
188
+ /** An exercise as the library + create endpoints return it (only the fields we read). */
189
+ const exerciseResponseSchema = z.looseObject({
190
+ id: intLike,
191
+ title: z.string(),
192
+ param_1_type: intLikeOrNull.optional(),
193
+ param_2_type: intLikeOrNull.optional()
194
+ });
195
+ z.array(exerciseResponseSchema);
196
+ z.looseObject({
197
+ workout_id: intLike,
198
+ id: intLike
199
+ });
200
+ /** A programWorkout from the calendar edit view (we match by id and walk sets). */
201
+ const programWorkoutResponseSchema = z.looseObject({
202
+ id: intLike,
203
+ sets: z.record(z.string(), z.unknown()).optional()
204
+ });
205
+ z.looseObject({ programWorkouts: z.array(programWorkoutResponseSchema).optional() });
206
+ //#endregion
207
+ //#region ../dto/src/workout.ts
208
+ /** A single exercise prescription inside a block. */
209
+ const exerciseSpecSchema = z.object({
210
+ id: z.union([z.number(), z.string()]),
211
+ title: z.string().optional(),
212
+ reps: z.union([
213
+ z.number(),
214
+ z.string(),
215
+ z.array(z.union([z.number(), z.string()]))
216
+ ]).optional(),
217
+ sets: z.number().optional(),
218
+ weight: z.union([z.number(), z.array(z.number())]).optional(),
219
+ rpe: z.union([z.number(), z.string()]).optional(),
220
+ instr: z.string().optional(),
221
+ param_1_type: z.number().optional(),
222
+ param_2_type: z.number().optional()
223
+ });
224
+ /** A block's Red-Zone leaderboard: a unit string/number, or an object with options. */
225
+ const leaderboardSpecSchema = z.union([
226
+ z.string(),
227
+ z.number(),
228
+ z.object({
229
+ unit: z.union([z.string(), z.number()]).optional(),
230
+ type: z.union([z.string(), z.number()]).optional(),
231
+ lowest_wins: z.boolean().optional(),
232
+ instruction: z.string().optional()
233
+ })
234
+ ]);
235
+ /** A block (group of exercises); two exercises render as a superset. */
236
+ const blockSpecSchema = z.object({
237
+ title: z.string(),
238
+ type: z.number().optional(),
239
+ instruction: z.string().optional(),
240
+ leaderboard: leaderboardSpecSchema.optional(),
241
+ exercises: z.array(exerciseSpecSchema)
242
+ });
243
+ z.object({
244
+ blocks: z.array(blockSpecSchema),
245
+ instruction: z.string().optional()
246
+ });
247
+ //#endregion
248
+ //#region ../core/src/context.ts
249
+ /** A tool argument that accepts a numeric id as a number or a string of digits. */
250
+ const idParam = idArgSchema;
251
+ function toId(value) {
252
+ return typeof value === "number" ? value : Number(value);
253
+ }
254
+ const READ = {
255
+ readOnlyHint: true,
256
+ openWorldHint: true
257
+ };
258
+ const DESTRUCTIVE = {
259
+ readOnlyHint: false,
260
+ destructiveHint: true,
261
+ openWorldHint: true
262
+ };
263
+ /** Run a tool body, converting thrown errors into an in-band tool error. */
264
+ async function attempt(fn) {
265
+ try {
266
+ return await fn();
267
+ } catch (err) {
268
+ return errorResult(err instanceof Error ? err.message : String(err));
269
+ }
270
+ }
271
+ /** Conservative per-result character cap, below the smallest host cap. */
272
+ const DEFAULT_RESULT_BUDGET = 6e4;
273
+ /** Reserve for the `__truncated` marker so wrapping cannot push back over budget. */
274
+ const MARKER_RESERVE = 300;
275
+ 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.";
276
+ const DEFAULT_OBJECT_HINT = "Result was truncated to fit the size budget. Request a more specific id or sub-resource.";
277
+ /** Active budget. Overridable via TH_MCP_RESULT_BUDGET on Node; the default on workerd. */
278
+ function resultBudget() {
279
+ const raw = (globalThis.process?.env)?.TH_MCP_RESULT_BUDGET;
280
+ const n = raw ? Number(raw) : NaN;
281
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_RESULT_BUDGET;
282
+ }
283
+ function isPlainObject(value) {
284
+ return typeof value === "object" && value !== null && !Array.isArray(value);
285
+ }
286
+ /** Largest count k such that the JSON of the first k pre-serialized pieces fits. O(n). */
287
+ function largestPrefixCount(pieces, charBudget) {
288
+ let used = 2;
289
+ let k = 0;
290
+ for (const piece of pieces) {
291
+ const add = piece.length + (k > 0 ? 1 : 0);
292
+ if (used + add > charBudget) break;
293
+ used += add;
294
+ k += 1;
295
+ }
296
+ return k;
297
+ }
298
+ /** Last resort: cap a string at the budget and label it as truncated, non-JSON output. */
299
+ function hardCap(text, budget, hint) {
300
+ if (text.length <= budget) return text;
301
+ const note = `\n\n[TRUNCATED: output exceeded ${budget} chars and is NOT valid JSON. ${hint ?? "Narrow the query (filter, paginate, or fetch a specific id)."}]`;
302
+ const keep = Math.max(0, budget - note.length);
303
+ return text.slice(0, keep) + note;
304
+ }
305
+ function largestArrayValuedKey(obj) {
306
+ let best = null;
307
+ let bestLen = -1;
308
+ for (const [key, value] of Object.entries(obj)) {
309
+ if (!Array.isArray(value)) continue;
310
+ const len = (JSON.stringify(value) ?? "[]").length;
311
+ if (len > bestLen) {
312
+ best = key;
313
+ bestLen = len;
314
+ }
315
+ }
316
+ return best;
317
+ }
318
+ /**
319
+ * Serialize `data` as JSON within `budget` characters. Small results are pretty-printed.
320
+ * Oversized results degrade in order: trim a top-level array (wrapping it as
321
+ * `{ items, __truncated }`), then a top-level object's largest array property (annotated
322
+ * with `__truncated`), then a last-resort hard character cap. Pure and side-effect free.
323
+ */
324
+ function boundedSerialize(data, budget, hint) {
325
+ if (typeof data === "string") return hardCap(data, budget, hint);
326
+ const compact = JSON.stringify(data) ?? "null";
327
+ if (compact.length <= budget) {
328
+ const pretty = JSON.stringify(data, null, 2) ?? "null";
329
+ return pretty.length <= budget ? pretty : compact;
330
+ }
331
+ if (Array.isArray(data)) {
332
+ const k = largestPrefixCount(data.map((el) => JSON.stringify(el) ?? "null"), budget - MARKER_RESERVE);
333
+ const wrapped = {
334
+ items: data.slice(0, k),
335
+ __truncated: {
336
+ returned: k,
337
+ total: data.length,
338
+ omitted: data.length - k,
339
+ hint: hint ?? DEFAULT_ARRAY_HINT
340
+ }
341
+ };
342
+ const out = JSON.stringify(wrapped);
343
+ if (out.length <= budget) return out;
344
+ } else if (isPlainObject(data)) {
345
+ const key = largestArrayValuedKey(data);
346
+ if (key !== null) {
347
+ const arr = data[key];
348
+ const pieces = arr.map((el) => JSON.stringify(el) ?? "null");
349
+ const restLen = (JSON.stringify({
350
+ ...data,
351
+ [key]: []
352
+ }) ?? "{}").length;
353
+ const k = largestPrefixCount(pieces, Math.max(0, budget - MARKER_RESERVE - restLen));
354
+ const clone = {
355
+ ...data,
356
+ [key]: arr.slice(0, k),
357
+ __truncated: {
358
+ field: key,
359
+ returned: k,
360
+ total: arr.length,
361
+ omitted: arr.length - k,
362
+ hint: hint ?? DEFAULT_OBJECT_HINT
363
+ }
364
+ };
365
+ const out = JSON.stringify(clone);
366
+ if (out.length <= budget) return out;
367
+ }
368
+ }
369
+ return hardCap(compact, budget, hint);
370
+ }
371
+ /** A successful tool result carrying JSON (or text) for the model, size-bounded. */
372
+ function jsonResult(data, opts) {
373
+ return { content: [{
374
+ type: "text",
375
+ text: boundedSerialize(data, resultBudget(), opts?.hint)
376
+ }] };
377
+ }
378
+ /** A tool-level error: returned in-band (isError) so the model can self-correct. */
379
+ function errorResult(message) {
380
+ return {
381
+ isError: true,
382
+ content: [{
383
+ type: "text",
384
+ text: message
385
+ }]
386
+ };
387
+ }
388
+ //#endregion
389
+ //#region ../core/src/confirm.ts
390
+ const NOT_CONFIRMED = "Not confirmed. Re-run with confirm:true, or connect a client that supports MCP elicitation.";
391
+ /**
392
+ * Confirm a destructive/athlete-facing action. Prefers MCP elicitation (an
393
+ * in-the-moment user prompt); falls back to an explicit confirm:true argument when
394
+ * the client does not support elicitation. Never proceeds without one of the two.
395
+ */
396
+ async function confirmGate(server, requestId, message, confirmArg) {
397
+ if (confirmArg === true) return true;
398
+ try {
399
+ const result = await server.server.elicitInput({
400
+ message,
401
+ requestedSchema: {
402
+ type: "object",
403
+ properties: { confirm: {
404
+ type: "boolean",
405
+ title: "Confirm",
406
+ description: message
407
+ } },
408
+ required: ["confirm"]
409
+ }
410
+ }, requestId === void 0 ? void 0 : { relatedRequestId: requestId });
411
+ return result.action === "accept" && result.content?.confirm === true;
412
+ } catch (err) {
413
+ console.warn("MCP elicitation unavailable; treating as not confirmed", err);
414
+ return false;
415
+ }
416
+ }
417
+ //#endregion
418
+ //#region ../js/src/auth.ts
419
+ const AUTH_URL = "https://apis.trainheroic.com/auth";
420
+ /**
421
+ * Authenticate against TrainHeroic. Returns the session bundle, or null on bad
422
+ * credentials. TrainHeroic returns only { id, scope, role, session_id } (verified in
423
+ * the Phase 0 spike: no refresh_token, no api_token, no TTL). The 48-char session_id
424
+ * is sent as the `session-token` header and works against both API hosts.
425
+ */
426
+ async function loginTrainHeroic(email, password) {
427
+ const res = await fetch(AUTH_URL, {
428
+ method: "POST",
429
+ headers: {
430
+ "content-type": "application/x-www-form-urlencoded",
431
+ accept: "application/json"
432
+ },
433
+ body: new URLSearchParams({
434
+ email,
435
+ password
436
+ }).toString()
437
+ });
438
+ if (!res.ok) return null;
439
+ const data = await res.json().catch(() => null);
440
+ if (!data || typeof data.id !== "number" || !data.session_id) return null;
441
+ return {
442
+ thUserId: data.id,
443
+ sessionId: data.session_id,
444
+ scope: data.scope ?? "",
445
+ role: data.role ?? ""
446
+ };
447
+ }
448
+ //#endregion
449
+ //#region ../js/src/client.ts
450
+ const COACH_BASE = "https://api.trainheroic.com";
451
+ const APIS_BASE = "https://apis.trainheroic.com";
452
+ var TrainHeroicAuthError = class extends Error {
453
+ name = "TrainHeroicAuthError";
454
+ };
455
+ /**
456
+ * Authenticated TrainHeroic API client. Holds the coach credentials (from the grant's
457
+ * encrypted props) and a lazily-acquired session token cached in memory for the life
458
+ * of the Durable Object instance. On a 401/403 it re-logs in once and retries, since
459
+ * TrainHeroic has no refresh token and sessions expire after ~1-2h.
460
+ */
461
+ var TrainHeroicClient = class {
462
+ #email;
463
+ #password;
464
+ #sessionId;
465
+ #loginInFlight = null;
466
+ constructor(email, password, sessionId = null) {
467
+ this.#email = email;
468
+ this.#password = password;
469
+ this.#sessionId = sessionId;
470
+ }
471
+ get sessionId() {
472
+ return this.#sessionId;
473
+ }
474
+ async #ensureSession() {
475
+ if (this.#sessionId) return this.#sessionId;
476
+ this.#loginInFlight ??= this.#login();
477
+ try {
478
+ return await this.#loginInFlight;
479
+ } finally {
480
+ this.#loginInFlight = null;
481
+ }
482
+ }
483
+ async #login() {
484
+ const session = await loginTrainHeroic(this.#email, this.#password);
485
+ if (!session) throw new TrainHeroicAuthError("TrainHeroic login failed");
486
+ this.#sessionId = session.sessionId;
487
+ return this.#sessionId;
488
+ }
489
+ async request(method, path, options = {}) {
490
+ const url = `${options.base === "apis" ? APIS_BASE : COACH_BASE}/${path.replace(/^\//, "")}`;
491
+ let session = await this.#ensureSession();
492
+ let res = await this.#send(method, url, session, options.body);
493
+ if (res.status === 401 || res.status === 403) {
494
+ if (this.#sessionId === session) this.#sessionId = null;
495
+ session = await this.#ensureSession();
496
+ res = await this.#send(method, url, session, options.body);
497
+ }
498
+ const text = await res.text();
499
+ let data = text;
500
+ if (text.length > 0) try {
501
+ data = JSON.parse(text);
502
+ } catch {
503
+ data = text;
504
+ }
505
+ return {
506
+ status: res.status,
507
+ ok: res.ok,
508
+ data
509
+ };
510
+ }
511
+ #send(method, url, session, body) {
512
+ const upper = method.toUpperCase();
513
+ const headers = {
514
+ accept: "application/json",
515
+ "session-token": session
516
+ };
517
+ const init = {
518
+ method: upper,
519
+ headers
520
+ };
521
+ if (body !== void 0 && upper !== "GET" && upper !== "DELETE") {
522
+ headers["content-type"] = "application/json";
523
+ init.body = JSON.stringify(body);
524
+ }
525
+ return fetch(url, init);
526
+ }
527
+ };
528
+ //#endregion
529
+ //#region ../js/src/exercise-util.ts
530
+ /**
531
+ * Display labels for TrainHeroic parameter types. The unit is FIXED PER EXERCISE
532
+ * (the API forces param_1_type/param_2_type back to the library default on save), so
533
+ * resolve/search surface it to stop callers picking, say, the miles "Run" for a
534
+ * metric workout. Keep in sync with the workout builder's unit table.
535
+ */
536
+ const PARAM_UNIT = {
537
+ 0: null,
538
+ 1: "lb",
539
+ 2: "%max",
540
+ 3: "reps",
541
+ 4: "sec",
542
+ 5: "yd",
543
+ 6: "m",
544
+ 7: "in",
545
+ 10: "mi",
546
+ 11: "ft",
547
+ 12: "in",
548
+ 13: "bpm",
549
+ 14: "RPE",
550
+ 18: "sec"
551
+ };
552
+ function coerceInt(value) {
553
+ if (typeof value === "boolean") return value ? 1 : 0;
554
+ if (typeof value === "number") return Number.isFinite(value) ? Math.trunc(value) : null;
555
+ if (typeof value === "string" && value.trim() !== "") {
556
+ const n = Number(value);
557
+ return Number.isFinite(n) ? Math.trunc(n) : null;
558
+ }
559
+ return null;
560
+ }
561
+ function unitLabel(paramType) {
562
+ const t = coerceInt(paramType);
563
+ if (t === null) return null;
564
+ return PARAM_UNIT[t] ?? null;
565
+ }
566
+ /**
567
+ * Fixed measurement units for an exercise, ordered by entry slot (param 1, then param 2).
568
+ * Positional, not semantic: some exercises reverse the slots, so the array is not labelled
569
+ * by role. A null entry is an unset slot.
570
+ */
571
+ function exerciseUnits(param1, param2) {
572
+ return [unitLabel(param1), unitLabel(param2)];
573
+ }
574
+ function isRecord(x) {
575
+ return typeof x === "object" && x !== null && !Array.isArray(x);
576
+ }
577
+ /**
578
+ * Rank candidate rows for a free-text query (FTS5 replacement). Higher is better:
579
+ * exact title, then prefix, then count of matched tokens, with shorter titles and
580
+ * standard (non-custom) exercises preferred on ties.
581
+ */
582
+ function rankSearch(rows, query, limit) {
583
+ const q = query.trim().toLowerCase();
584
+ const tokens = q.split(/\s+/u).filter((t) => t.length > 0);
585
+ return rows.map((row) => {
586
+ const title = row.title.toLowerCase();
587
+ let score = 0;
588
+ if (title === q) score += 1e3;
589
+ if (title.startsWith(q)) score += 100;
590
+ for (const tok of tokens) if (title.includes(tok)) score += 10;
591
+ score -= title.length * .05;
592
+ if ((row.can_edit ?? 0) === 0) score += 1;
593
+ return {
594
+ row,
595
+ score
596
+ };
597
+ }).sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.row);
598
+ }
599
+ //#endregion
600
+ //#region ../js/src/athlete.ts
601
+ async function getJson(client, path, label) {
602
+ const res = await client.request("GET", path);
603
+ if (!res.ok) throw new Error(`${label} failed (HTTP ${res.status}).`);
604
+ return res.data;
605
+ }
606
+ async function getArray(client, path, label) {
607
+ const res = await client.request("GET", path);
608
+ if (!res.ok || !Array.isArray(res.data)) throw new Error(`${label} failed (HTTP ${res.status}).`);
609
+ return res.data;
610
+ }
611
+ /** Lifetime training totals. `use_metric` is required by the API (omitting it 400s). */
612
+ function fetchAthleteProfileSummary(client, userId, useMetric = false) {
613
+ return getJson(client, `/v5/athleteProfile/summary?user_id=${userId}&use_metric=${useMetric ? 1 : 0}`, "athlete profile summary");
614
+ }
615
+ function fetchAthleteUser(client, userId) {
616
+ return getJson(client, `/v5/users/${userId}`, "athlete user");
617
+ }
618
+ function fetchAthletePrefs(client) {
619
+ return getJson(client, "/1.0/athlete/prefs", "athlete prefs");
620
+ }
621
+ function fetchWorkingMaxes(client) {
622
+ return getArray(client, "/2.0/athlete/workingMax", "athlete working maxes");
623
+ }
624
+ function fetchExerciseHistoryList(client) {
625
+ return getArray(client, "/v5/users/exercises/history", "athlete exercise history list");
626
+ }
627
+ /** Free-text search over the athlete's logged exercises (FTS replacement via rankSearch). */
628
+ async function searchExerciseHistory(client, query, limit = 20) {
629
+ return rankSearch(await fetchExerciseHistoryList(client), query, limit);
630
+ }
631
+ function fetchExerciseHistoryDetail(client, exerciseId, userId) {
632
+ return getJson(client, `/v5/exercises/${exerciseId}/history?userId=${userId}`, "athlete exercise history");
633
+ }
634
+ function fetchPersonalRecords(client, exerciseId) {
635
+ return getArray(client, `/v5/exercises/${exerciseId}/personalRecords`, "athlete personal records");
636
+ }
637
+ /** Last performance + PR for an exercise. `date` (YYYY-MM-DD) is required by the API. */
638
+ function fetchExerciseStats(client, exerciseId, userId, date) {
639
+ return getJson(client, `/v5/exercises/${exerciseId}/stats?userId=${userId}&date=${date}`, "athlete exercise stats");
640
+ }
641
+ /** Scheduled + completed workouts in an inclusive YYYY-MM-DD window. */
642
+ function fetchAthleteWorkouts(client, startDate, endDate) {
643
+ return getArray(client, `/3.0/athlete/programworkout/range?startDate=${startDate}&endDate=${endDate}`, "athlete workouts");
644
+ }
645
+ function fetchLeaderboard(client, workoutId, opts = {}) {
646
+ const qs = new URLSearchParams();
647
+ if (opts.page !== void 0) qs.set("page", String(opts.page));
648
+ if (opts.pageSize !== void 0) qs.set("pageSize", String(opts.pageSize));
649
+ if (opts.gender !== void 0) qs.set("gender", String(opts.gender));
650
+ const query = qs.toString();
651
+ return getJson(client, `/3.0/athlete/leaderboard/${workoutId}${query ? `?${query}` : ""}`, "athlete leaderboard");
652
+ }
653
+ const SLOTS = 10;
654
+ function nonEmpty(value) {
655
+ return value !== void 0 && value !== null && String(value).trim() !== "";
656
+ }
657
+ /**
658
+ * Per-set prescriptions from the param_N_data slots, e.g. ["5 @ 225", "3 @ 245"] or ["AMRAP"].
659
+ * Values are kept raw (a non-numeric prescription like "AMRAP" or "8-12" must survive); the
660
+ * positional units come from the exercise's param types, mirroring the coach presenter.
661
+ */
662
+ function prescribedSets(ex) {
663
+ const out = [];
664
+ for (let i = 1; i <= SLOTS; i += 1) {
665
+ const p1 = ex[`param_1_data_${i}`];
666
+ const p2 = ex[`param_2_data_${i}`];
667
+ const has1 = nonEmpty(p1);
668
+ const has2 = nonEmpty(p2);
669
+ if (!has1 && !has2) continue;
670
+ if (has1 && has2) out.push(`${p1} @ ${p2}`);
671
+ else if (has1) out.push(String(p1));
672
+ else out.push(`@ ${p2}`);
673
+ }
674
+ return out;
675
+ }
676
+ function presentExercise(ex) {
677
+ const instruction = typeof ex.instruction === "string" && ex.instruction !== "" ? ex.instruction : null;
678
+ return {
679
+ exerciseId: coerceInt(ex.exercise_id),
680
+ title: typeof ex.title === "string" ? ex.title : "",
681
+ instruction,
682
+ units: exerciseUnits(ex.param_1_type, ex.param_2_type),
683
+ prescribed: prescribedSets(ex)
684
+ };
685
+ }
686
+ function presentBlock(set) {
687
+ const exercises = Array.isArray(set.workoutSetExercises) ? set.workoutSetExercises : [];
688
+ return {
689
+ order: coerceInt(set.order) ?? 0,
690
+ title: typeof set.title === "string" && set.title !== "" ? set.title : null,
691
+ instruction: typeof set.instruction === "string" && set.instruction !== "" ? set.instruction : null,
692
+ isTest: coerceInt(set.is_test) === 1,
693
+ exercises: exercises.filter(isRecord).map(presentExercise)
694
+ };
695
+ }
696
+ function str(v) {
697
+ return typeof v === "string" && v !== "" ? v : null;
698
+ }
699
+ /** Flatten one `/3.0/athlete/programworkout/range` item into a readable workout. */
700
+ function presentAthleteWorkout(raw) {
701
+ const rec = raw;
702
+ const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
703
+ const workout = isRecord(ssw.workout) ? ssw.workout : {};
704
+ const sets = Array.isArray(workout.workoutSets) ? workout.workoutSets : [];
705
+ return {
706
+ id: coerceInt(rec.id),
707
+ date: str(rec.date) ?? "",
708
+ title: str(rec.workout_title) ?? "",
709
+ program: str(rec.program_title),
710
+ team: str(rec.team_title),
711
+ instruction: str(workout.instruction),
712
+ blocks: sets.filter(isRecord).map(presentBlock).sort((a, b) => a.order - b.order)
713
+ };
714
+ }
715
+ function presentAthleteWorkouts(list) {
716
+ return list.map(presentAthleteWorkout);
717
+ }
718
+ const MAX_PARAM_SLOTS = 10;
719
+ /**
720
+ * Build the body for `PUT /1.0/athlete/savedworkoutsetexercise/{id}`. The body uses
721
+ * snake_case keys matching the live API response shape. Each set slot (1-10) gets a
722
+ * `param_N_made` flag (1 if data is present, 0 otherwise) and `param_1_data_N` /
723
+ * `param_2_data_N` string values.
724
+ *
725
+ * Only `savedWorkoutSetExerciseId`, `savedWorkoutSetId`, and `workoutSetExerciseId` are
726
+ * required from the live exercise record; everything else is derived from `results`.
727
+ *
728
+ * Exported for unit testing — callers should use `logAthleteSet` instead.
729
+ */
730
+ function buildExerciseLogPayload(savedWorkoutSetExerciseId, savedWorkoutSetId, workoutSetExerciseId, results) {
731
+ if (results.length > MAX_PARAM_SLOTS) throw new Error(`At most ${MAX_PARAM_SLOTS} sets are supported per exercise; got ${results.length}.`);
732
+ const body = {
733
+ id: savedWorkoutSetExerciseId,
734
+ saved_workout_set_id: savedWorkoutSetId,
735
+ workout_set_exercise_id: workoutSetExerciseId,
736
+ completed: results.some((s) => s.param1 !== void 0 || s.param2 !== void 0) ? 1 : 0
737
+ };
738
+ for (let i = 1; i <= MAX_PARAM_SLOTS; i += 1) {
739
+ const slot = results[i - 1];
740
+ const p1 = slot?.param1 !== void 0 ? String(slot.param1) : "";
741
+ const p2 = slot?.param2 !== void 0 ? String(slot.param2) : "";
742
+ body[`param_${i}_made`] = p1 !== "" || p2 !== "" ? 1 : 0;
743
+ body[`param_1_data_${i}`] = p1;
744
+ body[`param_2_data_${i}`] = p2;
745
+ }
746
+ return body;
747
+ }
748
+ /**
749
+ * Locate the target saved workout set across all program workouts on the given day.
750
+ * Returns the `savedWorkoutId`, the matching set's `workoutSetExercises` array so callers
751
+ * can look up `workout_set_exercise_id` by `savedWorkoutSetExerciseId`, and the raw set
752
+ * record so callers can build the set-completion PUT body via `buildSetCompletePayload`.
753
+ */
754
+ function findSavedWorkoutSet(workouts, savedWorkoutSetId) {
755
+ for (const pw of workouts) {
756
+ const rec = pw;
757
+ const ssw = isRecord(rec.summarizedSavedWorkout) ? rec.summarizedSavedWorkout : {};
758
+ const sw = isRecord(ssw.saved_workout) ? ssw.saved_workout : null;
759
+ if (!sw) continue;
760
+ const sets = Array.isArray(sw.workoutSets) ? sw.workoutSets : [];
761
+ for (const s of sets) {
762
+ if (!isRecord(s)) continue;
763
+ if (coerceInt(s.id) === savedWorkoutSetId) {
764
+ const savedWorkoutId = coerceInt(sw.id);
765
+ if (!savedWorkoutId) continue;
766
+ return {
767
+ savedWorkoutId,
768
+ exercises: Array.isArray(s.workoutSetExercises) ? s.workoutSetExercises.filter(isRecord) : [],
769
+ rawSet: s
770
+ };
771
+ }
772
+ }
773
+ }
774
+ throw new Error(`Saved workout set ${savedWorkoutSetId} not found on this date.`);
775
+ }
776
+ /**
777
+ * Build the body for `PUT /1.0/athlete/savedworkoutset/{id}` that marks the set completed.
778
+ *
779
+ * The API requires the **app's camelCase in-memory model**, not the snake_case shape the
780
+ * GET endpoints return. Key field mappings from the raw set record:
781
+ * saved_workout_id → sessionId
782
+ * workout_set_id → workoutSetId
783
+ * is_super_set (0/1) → isSuperSet (boolean)
784
+ * plain_text (0/1) → isPlainText (boolean)
785
+ * unit ("lb"/"kg") → isMetric (boolean)
786
+ * workoutSetExercises[].id → exercises (array of IDs)
787
+ *
788
+ * `exerciseIds` must be the savedWorkoutSetExercise IDs (not workout_set_exercise_ids).
789
+ *
790
+ * Exported for unit testing — callers should use `logAthleteSet` instead.
791
+ */
792
+ function buildSetCompletePayload(rawSet, exerciseIds, complete) {
793
+ const id = coerceInt(rawSet.id);
794
+ const sessionId = coerceInt(rawSet.saved_workout_id);
795
+ const workoutSetId = coerceInt(rawSet.workout_set_id);
796
+ if (!id || !sessionId || !workoutSetId) throw new Error("Raw set is missing required id / saved_workout_id / workout_set_id.");
797
+ const unit = typeof rawSet.unit === "string" ? rawSet.unit : "";
798
+ return {
799
+ id,
800
+ sessionId,
801
+ workoutSetId,
802
+ completed: complete ? "1" : "0",
803
+ rx: coerceInt(rawSet.rx) ?? 0,
804
+ version: coerceInt(rawSet.version) ?? 0,
805
+ isMetric: unit.toLowerCase() === "kg",
806
+ isSuperSet: rawSet.is_super_set === 1 || rawSet.is_super_set === true,
807
+ isPlainText: rawSet.plain_text === 1 || rawSet.plain_text === true,
808
+ title: typeof rawSet.title === "string" ? rawSet.title : "",
809
+ instruction: typeof rawSet.instruction === "string" ? rawSet.instruction : "",
810
+ notes: rawSet.notes ?? null,
811
+ exercises: [...exerciseIds]
812
+ };
813
+ }
814
+ /**
815
+ * Record entered set results for one saved workout set.
816
+ *
817
+ * **Two-step write (both required for data to persist):**
818
+ * 1. For each exercise in `results`:
819
+ * `PUT /1.0/athlete/savedworkoutsetexercise/{savedWorkoutSetExerciseId}` with the
820
+ * per-slot `param_N_data_M` values. This is the only path that actually stores reps
821
+ * and weight — the `savedworkoutset` PUT accepts the same fields but silently discards
822
+ * them.
823
+ * 2. `PUT /1.0/athlete/savedworkoutset/{savedWorkoutSetId}` with `completed:"1"` to
824
+ * mark the block done and make the entry visible as a logged set in history.
825
+ *
826
+ * The date is used to locate the saved workout (via the range endpoint) so we can resolve
827
+ * `saved_workout_id` and `workout_set_exercise_id` for each exercise. Both are required
828
+ * by the respective PUT bodies.
829
+ */
830
+ async function logAthleteSet(client, args) {
831
+ const { exercises, rawSet } = findSavedWorkoutSet(await fetchAthleteWorkouts(client, args.date, args.date), args.savedWorkoutSetId);
832
+ let exercisesLogged = 0;
833
+ for (const result of args.results) {
834
+ const ex = exercises.find((e) => coerceInt(e.id) === result.savedWorkoutSetExerciseId);
835
+ if (!ex) throw new Error(`savedWorkoutSetExerciseId ${result.savedWorkoutSetExerciseId} not found in saved workout set ${args.savedWorkoutSetId}.`);
836
+ const workoutSetExerciseId = coerceInt(ex.workout_set_exercise_id);
837
+ if (!workoutSetExerciseId) throw new Error(`Could not resolve workout_set_exercise_id for exercise ${result.savedWorkoutSetExerciseId}.`);
838
+ const body = buildExerciseLogPayload(result.savedWorkoutSetExerciseId, args.savedWorkoutSetId, workoutSetExerciseId, result.sets);
839
+ const res = await client.request("PUT", `/1.0/athlete/savedworkoutsetexercise/${result.savedWorkoutSetExerciseId}`, { body });
840
+ if (!res.ok) throw new Error(`Failed to log exercise ${result.savedWorkoutSetExerciseId} (HTTP ${res.status}).`);
841
+ exercisesLogged += 1;
842
+ }
843
+ const setBody = buildSetCompletePayload(rawSet, exercises.map((e) => coerceInt(e.id)).filter((n) => n !== null), true);
844
+ const setRes = await client.request("PUT", `/1.0/athlete/savedworkoutset/${args.savedWorkoutSetId}`, { body: setBody });
845
+ if (!setRes.ok) throw new Error(`Failed to mark workout set ${args.savedWorkoutSetId} completed (HTTP ${setRes.status}).`);
846
+ return {
847
+ savedWorkoutSetId: args.savedWorkoutSetId,
848
+ exercisesLogged
849
+ };
850
+ }
851
+ /** Flatten `/v5/exercises/{id}/history` into PRs + a session time-series. */
852
+ function presentExerciseHistory(detail) {
853
+ return {
854
+ liftPRs: (detail.liftPRs ?? []).map((p) => ({
855
+ description: p.description ?? null,
856
+ reps: p.reps ?? null,
857
+ weight: p.weight ?? null,
858
+ date: p.dateCompleted ?? null
859
+ })),
860
+ sessions: (detail.history ?? []).map((h) => ({
861
+ date: h.dateCompleted,
862
+ abr: h.abr ?? null,
863
+ estimated1RM: h.bestEstimated1RM ?? null,
864
+ sets: (h.sets ?? []).map((s) => ({
865
+ setNumber: s.setNumber,
866
+ value: s.formattedValue ?? null
867
+ }))
868
+ }))
869
+ };
870
+ }
871
+ //#endregion
872
+ //#region ../core/src/tools/athlete-training.ts
873
+ /** Identity, profile, prefs, working maxes, leaderboard. */
874
+ function registerProfileTools(server, ctx, whoami, userId) {
875
+ server.registerTool("athlete_whoami", {
876
+ title: "Who am I (athlete)",
877
+ description: "The logged-in account's identity (id, name, roles) from /user/simple.",
878
+ inputSchema: {},
879
+ annotations: READ
880
+ }, () => attempt(async () => jsonResult(await whoami())));
881
+ server.registerTool("athlete_profile", {
882
+ title: "Athlete profile + lifetime totals",
883
+ description: "Lifetime training totals (reps, volume, sessions, first/last logged) plus the profile (name, units, dob). Set useMetric for kg/metric totals.",
884
+ inputSchema: { useMetric: z.boolean().optional() },
885
+ annotations: READ
886
+ }, ({ useMetric }) => attempt(async () => {
887
+ const id = await userId();
888
+ const [summary, user] = await Promise.all([fetchAthleteProfileSummary(ctx.client, id, useMetric ?? false), fetchAthleteUser(ctx.client, id)]);
889
+ return jsonResult({
890
+ summary,
891
+ user
892
+ });
893
+ }));
894
+ server.registerTool("athlete_prefs", {
895
+ title: "Athlete preferences",
896
+ description: "Notification and display preference flags for the athlete account.",
897
+ inputSchema: {},
898
+ annotations: READ
899
+ }, () => attempt(async () => jsonResult(await fetchAthletePrefs(ctx.client))));
900
+ server.registerTool("athlete_working_maxes", {
901
+ title: "Working maxes",
902
+ description: "The athlete's working max per exercise (drives % prescriptions).",
903
+ inputSchema: {},
904
+ annotations: READ
905
+ }, () => attempt(async () => jsonResult(await fetchWorkingMaxes(ctx.client))));
906
+ server.registerTool("athlete_leaderboard", {
907
+ title: "Benchmark leaderboard",
908
+ description: "Leaderboard for a benchmark/test workout by its workout id.",
909
+ inputSchema: {
910
+ workoutId: idParam,
911
+ page: z.number().int().positive().optional(),
912
+ pageSize: z.number().int().positive().max(200).optional(),
913
+ gender: z.number().int().optional()
914
+ },
915
+ annotations: READ
916
+ }, ({ workoutId, page, pageSize, gender }) => attempt(async () => {
917
+ const opts = {};
918
+ if (page !== void 0) opts.page = page;
919
+ if (pageSize !== void 0) opts.pageSize = pageSize;
920
+ if (gender !== void 0) opts.gender = gender;
921
+ return jsonResult(await fetchLeaderboard(ctx.client, toId(workoutId), opts));
922
+ }));
923
+ }
924
+ /** Workouts, exercise catalog, per-exercise history/PRs/stats. */
925
+ function registerExerciseTools(server, ctx, userId) {
926
+ server.registerTool("athlete_workouts", {
927
+ title: "Workouts in a date range",
928
+ description: "Scheduled + completed workouts in an inclusive YYYY-MM-DD window, flattened to blocks/exercises with per-set prescriptions and positional units. Set raw:true for the untouched API objects. Narrow the window if the result is truncated.",
929
+ inputSchema: {
930
+ startDate: dateString,
931
+ endDate: dateString,
932
+ raw: z.boolean().optional()
933
+ },
934
+ annotations: READ
935
+ }, ({ startDate, endDate, raw }) => attempt(async () => {
936
+ const workouts = await fetchAthleteWorkouts(ctx.client, startDate, endDate);
937
+ return jsonResult(raw === true ? workouts : presentAthleteWorkouts(workouts), { hint: "Narrow startDate/endDate to shrink this result." });
938
+ }));
939
+ server.registerTool("athlete_exercises", {
940
+ title: "Search logged exercises",
941
+ description: "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 / _stats.",
942
+ inputSchema: {
943
+ q: z.string().optional(),
944
+ limit: z.number().int().positive().max(200).optional()
945
+ },
946
+ annotations: READ
947
+ }, ({ q, limit }) => attempt(async () => {
948
+ return jsonResult((q !== void 0 && q.trim() !== "" ? await searchExerciseHistory(ctx.client, q, limit ?? 20) : await fetchExerciseHistoryList(ctx.client)).map((r) => ({
949
+ id: r.id,
950
+ title: r.title,
951
+ isCircuit: r.isCircuit ?? false,
952
+ units: exerciseUnits(r.param1Type, r.param2Type)
953
+ })), { hint: "Pass q to search by name, or limit to cap the list." });
954
+ }));
955
+ server.registerTool("athlete_exercise_history", {
956
+ title: "Exercise history + PRs",
957
+ description: "Per-exercise PRs and the dated session time-series (sets performed, estimated 1RM). Set raw:true for the untouched API object. Get the exercise id from athlete_exercises.",
958
+ inputSchema: {
959
+ exerciseId: idParam,
960
+ raw: z.boolean().optional()
961
+ },
962
+ annotations: READ
963
+ }, ({ exerciseId, raw }) => attempt(async () => {
964
+ const detail = await fetchExerciseHistoryDetail(ctx.client, toId(exerciseId), await userId());
965
+ return jsonResult(raw === true ? detail : presentExerciseHistory(detail));
966
+ }));
967
+ server.registerTool("athlete_personal_records", {
968
+ title: "Exercise personal records",
969
+ description: "Personal records for an exercise (reps/weight, strength-standard filters).",
970
+ inputSchema: { exerciseId: idParam },
971
+ annotations: READ
972
+ }, ({ exerciseId }) => attempt(async () => jsonResult(await fetchPersonalRecords(ctx.client, toId(exerciseId)))));
973
+ server.registerTool("athlete_exercise_stats", {
974
+ title: "Exercise stats (last performance + PR)",
975
+ description: "Last performance and PR for an exercise as of a date (YYYY-MM-DD, required by the API).",
976
+ inputSchema: {
977
+ exerciseId: idParam,
978
+ date: dateString
979
+ },
980
+ annotations: READ
981
+ }, ({ exerciseId, date }) => attempt(async () => jsonResult(await fetchExerciseStats(ctx.client, toId(exerciseId), await userId(), date))));
982
+ }
983
+ /** The gated set-logging write. */
984
+ function registerLogTool(server, ctx) {
985
+ server.registerTool("athlete_log_set", {
986
+ title: "Log completed set results",
987
+ 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).",
988
+ inputSchema: {
989
+ ...logSetArgsSchema.shape,
990
+ confirm: z.boolean().optional()
991
+ },
992
+ annotations: DESTRUCTIVE
993
+ }, ({ date, savedWorkoutSetId, results, confirm }, extra) => attempt(async () => {
994
+ 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);
995
+ const mapped = results.map((r) => ({
996
+ savedWorkoutSetExerciseId: toId(r.savedWorkoutSetExerciseId),
997
+ sets: r.sets.map((s) => {
998
+ const slot = {};
999
+ if (s.param1 !== void 0) slot.param1 = s.param1;
1000
+ if (s.param2 !== void 0) slot.param2 = s.param2;
1001
+ return slot;
1002
+ })
1003
+ }));
1004
+ return jsonResult(await logAthleteSet(ctx.client, {
1005
+ date,
1006
+ savedWorkoutSetId: toId(savedWorkoutSetId),
1007
+ results: mapped
1008
+ }));
1009
+ }));
1010
+ }
1011
+ /**
1012
+ * Live tools over the logged-in user's own training (history, scheduled/completed workouts,
1013
+ * PRs, working maxes), plus a gated set-logging write. The athlete user id is
1014
+ * resolved once from /user/simple and reused across tools.
1015
+ */
1016
+ function registerAthleteTrainingTools(server, ctx) {
1017
+ let whoamiCache = null;
1018
+ const whoami = async () => {
1019
+ if (whoamiCache === null) {
1020
+ const res = await ctx.client.request("GET", "/user/simple");
1021
+ if (!res.ok || !isRecord(res.data)) throw new Error("Could not load /user/simple.");
1022
+ whoamiCache = res.data;
1023
+ }
1024
+ return whoamiCache;
1025
+ };
1026
+ const userId = async () => {
1027
+ const id = coerceInt((await whoami()).id);
1028
+ if (id === null || id <= 0) throw new Error("Could not resolve athlete user id from /user/simple.");
1029
+ return id;
1030
+ };
1031
+ registerProfileTools(server, ctx, whoami, userId);
1032
+ registerExerciseTools(server, ctx, userId);
1033
+ registerLogTool(server, ctx);
1034
+ }
1035
+ //#endregion
1036
+ //#region src/server.ts
1037
+ async function main() {
1038
+ const email = process.env.TRAINHEROIC_EMAIL;
1039
+ const password = process.env.TRAINHEROIC_PASSWORD;
1040
+ if (!email || !password) {
1041
+ process.stderr.write("Set TRAINHEROIC_EMAIL and TRAINHEROIC_PASSWORD in the environment.\n");
1042
+ process.exit(1);
1043
+ }
1044
+ const client = new TrainHeroicClient(email, password);
1045
+ const server = new McpServer({
1046
+ name: "trainheroic-athlete",
1047
+ version: "0.1.0"
1048
+ });
1049
+ registerAthleteTrainingTools(server, { client });
1050
+ await server.connect(new StdioServerTransport());
1051
+ }
1052
+ await main();
1053
+ //#endregion
1054
+ export {};
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@trainheroic-unofficial/athlete-mcp",
3
+ "version": "0.4.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/alandotcom/trainheroic-unofficial.git",
8
+ "directory": "packages/athlete-mcp"
9
+ },
10
+ "description": "Local single-user MCP server for a TrainHeroic athlete. No database, no Cloudflare deps.",
11
+ "type": "module",
12
+ "bin": {
13
+ "trainheroic-athlete-mcp": "./dist/server.mjs"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.29.0",
23
+ "zod": "^4.4.3",
24
+ "@trainheroic-unofficial/core": "0.4.0",
25
+ "@trainheroic-unofficial/js": "0.4.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^26.0.0",
29
+ "tsdown": "^0.22.3",
30
+ "tsx": "^4.22.4",
31
+ "typescript": "^6.0.3",
32
+ "vitest": "^4.1.9"
33
+ },
34
+ "scripts": {
35
+ "start": "tsx src/server.ts",
36
+ "inspect": "npx @modelcontextprotocol/inspector -e TRAINHEROIC_EMAIL=\"$TRAINHEROIC_EMAIL\" -e TRAINHEROIC_PASSWORD=\"$TRAINHEROIC_PASSWORD\" tsx src/server.ts",
37
+ "build": "tsdown",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "vitest run --passWithNoTests"
40
+ }
41
+ }