@trainheroic-unofficial/athlete-mcp 3.6.0 → 3.7.1

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 +188 -120
  2. package/package.json +7 -7
package/dist/server.mjs CHANGED
@@ -441,10 +441,35 @@ z.object({
441
441
  match: exerciseViewSchema.nullable(),
442
442
  candidates: z.array(exerciseViewSchema)
443
443
  });
444
+ /** TrainHeroic's documented exercise parameter-type codes. */
445
+ const exerciseParamTypeSchema = z.union([
446
+ z.literal(0),
447
+ z.literal(1),
448
+ z.literal(2),
449
+ z.literal(3),
450
+ z.literal(4),
451
+ z.literal(5),
452
+ z.literal(6),
453
+ z.literal(7),
454
+ z.literal(10),
455
+ z.literal(11),
456
+ z.literal(12),
457
+ z.literal(13),
458
+ z.literal(14),
459
+ z.literal(18)
460
+ ]);
461
+ const exerciseWriteShape = {
462
+ title: z.string().trim().min(1),
463
+ param_1_type: exerciseParamTypeSchema.optional(),
464
+ param_2_type: exerciseParamTypeSchema.optional()
465
+ };
444
466
  z.looseObject({
445
- title: z.string().min(1),
446
- param_1_type: z.number().optional(),
447
- param_2_type: z.number().optional()
467
+ ...exerciseWriteShape,
468
+ points_of_performance: z.string().default("")
469
+ });
470
+ z.looseObject({
471
+ ...exerciseWriteShape,
472
+ points_of_performance: z.string().optional()
448
473
  });
449
474
  z.object({
450
475
  streamId: idSchema,
@@ -1000,12 +1025,29 @@ function confirmGate(ctx, message, confirmArg) {
1000
1025
  */
1001
1026
  const SERVER_INSTRUCTIONS = "Speak to the user in plain, everyday language about their training. Describe what you are doing in the TrainHeroic app's own terms (for example, say you are creating a workout rather than naming a tool). Do not surface internal tool names (the snake_case identifiers such as athlete_session_create), raw parameter names, or numeric ids in your replies unless the user explicitly asks for them; they are implementation details. The tool descriptions cross-reference each other by name only so you can chain them correctly. Keep that wiring to yourself.";
1002
1027
  //#endregion
1028
+ //#region ../js/src/date-window.ts
1029
+ const DAY_MS = 864e5;
1030
+ const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/u;
1031
+ function dayNumber(value) {
1032
+ if (!ISO_DAY.test(value)) return null;
1033
+ const timestamp = Date.parse(`${value}T00:00:00Z`);
1034
+ if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== value) return null;
1035
+ return timestamp / DAY_MS;
1036
+ }
1037
+ /** Number of calendar days in an inclusive ISO-date range, or null for an invalid range. */
1038
+ function dateSpanDays(start, end) {
1039
+ const first = dayNumber(start);
1040
+ const last = dayNumber(end);
1041
+ return first === null || last === null || first > last ? null : last - first + 1;
1042
+ }
1043
+ //#endregion
1003
1044
  //#region ../js/src/http-error.ts
1004
1045
  const MAX_REQUEST_KEYS = 50;
1005
1046
  const MAX_RESPONSE_DEPTH = 4;
1006
1047
  const MAX_RESPONSE_KEYS = 20;
1007
1048
  const MAX_RESPONSE_ITEMS = 10;
1008
1049
  const MAX_RESPONSE_NODES = 50;
1050
+ const MAX_RESPONSE_STRING = 2e3;
1009
1051
  const RESPONSE_DIAGNOSTIC_KEYS = /* @__PURE__ */ new Set([
1010
1052
  "code",
1011
1053
  "detail",
@@ -1043,6 +1085,11 @@ const PARAMETER_TYPES = /* @__PURE__ */ new Set([
1043
1085
  14,
1044
1086
  18
1045
1087
  ]);
1088
+ /** Keep provider diagnostics useful while removing common credentials and direct identifiers. */
1089
+ function redactText(value) {
1090
+ const redacted = value.slice(0, MAX_RESPONSE_STRING).replace(/\bAuthorization(["']?\s*[:=]\s*)(?:"(?:\\(?:[\s\S]|$)|[^"\\])*(?:"|$)|'(?:\\(?:[\s\S]|$)|[^'\\])*(?:'|$)|[^"'\r\n,;&]+)/giu, (_match, separator) => `Authorization${separator}[Redacted]`).replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/giu, "Bearer [Redacted]").replace(/\b(password|passwd|secret|token|access[_-]?token|refresh[_-]?token|session(?:[_-]?(?:id|token))?|api[_-]?key|client[_-]?secret)(["']?\s*[:=]\s*)(?:"(?:\\(?:[\s\S]|$)|[^"\\])*(?:"|$)|'(?:\\(?:[\s\S]|$)|[^'\\])*(?:'|$)|[^"'\r\n,;&}\]]+)/giu, (_match, key, separator) => `${key}${separator}[Redacted]`).replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/giu, "[Redacted email]").replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/gu, "[Redacted IP]").replace(/\b\d{3}-\d{2}-\d{4}\b/gu, "[Redacted]").replace(/\b(?:bearer\s+)?[a-f0-9]{32,}\b/giu, "[Redacted]");
1091
+ return value.length <= MAX_RESPONSE_STRING ? redacted : `${redacted}…[truncated]`;
1092
+ }
1046
1093
  function safeFieldName(key) {
1047
1094
  if (key === "__proto__" || key === "constructor" || key === "prototype") return "[Redacted key]";
1048
1095
  if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,79}$/.test(key)) return "[Redacted key]";
@@ -1070,17 +1117,22 @@ function requestBodySummary(body) {
1070
1117
  const entries = boundedEntries(body, MAX_REQUEST_KEYS).sort(([left], [right]) => left.localeCompare(right));
1071
1118
  const keys = entries.map(([key]) => safeFieldName(key));
1072
1119
  const values = {};
1120
+ const arrayLengths = {};
1073
1121
  for (const [key, value] of entries) {
1074
1122
  const safeValue = safeRequestValue(key, value);
1075
1123
  if (safeValue !== void 0) values[safeFieldName(key)] = safeValue;
1124
+ if (Array.isArray(value)) arrayLengths[safeFieldName(key)] = value.length;
1076
1125
  }
1077
- return Object.keys(values).length > 0 ? {
1126
+ const byKey = new Map(entries);
1127
+ const dateStart = byKey.get("date_start");
1128
+ const dateEnd = byKey.get("date_end");
1129
+ const span = typeof dateStart === "string" && typeof dateEnd === "string" ? dateSpanDays(dateStart, dateEnd) : null;
1130
+ return {
1078
1131
  type: "object",
1079
1132
  keys,
1080
- values
1081
- } : {
1082
- type: "object",
1083
- keys
1133
+ ...Object.keys(values).length > 0 ? { values } : {},
1134
+ ...Object.keys(arrayLengths).length > 0 ? { arrayLengths } : {},
1135
+ ...span === null ? {} : { dateSpanDays: span }
1084
1136
  };
1085
1137
  }
1086
1138
  const primitive = typeof body;
@@ -1092,7 +1144,7 @@ function sanitizedDiagnosticValue(value, depth, budget, field) {
1092
1144
  if (value === null) return null;
1093
1145
  if (typeof value === "boolean") return field && RESPONSE_BOOLEAN_KEYS.has(field) ? value : "[Redacted]";
1094
1146
  if (typeof value === "number") return field && RESPONSE_STATUS_KEYS.has(field) && Number.isInteger(value) && value >= 100 && value <= 599 ? value : "[Redacted]";
1095
- if (typeof value === "string") return "[Redacted]";
1147
+ if (typeof value === "string") return redactText(value);
1096
1148
  if (depth >= MAX_RESPONSE_DEPTH) return "[Truncated]";
1097
1149
  if (Array.isArray(value)) {
1098
1150
  const result = [];
@@ -1198,6 +1250,9 @@ function notifyHttpError(handler, method, url, status, diagnostics = {}) {
1198
1250
  } catch {}
1199
1251
  }
1200
1252
  //#endregion
1253
+ //#region ../js/src/transport.ts
1254
+ const defaultTrainHeroicTransport = (url, init) => fetch(url, init);
1255
+ //#endregion
1201
1256
  //#region ../js/src/auth.ts
1202
1257
  const DEFAULT_AUTH_URL = "https://apis.trainheroic.com/auth";
1203
1258
  /**
@@ -1221,7 +1276,7 @@ function authUrl() {
1221
1276
  */
1222
1277
  async function loginTrainHeroic(email, password, options = {}) {
1223
1278
  const url = authUrl();
1224
- const res = await fetch(url, {
1279
+ const res = await (options.transport ?? defaultTrainHeroicTransport)(url, {
1225
1280
  method: "POST",
1226
1281
  headers: {
1227
1282
  "content-type": "application/x-www-form-urlencoded",
@@ -1233,6 +1288,7 @@ async function loginTrainHeroic(email, password, options = {}) {
1233
1288
  }).toString()
1234
1289
  });
1235
1290
  if (!res.ok) {
1291
+ await res.body?.cancel().catch(() => {});
1236
1292
  notifyHttpError(options.onHttpError, "POST", url, res.status);
1237
1293
  return null;
1238
1294
  }
@@ -1246,115 +1302,6 @@ async function loginTrainHeroic(email, password, options = {}) {
1246
1302
  };
1247
1303
  }
1248
1304
  //#endregion
1249
- //#region ../js/src/client.ts
1250
- const DEFAULT_COACH_BASE = "https://api.trainheroic.com";
1251
- const DEFAULT_APIS_BASE = "https://apis.trainheroic.com";
1252
- /**
1253
- * Resolve an API host, allowing an env override. The override exists so a test harness can point
1254
- * the client at a local fake backend (and it doubles as a staging knob); production leaves these
1255
- * unset and gets the real hosts. Read through `globalThis.process?.env` — not an `import process`
1256
- * — so the runtime-agnostic `.` entry stays free of `node:*` and runs unchanged on workerd, and
1257
- * read per request (not at module load) so a value the harness sets in the child env always wins.
1258
- */
1259
- function envBase(key, fallback) {
1260
- const v = (globalThis.process?.env)?.[key];
1261
- return v && v.length > 0 ? v : fallback;
1262
- }
1263
- var TrainHeroicAuthError = class extends Error {
1264
- name = "TrainHeroicAuthError";
1265
- };
1266
- /**
1267
- * Authenticated TrainHeroic API client. Holds the credentials (on the hosted Worker, from the
1268
- * grant's encrypted props) and a lazily-acquired session token cached in memory for the life of
1269
- * *this client instance* — nothing longer. On a 401/403 it re-logs in once and retries, since
1270
- * TrainHeroic has no refresh token and sessions expire after ~1-2h.
1271
- *
1272
- * Anything that wants a session to outlive one client owns that itself: pass a previously
1273
- * acquired token as `sessionId` and keep the new one via `options.onSession`. That matters
1274
- * wherever clients are short-lived — the hosted Worker builds one per HTTP request, and each CLI
1275
- * invocation is a fresh process — because otherwise every operation replays the password.
1276
- */
1277
- var TrainHeroicClient = class {
1278
- #email;
1279
- #password;
1280
- #onSession;
1281
- #onHttpError;
1282
- #sessionId;
1283
- #loginInFlight = null;
1284
- constructor(email, password, sessionId = null, options = {}) {
1285
- this.#email = email;
1286
- this.#password = password;
1287
- this.#sessionId = sessionId;
1288
- this.#onSession = options.onSession;
1289
- this.#onHttpError = options.onHttpError;
1290
- }
1291
- get sessionId() {
1292
- return this.#sessionId;
1293
- }
1294
- async #ensureSession() {
1295
- if (this.#sessionId) return this.#sessionId;
1296
- this.#loginInFlight ??= this.#login();
1297
- try {
1298
- return await this.#loginInFlight;
1299
- } finally {
1300
- this.#loginInFlight = null;
1301
- }
1302
- }
1303
- async #login() {
1304
- const session = await loginTrainHeroic(this.#email, this.#password, this.#onHttpError ? { onHttpError: this.#onHttpError } : {});
1305
- if (!session) throw new TrainHeroicAuthError("TrainHeroic login failed");
1306
- this.#sessionId = session.sessionId;
1307
- try {
1308
- this.#onSession?.(this.#sessionId);
1309
- } catch {}
1310
- return this.#sessionId;
1311
- }
1312
- async request(method, path, options = {}) {
1313
- const url = `${options.base === "apis" ? envBase("TH_APIS_BASE", DEFAULT_APIS_BASE) : envBase("TH_COACH_BASE", DEFAULT_COACH_BASE)}/${path.replace(/^\//, "")}`;
1314
- let session = await this.#ensureSession();
1315
- let res = await this.#send(method, url, session, options.body);
1316
- if (res.status === 401 || res.status === 403) {
1317
- if (this.#sessionId === session) this.#sessionId = null;
1318
- session = await this.#ensureSession();
1319
- res = await this.#send(method, url, session, options.body);
1320
- }
1321
- const shouldReport = !res.ok && !options.expectedStatuses?.includes(res.status);
1322
- let text;
1323
- try {
1324
- text = await res.text();
1325
- } catch (error) {
1326
- if (shouldReport) notifyHttpError(this.#onHttpError, method, url, res.status, { requestBody: options.body });
1327
- throw error;
1328
- }
1329
- const data = parseResponseText(text);
1330
- if (shouldReport) notifyHttpError(this.#onHttpError, method, url, res.status, {
1331
- requestBody: options.body,
1332
- responseBody: data
1333
- });
1334
- return {
1335
- status: res.status,
1336
- ok: res.ok,
1337
- data
1338
- };
1339
- }
1340
- #send(method, url, session, body) {
1341
- const upper = method.toUpperCase();
1342
- const headers = {
1343
- accept: "application/json",
1344
- "session-token": session
1345
- };
1346
- const init = {
1347
- method: upper,
1348
- headers
1349
- };
1350
- if (body !== void 0 && upper !== "GET" && upper !== "DELETE") {
1351
- headers["content-type"] = "application/json";
1352
- init.body = JSON.stringify(body);
1353
- }
1354
- return fetch(url, init);
1355
- }
1356
- };
1357
- //#endregion
1358
1305
  //#region ../js/src/exercise-util.ts
1359
1306
  /**
1360
1307
  * Display labels for TrainHeroic parameter types. The unit is FIXED PER EXERCISE
@@ -1534,6 +1481,127 @@ function assertPositiveInteger(value, label) {
1534
1481
  if (!Number.isInteger(value) || value < 1) throw new RangeError(`${label} must be a positive integer; received ${value}.`);
1535
1482
  }
1536
1483
  //#endregion
1484
+ //#region ../js/src/client.ts
1485
+ const DEFAULT_COACH_BASE = "https://api.trainheroic.com";
1486
+ const DEFAULT_APIS_BASE = "https://apis.trainheroic.com";
1487
+ const MAX_REQUEST_CONCURRENCY = 4;
1488
+ /**
1489
+ * Resolve an API host, allowing an env override. The override exists so a test harness can point
1490
+ * the client at a local fake backend (and it doubles as a staging knob); production leaves these
1491
+ * unset and gets the real hosts. Read through `globalThis.process?.env` — not an `import process`
1492
+ * — so the runtime-agnostic `.` entry stays free of `node:*` and runs unchanged on workerd, and
1493
+ * read per request (not at module load) so a value the harness sets in the child env always wins.
1494
+ */
1495
+ function envBase(key, fallback) {
1496
+ const v = (globalThis.process?.env)?.[key];
1497
+ return v && v.length > 0 ? v : fallback;
1498
+ }
1499
+ var TrainHeroicAuthError = class extends Error {
1500
+ name = "TrainHeroicAuthError";
1501
+ };
1502
+ /**
1503
+ * Authenticated TrainHeroic API client. Holds the credentials (on the hosted Worker, from the
1504
+ * grant's encrypted props) and a lazily-acquired session token cached in memory for the life of
1505
+ * *this client instance* — nothing longer. On a 401/403 it re-logs in once and retries, since
1506
+ * TrainHeroic has no refresh token and sessions expire after ~1-2h.
1507
+ *
1508
+ * Anything that wants a session to outlive one client owns that itself: pass a previously
1509
+ * acquired token as `sessionId` and keep the new one via `options.onSession`. That matters
1510
+ * wherever clients are short-lived — the hosted Worker builds one per HTTP request, and each CLI
1511
+ * invocation is a fresh process — because otherwise every operation replays the password.
1512
+ */
1513
+ var TrainHeroicClient = class {
1514
+ #email;
1515
+ #password;
1516
+ #onSession;
1517
+ #onHttpError;
1518
+ #transport;
1519
+ #requestLimit = createLimiter(MAX_REQUEST_CONCURRENCY);
1520
+ #sessionId;
1521
+ #loginInFlight = null;
1522
+ constructor(email, password, sessionId = null, options = {}) {
1523
+ this.#email = email;
1524
+ this.#password = password;
1525
+ this.#sessionId = sessionId;
1526
+ this.#onSession = options.onSession;
1527
+ this.#onHttpError = options.onHttpError;
1528
+ this.#transport = options.transport ?? defaultTrainHeroicTransport;
1529
+ }
1530
+ get sessionId() {
1531
+ return this.#sessionId;
1532
+ }
1533
+ async #ensureSession() {
1534
+ if (this.#sessionId) return this.#sessionId;
1535
+ this.#loginInFlight ??= this.#login();
1536
+ try {
1537
+ return await this.#loginInFlight;
1538
+ } finally {
1539
+ this.#loginInFlight = null;
1540
+ }
1541
+ }
1542
+ async #login() {
1543
+ const session = await loginTrainHeroic(this.#email, this.#password, {
1544
+ ...this.#onHttpError ? { onHttpError: this.#onHttpError } : {},
1545
+ transport: this.#transport
1546
+ });
1547
+ if (!session) throw new TrainHeroicAuthError("TrainHeroic login failed");
1548
+ this.#sessionId = session.sessionId;
1549
+ try {
1550
+ this.#onSession?.(this.#sessionId);
1551
+ } catch {}
1552
+ return this.#sessionId;
1553
+ }
1554
+ async request(method, path, options = {}) {
1555
+ await this.#ensureSession();
1556
+ return this.#requestLimit.run(() => this.#request(method, path, options));
1557
+ }
1558
+ async #request(method, path, options) {
1559
+ const url = `${options.base === "apis" ? envBase("TH_APIS_BASE", DEFAULT_APIS_BASE) : envBase("TH_COACH_BASE", DEFAULT_COACH_BASE)}/${path.replace(/^\//, "")}`;
1560
+ let session = await this.#ensureSession();
1561
+ let res = await this.#send(method, url, session, options.body);
1562
+ if (res.status === 401 || res.status === 403) {
1563
+ await res.body?.cancel().catch(() => {});
1564
+ if (this.#sessionId === session) this.#sessionId = null;
1565
+ session = await this.#ensureSession();
1566
+ res = await this.#send(method, url, session, options.body);
1567
+ }
1568
+ const shouldReport = !res.ok && !options.expectedStatuses?.includes(res.status);
1569
+ let text;
1570
+ try {
1571
+ text = await res.text();
1572
+ } catch (error) {
1573
+ if (shouldReport) notifyHttpError(this.#onHttpError, method, url, res.status, { requestBody: options.body });
1574
+ throw error;
1575
+ }
1576
+ const data = parseResponseText(text);
1577
+ if (shouldReport) notifyHttpError(this.#onHttpError, method, url, res.status, {
1578
+ requestBody: options.body,
1579
+ responseBody: data
1580
+ });
1581
+ return {
1582
+ status: res.status,
1583
+ ok: res.ok,
1584
+ data
1585
+ };
1586
+ }
1587
+ #send(method, url, session, body) {
1588
+ const upper = method.toUpperCase();
1589
+ const headers = {
1590
+ accept: "application/json",
1591
+ "session-token": session
1592
+ };
1593
+ const init = {
1594
+ method: upper,
1595
+ headers
1596
+ };
1597
+ if (body !== void 0 && upper !== "GET" && upper !== "DELETE") {
1598
+ headers["content-type"] = "application/json";
1599
+ init.body = JSON.stringify(body);
1600
+ }
1601
+ return this.#transport(url, init);
1602
+ }
1603
+ };
1604
+ //#endregion
1537
1605
  //#region ../js/src/athlete.ts
1538
1606
  async function getJson(client, path, label) {
1539
1607
  const res = await client.request("GET", path);
@@ -3135,7 +3203,7 @@ function registerAthleteTrainingTools(server, ctx) {
3135
3203
  }
3136
3204
  //#endregion
3137
3205
  //#region package.json
3138
- var version = "3.6.0";
3206
+ var version = "3.7.1";
3139
3207
  //#endregion
3140
3208
  //#region src/server.ts
3141
3209
  function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/athlete-mcp",
3
- "version": "3.6.0",
3
+ "version": "3.7.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,14 +20,14 @@
20
20
  ],
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/server": "2.0.0",
23
- "zod": "^4.4.3",
24
- "@trainheroic-unofficial/core": "3.6.0",
25
- "@trainheroic-unofficial/js": "3.6.0"
23
+ "zod": "^4.6.3",
24
+ "@trainheroic-unofficial/core": "3.7.1",
25
+ "@trainheroic-unofficial/js": "3.7.1"
26
26
  },
27
27
  "devDependencies": {
28
- "@types/node": "^26.3.0",
29
- "tsdown": "^0.22.14",
30
- "tsx": "^4.23.12",
28
+ "@types/node": "^26.5.1",
29
+ "tsdown": "^0.23.0",
30
+ "tsx": "^4.23.13",
31
31
  "typescript": "^7.0.2",
32
32
  "vitest": "^4.1.11"
33
33
  },