@trainheroic-unofficial/athlete-mcp 3.1.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/server.mjs +513 -79
  2. package/package.json +4 -4
package/dist/server.mjs CHANGED
@@ -24,12 +24,14 @@ const numLikeOrNull = z.union([
24
24
  z.string(),
25
25
  z.null()
26
26
  ]);
27
- z.looseObject({
27
+ /** `/user/simple` — the identity + tenant key (numeric `id`) for any logged-in account. */
28
+ const userSimpleSchema = z.looseObject({
28
29
  id: intLike$1,
29
30
  roles: z.array(z.string()).optional(),
30
31
  org_id: intLikeOrNull$1.optional()
31
32
  });
32
- z.looseObject({
33
+ /** `/v5/athleteProfile/summary` — lifetime training totals. Needs `use_metric` in the query. */
34
+ const athleteProfileSummarySchema = z.looseObject({
33
35
  reps_sum: z.number().optional(),
34
36
  volume_sum: z.number().optional(),
35
37
  sessions_count: z.number().optional(),
@@ -37,7 +39,8 @@ z.looseObject({
37
39
  last_logged_date: z.string().optional(),
38
40
  duration_hours: z.number().optional()
39
41
  });
40
- z.looseObject({
42
+ /** `/v5/users/{id}` — the detailed athlete profile (only the fields we surface). */
43
+ const athleteUserSchema = z.looseObject({
41
44
  id: intLike$1,
42
45
  email: z.string().optional(),
43
46
  name_first: z.string().optional(),
@@ -47,7 +50,8 @@ z.looseObject({
47
50
  date_of_birth: z.string().optional(),
48
51
  use_metric: z.boolean().optional()
49
52
  });
50
- z.looseObject({ id: intLike$1 });
53
+ /** `/1.0/athlete/prefs` notification + display preference flags. */
54
+ const athletePrefsSchema = z.looseObject({ id: intLike$1 });
51
55
  /** One item of `/2.0/athlete/workingMax` — the athlete's working max for an exercise. */
52
56
  const athleteWorkingMaxSchema = z.looseObject({
53
57
  exercise_id: intLike$1,
@@ -57,7 +61,7 @@ const athleteWorkingMaxSchema = z.looseObject({
57
61
  type_suffix: z.string().optional(),
58
62
  working_max_id: intLikeOrNull$1.optional()
59
63
  });
60
- z.array(athleteWorkingMaxSchema);
64
+ const athleteWorkingMaxListSchema = z.array(athleteWorkingMaxSchema);
61
65
  /** One item of `/v5/users/exercises/history` — an exercise the athlete has logged. */
62
66
  const exerciseHistoryListItemSchema = z.looseObject({
63
67
  id: intLike$1,
@@ -107,7 +111,8 @@ const liftPRSchema = z.looseObject({
107
111
  isMetric: z.boolean().optional(),
108
112
  description: z.string().optional()
109
113
  });
110
- z.looseObject({
114
+ /** `/v5/exercises/{id}/history` — the per-exercise PRs + session history. */
115
+ const exerciseHistoryDetailSchema = z.looseObject({
111
116
  liftPRs: z.array(liftPRSchema).optional(),
112
117
  singleParamPRs: z.array(z.unknown()).optional(),
113
118
  history: z.array(historyEntrySchema).optional()
@@ -123,8 +128,9 @@ const personalRecordSchema = z.looseObject({
123
128
  units: z.string().optional(),
124
129
  isMetric: z.boolean().optional()
125
130
  });
126
- z.array(personalRecordSchema);
127
- z.looseObject({
131
+ const personalRecordListSchema = z.array(personalRecordSchema);
132
+ /** `/v5/exercises/{id}/stats` — last performance + PR for an exercise. Needs `date` in the query. */
133
+ const exerciseStatsSchema = z.looseObject({
128
134
  isLift: z.boolean().optional(),
129
135
  lastPerformance: z.unknown().optional(),
130
136
  personalRecord: z.unknown().optional()
@@ -144,7 +150,7 @@ const programWorkoutSchema = z.looseObject({
144
150
  team_title: z.string().optional(),
145
151
  summarizedSavedWorkout: z.unknown().optional()
146
152
  });
147
- z.array(programWorkoutSchema);
153
+ const programWorkoutListSchema = z.array(programWorkoutSchema);
148
154
  /** A `YYYY-MM-DD` date argument. The single definition reused across athlete tool inputs. */
149
155
  const dateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/u, "expected YYYY-MM-DD");
150
156
  z.object({
@@ -251,6 +257,163 @@ const athleteSessionRemoveArgsSchema = z.object({
251
257
  programWorkoutId: idArgSchema,
252
258
  date: dateString
253
259
  });
260
+ const presentedNullNumber = z.number().nullable();
261
+ const presentedNullString = z.string().nullable();
262
+ /** Flattened exercise within a presented workout: prescriptions, logged results, units. */
263
+ const athleteWorkoutExerciseSchema = z.object({
264
+ exerciseId: presentedNullNumber,
265
+ title: z.string(),
266
+ instruction: presentedNullString,
267
+ units: z.array(presentedNullString),
268
+ prescribed: z.array(z.string()),
269
+ performed: z.array(z.string())
270
+ });
271
+ const athleteWorkoutBlockSchema = z.object({
272
+ order: z.number(),
273
+ title: presentedNullString,
274
+ instruction: presentedNullString,
275
+ isTest: z.boolean(),
276
+ exercises: z.array(athleteWorkoutExerciseSchema)
277
+ });
278
+ const athleteWorkoutViewSchema = z.object({
279
+ id: presentedNullNumber,
280
+ date: z.string(),
281
+ title: z.string(),
282
+ program: presentedNullString,
283
+ team: presentedNullString,
284
+ instruction: presentedNullString,
285
+ logged: z.boolean(),
286
+ personal: z.boolean(),
287
+ blocks: z.array(athleteWorkoutBlockSchema)
288
+ });
289
+ const athleteWorkoutSummarySchema = z.object({
290
+ id: presentedNullNumber,
291
+ date: z.string(),
292
+ title: z.string(),
293
+ program: presentedNullString,
294
+ team: presentedNullString,
295
+ logged: z.boolean(),
296
+ personal: z.boolean(),
297
+ exerciseCount: z.number().int().nonnegative(),
298
+ performedCount: z.number().int().nonnegative()
299
+ });
300
+ /** Presented views first so a summary/detail row cannot be swallowed by the loose raw list. */
301
+ const athleteWorkoutsOutputSchema = z.union([
302
+ z.array(athleteWorkoutSummarySchema),
303
+ z.array(athleteWorkoutViewSchema),
304
+ programWorkoutListSchema
305
+ ]);
306
+ const athleteProfileOutputSchema = z.looseObject({
307
+ summary: athleteProfileSummarySchema,
308
+ user: athleteUserSchema
309
+ });
310
+ const athleteExerciseCatalogOutputSchema = z.array(z.object({
311
+ id: z.union([z.number(), z.string()]),
312
+ title: z.string(),
313
+ isCircuit: z.boolean(),
314
+ units: z.array(presentedNullString)
315
+ }));
316
+ const coachAthleteExerciseSchema = z.object({
317
+ exerciseId: presentedNullNumber,
318
+ title: z.string(),
319
+ summary: presentedNullString,
320
+ completed: z.boolean()
321
+ });
322
+ const coachAthleteSessionSchema = z.object({
323
+ workoutId: presentedNullNumber,
324
+ savedWorkoutId: presentedNullNumber,
325
+ title: z.string(),
326
+ logged: z.boolean(),
327
+ completed: z.boolean(),
328
+ rpe: presentedNullNumber,
329
+ durationMin: presentedNullNumber,
330
+ notes: presentedNullString,
331
+ exercises: z.array(coachAthleteExerciseSchema)
332
+ });
333
+ z.object({
334
+ athleteId: presentedNullNumber,
335
+ athleteName: presentedNullString,
336
+ year: z.number().int(),
337
+ month: z.number().int(),
338
+ sessions: z.array(coachAthleteSessionSchema)
339
+ });
340
+ const rosterActivityRowSchema = z.object({
341
+ athleteId: z.number(),
342
+ sessionsCount: presentedNullNumber,
343
+ firstLoggedDate: presentedNullString,
344
+ lastLoggedDate: presentedNullString,
345
+ totalReps: presentedNullNumber,
346
+ totalVolume: presentedNullNumber
347
+ });
348
+ const teamVolumeAthleteSchema = z.object({
349
+ athleteId: z.number(),
350
+ name: presentedNullString,
351
+ sessions: z.number(),
352
+ reps: z.number(),
353
+ volume: z.number(),
354
+ firstLoggedDate: presentedNullString,
355
+ lastLoggedDate: presentedNullString
356
+ });
357
+ z.object({
358
+ window: z.object({
359
+ start: z.string(),
360
+ end: z.string()
361
+ }),
362
+ athletes: z.array(teamVolumeAthleteSchema),
363
+ totals: z.object({
364
+ athletes: z.number(),
365
+ sessions: z.number(),
366
+ reps: z.number(),
367
+ volume: z.number()
368
+ })
369
+ });
370
+ z.array(rosterActivityRowSchema);
371
+ const presentedExerciseSessionSchema = z.object({
372
+ date: z.string(),
373
+ abr: presentedNullString,
374
+ estimated1RM: presentedNullNumber,
375
+ sets: z.array(z.object({
376
+ setNumber: z.number(),
377
+ value: presentedNullString
378
+ }))
379
+ });
380
+ const presentedExerciseHistorySchema = z.object({
381
+ liftPRs: z.array(z.object({
382
+ description: presentedNullString,
383
+ reps: presentedNullNumber,
384
+ weight: presentedNullNumber,
385
+ units: presentedNullString,
386
+ date: presentedNullString
387
+ })),
388
+ sessions: z.array(presentedExerciseSessionSchema)
389
+ });
390
+ /** Presented history first so the loose raw detail schema cannot swallow it. */
391
+ const exerciseHistoryOutputSchema = z.union([presentedExerciseHistorySchema, exerciseHistoryDetailSchema]);
392
+ //#endregion
393
+ //#region ../dto/src/exercise.ts
394
+ /**
395
+ * A row presented for display. The raw param-type codes are dropped and the fixed
396
+ * measurement units are surfaced positionally in `units`, ordered by entry slot
397
+ * (param 1, then param 2). Positional, not semantic: param 2 is not always the load
398
+ * — some exercises reverse the slots — so the units are not labelled by role.
399
+ */
400
+ const exerciseViewSchema = z.object({
401
+ id: z.number(),
402
+ title: z.string(),
403
+ can_edit: z.number(),
404
+ user_id: z.number().nullable(),
405
+ use_count: z.number(),
406
+ units: z.array(z.string().nullable())
407
+ });
408
+ z.looseObject({
409
+ id: idSchema,
410
+ title: z.string(),
411
+ units: z.array(z.string().nullable())
412
+ });
413
+ z.object({
414
+ match: exerciseViewSchema.nullable(),
415
+ candidates: z.array(exerciseViewSchema)
416
+ });
254
417
  z.looseObject({
255
418
  title: z.string().min(1),
256
419
  param_1_type: z.number().optional(),
@@ -298,6 +461,204 @@ z.looseObject({
298
461
  program_type: intLike
299
462
  }).optional()
300
463
  });
464
+ z.object({
465
+ pub_enabled: z.union([z.number(), z.boolean()]).optional(),
466
+ pub_days: z.unknown().optional(),
467
+ pub_time: z.unknown().optional(),
468
+ pub_timezone: z.string().optional()
469
+ }).refine((v) => v.pub_enabled !== void 0 || v.pub_days !== void 0 || v.pub_time !== void 0 || v.pub_timezone !== void 0, { message: "Provide at least one pub_* field" });
470
+ //#endregion
471
+ //#region ../dto/src/tool-output.ts
472
+ const nullableNumber = z.number().nullable();
473
+ const nullableString = z.string().nullable();
474
+ const truncationMarkerSchema = z.looseObject({
475
+ field: z.string().optional(),
476
+ returned: z.number().int().nonnegative().optional(),
477
+ total: z.number().int().nonnegative(),
478
+ omitted: z.number().int().nonnegative(),
479
+ hint: z.string()
480
+ });
481
+ /** Dedicated budget-fallback envelope. Never the natural tool shape. */
482
+ const truncatedOutputSchema = z.looseObject({
483
+ preview: z.string().optional(),
484
+ items: z.array(z.json()).optional(),
485
+ __truncated: truncationMarkerSchema
486
+ });
487
+ /**
488
+ * Add the shared size-budget fallback to a tool's natural result shape.
489
+ * Truncation is a dedicated `{ items|preview, __truncated }` envelope, so it cannot
490
+ * validate as the natural object and drop the marker.
491
+ */
492
+ function toolOutputSchema(schema) {
493
+ return z.union([truncatedOutputSchema, schema]);
494
+ }
495
+ /** Explicit passthrough for tools whose live API payload is not yet contracted. */
496
+ const opaqueOutputSchema = toolOutputSchema(z.json());
497
+ const logSetTargetOutputSchema = z.object({
498
+ date: z.string(),
499
+ workoutTitle: z.string(),
500
+ program: nullableString,
501
+ programId: nullableNumber,
502
+ team: nullableString,
503
+ teamId: nullableNumber,
504
+ savedWorkoutSetId: z.number(),
505
+ setTitle: nullableString,
506
+ exercises: z.array(z.object({
507
+ savedWorkoutSetExerciseId: z.number(),
508
+ title: z.string(),
509
+ units: z.array(nullableString),
510
+ prescribed: z.array(z.string()),
511
+ performed: z.array(z.string())
512
+ }))
513
+ });
514
+ const logTargetsOutputSchema = z.union([z.array(logSetTargetOutputSchema), programWorkoutListSchema]);
515
+ z.object({
516
+ draft: z.literal(true),
517
+ note: z.string(),
518
+ would_POST: z.string(),
519
+ payload: z.json()
520
+ });
521
+ z.looseObject({
522
+ status: z.enum(["sent", "logged"]),
523
+ reference: z.string().optional(),
524
+ note: z.string()
525
+ });
526
+ z.looseObject({ removed: z.union([z.boolean(), z.number()]) });
527
+ const athleteSessionRemovedOutputSchema = z.object({
528
+ removed: z.literal(true),
529
+ programWorkoutId: z.number(),
530
+ date: z.string()
531
+ });
532
+ z.object({
533
+ invited: z.literal(true),
534
+ teamId: z.number(),
535
+ result: z.json().optional()
536
+ });
537
+ const setLogOutputSchema = z.object({
538
+ savedWorkoutSetId: z.number(),
539
+ exercisesLogged: z.number().int().nonnegative(),
540
+ setCompleted: z.boolean()
541
+ });
542
+ const setPrescriptionOutputSchema = z.object({
543
+ savedWorkoutSetId: z.number(),
544
+ exercisesPrescribed: z.number().int().nonnegative()
545
+ });
546
+ const exerciseSwapOutputSchema = z.object({
547
+ savedWorkoutSetExerciseId: z.number(),
548
+ athleteId: nullableNumber,
549
+ newExerciseId: z.number(),
550
+ newExerciseTitle: nullableString,
551
+ originalTeamExerciseId: nullableNumber
552
+ });
553
+ const sessionLogSetOutputSchema = z.object({
554
+ savedWorkoutSetId: z.number(),
555
+ exercisesLogged: z.number().int().nonnegative()
556
+ });
557
+ const sessionLogOutputSchema = z.object({
558
+ date: z.string(),
559
+ created: z.boolean(),
560
+ sets: z.array(sessionLogSetOutputSchema),
561
+ scheduledAlternatives: z.array(z.object({
562
+ exerciseId: z.number(),
563
+ title: z.string(),
564
+ program: nullableString,
565
+ workoutTitle: z.string(),
566
+ savedWorkoutSetId: z.number(),
567
+ savedWorkoutSetExerciseId: z.number()
568
+ })).optional()
569
+ });
570
+ const personalWorkoutCreatedOutputSchema = z.object({
571
+ programWorkoutId: z.number(),
572
+ workoutId: z.number(),
573
+ savedWorkoutId: z.number(),
574
+ groupId: z.number(),
575
+ date: z.string()
576
+ });
577
+ const workoutReadExerciseOutputSchema = z.object({
578
+ order: z.number(),
579
+ title: z.string(),
580
+ reps: z.array(z.string()),
581
+ primaryUnit: nullableString,
582
+ load: z.array(z.string()),
583
+ loadUnit: nullableString,
584
+ instruction: z.string()
585
+ });
586
+ const workoutReadBlockOutputSchema = z.object({
587
+ order: z.number(),
588
+ title: z.string(),
589
+ instruction: z.string(),
590
+ leaderboard: nullableString,
591
+ exercises: z.array(workoutReadExerciseOutputSchema)
592
+ });
593
+ const workoutReadOutputSchema = z.object({
594
+ pwId: z.number(),
595
+ date: z.string(),
596
+ published: z.json().optional(),
597
+ instruction: z.string(),
598
+ blocks: z.array(workoutReadBlockOutputSchema)
599
+ });
600
+ z.object({
601
+ pwId: z.number(),
602
+ workoutId: z.number(),
603
+ programId: z.number(),
604
+ published: z.literal(false),
605
+ advisories: z.object({
606
+ notes: z.array(z.string()),
607
+ warnings: z.array(z.string())
608
+ }),
609
+ readback: workoutReadOutputSchema.nullable(),
610
+ note: z.string()
611
+ });
612
+ z.object({
613
+ published: z.number(),
614
+ readback: workoutReadOutputSchema,
615
+ note: z.string()
616
+ });
617
+ z.object({
618
+ sent: z.literal(true),
619
+ comment: z.json()
620
+ });
621
+ z.object({
622
+ deleted: z.literal(true),
623
+ response: z.json().optional()
624
+ });
625
+ z.object({ deleted: z.number() });
626
+ z.object({ forgotten: z.number() });
627
+ z.looseObject({ id: z.union([z.number(), z.string()]) });
628
+ const mainLiftPrOutputSchema = z.object({
629
+ family: z.enum([
630
+ "cleanjerk",
631
+ "snatch",
632
+ "deadlift",
633
+ "squat",
634
+ "bench",
635
+ "overhead"
636
+ ]),
637
+ label: z.string(),
638
+ exerciseId: nullableNumber,
639
+ title: nullableString,
640
+ weight: nullableNumber,
641
+ reps: nullableNumber,
642
+ units: nullableString,
643
+ date: nullableString
644
+ });
645
+ z.object({
646
+ athleteId: z.number(),
647
+ athleteName: nullableString,
648
+ prs: z.array(mainLiftPrOutputSchema)
649
+ });
650
+ z.object({
651
+ containerId: z.number(),
652
+ programId: z.number(),
653
+ title: z.string(),
654
+ kind: z.enum(["calendar", "fixed"]),
655
+ requestedName: z.string(),
656
+ nameApplied: z.boolean()
657
+ });
658
+ z.object({
659
+ programId: z.number(),
660
+ containerId: nullableNumber
661
+ });
301
662
  //#endregion
302
663
  //#region ../dto/src/workout.ts
303
664
  /** A single exercise prescription inside a block. */
@@ -350,6 +711,10 @@ z.object({
350
711
  blocks: z.array(blockSpecSchema),
351
712
  instruction: z.string().optional()
352
713
  });
714
+ z.object({
715
+ title: z.string().min(1),
716
+ instruction: z.string().optional()
717
+ });
353
718
  //#endregion
354
719
  //#region ../core/src/context.ts
355
720
  /** A tool argument that accepts a numeric id as a number or a string of digits. */
@@ -359,6 +724,12 @@ function toId(value) {
359
724
  }
360
725
  const READ = {
361
726
  readOnlyHint: true,
727
+ destructiveHint: false,
728
+ openWorldHint: true
729
+ };
730
+ const ADDITIVE = {
731
+ readOnlyHint: false,
732
+ destructiveHint: false,
362
733
  openWorldHint: true
363
734
  };
364
735
  const DESTRUCTIVE = {
@@ -380,32 +751,36 @@ async function attempt(fn) {
380
751
  }
381
752
  /** Conservative per-result character cap, below the smallest host cap. */
382
753
  const DEFAULT_RESULT_BUDGET = 6e4;
754
+ /** Smallest cap that can still carry a useful structured truncation envelope. */
755
+ const MIN_RESULT_BUDGET = 256;
383
756
  /** Reserve for the `__truncated` marker so wrapping cannot push back over budget. */
384
757
  const MARKER_RESERVE = 300;
385
758
  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.";
386
759
  const DEFAULT_OBJECT_HINT = "Result was truncated to fit the size budget. Request a more specific id or sub-resource.";
387
760
  /**
388
761
  * Clip an array to its first `keep` items and attach the `__truncated` marker describing what was
389
- * dropped. The marker is model-facing (tools instruct the model to key off `__truncated`), so its
390
- * shape has exactly one definition here: the size-budget path (`boundedSerialize`) and any tool
391
- * that deliberately caps a list (e.g. `athlete_exercises`) emit the same thing.
762
+ * dropped. Every budget fallback that trims a list uses this envelope (`{ items, __truncated }`),
763
+ * including in-place object-array truncation (the sliced array becomes `items` and `field` names
764
+ * the original key). `athlete_exercises` uses the same helper for a client-side catalog cap.
392
765
  */
393
- function clipArray(items, keep, hint) {
766
+ function clipArray(items, keep, hint, field) {
767
+ const marker = {
768
+ returned: keep,
769
+ total: items.length,
770
+ omitted: items.length - keep,
771
+ hint: hint ?? DEFAULT_ARRAY_HINT
772
+ };
773
+ if (field !== void 0) marker.field = field;
394
774
  return {
395
775
  items: items.slice(0, keep),
396
- __truncated: {
397
- returned: keep,
398
- total: items.length,
399
- omitted: items.length - keep,
400
- hint: hint ?? DEFAULT_ARRAY_HINT
401
- }
776
+ __truncated: marker
402
777
  };
403
778
  }
404
779
  /** Active budget. Overridable via TH_MCP_RESULT_BUDGET on Node; the default on workerd. */
405
780
  function resultBudget() {
406
781
  const raw = (globalThis.process?.env)?.TH_MCP_RESULT_BUDGET;
407
782
  const n = raw ? Number(raw) : NaN;
408
- return Number.isFinite(n) && n > 0 ? n : DEFAULT_RESULT_BUDGET;
783
+ return Number.isFinite(n) && n >= MIN_RESULT_BUDGET ? n : DEFAULT_RESULT_BUDGET;
409
784
  }
410
785
  function isPlainObject(value) {
411
786
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -422,13 +797,6 @@ function largestPrefixCount(pieces, charBudget) {
422
797
  }
423
798
  return k;
424
799
  }
425
- /** Last resort: cap a string at the budget and label it as truncated, non-JSON output. */
426
- function hardCap(text, budget, hint) {
427
- if (text.length <= budget) return text;
428
- 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)."}]`;
429
- const keep = Math.max(0, budget - note.length);
430
- return text.slice(0, keep) + note;
431
- }
432
800
  function largestArrayValuedKey(obj) {
433
801
  let best = null;
434
802
  let bestLen = -1;
@@ -442,56 +810,79 @@ function largestArrayValuedKey(obj) {
442
810
  }
443
811
  return best;
444
812
  }
445
- /**
446
- * Serialize `data` as JSON within `budget` characters. Small results are pretty-printed.
447
- * Oversized results degrade in order: trim a top-level array (wrapping it as
448
- * `{ items, __truncated }`), then a top-level object's largest array property (annotated
449
- * with `__truncated`), then a last-resort hard character cap. Pure and side-effect free.
450
- */
451
- function boundedSerialize(data, budget, hint) {
452
- if (typeof data === "string") return hardCap(data, budget, hint);
453
- const compact = JSON.stringify(data) ?? "null";
813
+ function jsonValue(data) {
814
+ return JSON.parse(JSON.stringify(data) ?? "null");
815
+ }
816
+ function previewEnvelope(source, budget, hint) {
817
+ const total = source.length;
818
+ const makeValue = (preview, markerHint) => ({
819
+ preview,
820
+ __truncated: {
821
+ total,
822
+ omitted: total - preview.length,
823
+ hint: markerHint
824
+ }
825
+ });
826
+ let markerHint = hint ?? "Narrow the query (filter, paginate, or fetch a specific id).";
827
+ while (JSON.stringify(makeValue("", markerHint)).length > budget && markerHint.length > 0) markerHint = markerHint.slice(0, -1);
828
+ if (JSON.stringify(makeValue("", markerHint)).length > budget) throw new RangeError("Result budget is too small for a structured truncation envelope.");
829
+ let keep = Math.max(0, budget - JSON.stringify(makeValue("", markerHint)).length);
830
+ let value = makeValue(source.slice(0, keep), markerHint);
831
+ while (JSON.stringify(value).length > budget && keep > 0) {
832
+ keep = Math.max(0, keep - (JSON.stringify(value).length - budget));
833
+ value = makeValue(source.slice(0, keep), markerHint);
834
+ }
835
+ return value;
836
+ }
837
+ function boundedResult(data, budget, hint) {
838
+ if (typeof data === "string" && data.length <= budget) return {
839
+ text: data,
840
+ value: data
841
+ };
842
+ const value = jsonValue(data);
843
+ const compact = JSON.stringify(value);
454
844
  if (compact.length <= budget) {
455
- const pretty = JSON.stringify(data, null, 2) ?? "null";
456
- return pretty.length <= budget ? pretty : compact;
845
+ const pretty = JSON.stringify(value, null, 2);
846
+ return {
847
+ text: pretty.length <= budget ? pretty : compact,
848
+ value
849
+ };
457
850
  }
458
- if (Array.isArray(data)) {
459
- const k = largestPrefixCount(data.map((el) => JSON.stringify(el) ?? "null"), budget - MARKER_RESERVE);
460
- const out = JSON.stringify(clipArray(data, k, hint));
461
- if (out.length <= budget) return out;
462
- } else if (isPlainObject(data)) {
463
- const key = largestArrayValuedKey(data);
851
+ if (Array.isArray(value)) {
852
+ const truncated = clipArray(value, largestPrefixCount(value.map((element) => JSON.stringify(element)), budget - MARKER_RESERVE), hint);
853
+ const text = JSON.stringify(truncated);
854
+ if (text.length <= budget) return {
855
+ text,
856
+ value: truncated
857
+ };
858
+ } else if (isPlainObject(value)) {
859
+ const key = largestArrayValuedKey(value);
464
860
  if (key !== null) {
465
- const arr = data[key];
466
- const pieces = arr.map((el) => JSON.stringify(el) ?? "null");
467
- const restLen = (JSON.stringify({
468
- ...data,
469
- [key]: []
470
- }) ?? "{}").length;
471
- const k = largestPrefixCount(pieces, Math.max(0, budget - MARKER_RESERVE - restLen));
472
- const clone = {
473
- ...data,
474
- [key]: arr.slice(0, k),
475
- __truncated: {
476
- field: key,
477
- returned: k,
478
- total: arr.length,
479
- omitted: arr.length - k,
480
- hint: hint ?? DEFAULT_OBJECT_HINT
481
- }
861
+ const array = value[key];
862
+ const truncated = clipArray(array, largestPrefixCount(array.map((element) => JSON.stringify(element)), budget - MARKER_RESERVE), hint ?? DEFAULT_OBJECT_HINT, key);
863
+ const text = JSON.stringify(truncated);
864
+ if (text.length <= budget) return {
865
+ text,
866
+ value: truncated
482
867
  };
483
- const out = JSON.stringify(clone);
484
- if (out.length <= budget) return out;
485
868
  }
486
869
  }
487
- return hardCap(compact, budget, hint);
870
+ const truncated = previewEnvelope(typeof data === "string" ? data : compact, budget, hint);
871
+ return {
872
+ text: JSON.stringify(truncated),
873
+ value: truncated
874
+ };
488
875
  }
489
876
  /** A successful tool result carrying JSON (or text) for the model, size-bounded. */
490
877
  function jsonResult(data, opts) {
491
- return { content: [{
492
- type: "text",
493
- text: boundedSerialize(data, resultBudget(), opts?.hint)
494
- }] };
878
+ const { text, value } = boundedResult(data, resultBudget(), opts?.hint);
879
+ return {
880
+ content: [{
881
+ type: "text",
882
+ text
883
+ }],
884
+ structuredContent: value
885
+ };
495
886
  }
496
887
  /** A tool-level error: returned in-band (isError) so the model can self-correct. */
497
888
  function errorResult(message) {
@@ -860,6 +1251,15 @@ function fetchWorkingMaxes(client) {
860
1251
  function fetchExerciseHistoryList(client) {
861
1252
  return getArray(client, "/v5/users/exercises/history", "athlete exercise history list");
862
1253
  }
1254
+ function fetchRecentExercises(client) {
1255
+ return getArray(client, "/v5/users/exercises/recent", "athlete recent exercises");
1256
+ }
1257
+ function fetchAthleteCircuits(client, kind = "recent") {
1258
+ return getArray(client, `/v5/users/circuits/${kind}`, `athlete ${kind} circuits`);
1259
+ }
1260
+ function fetchAthleteProgrammingPrograms(client) {
1261
+ return getArray(client, "/1.0/athlete/programming/programs", "athlete programming programs");
1262
+ }
863
1263
  /** Free-text search over the athlete's logged exercises (FTS replacement via rankSearch). */
864
1264
  async function searchExerciseHistory(client, query, limit = 20) {
865
1265
  return rankSearch(await fetchExerciseHistoryList(client), query, limit);
@@ -1811,12 +2211,14 @@ function registerProfileTools(server, ctx, whoami, userId) {
1811
2211
  title: "Who am I (athlete)",
1812
2212
  description: "The logged-in account's identity (id, name, roles) from /user/simple.",
1813
2213
  inputSchema: {},
2214
+ outputSchema: toolOutputSchema(userSimpleSchema),
1814
2215
  annotations: READ
1815
2216
  }, () => attempt(async () => jsonResult(await whoami())));
1816
2217
  server.registerTool("athlete_profile", {
1817
2218
  title: "Athlete profile + lifetime totals",
1818
2219
  description: "Lifetime training totals in one call — all-time session count (summary.sessions_count), total reps and volume, first/last logged date — plus the profile (name, units, dob). Use this for any 'how many sessions all-time / total volume ever' question rather than summing athlete_workouts windows. Set useMetric for kg/metric totals.",
1819
2220
  inputSchema: { useMetric: z.boolean().optional() },
2221
+ outputSchema: toolOutputSchema(athleteProfileOutputSchema),
1820
2222
  annotations: READ
1821
2223
  }, ({ useMetric }) => attempt(async () => {
1822
2224
  const id = await userId();
@@ -1830,12 +2232,14 @@ function registerProfileTools(server, ctx, whoami, userId) {
1830
2232
  title: "Athlete preferences",
1831
2233
  description: "Notification and display preference flags for the athlete account.",
1832
2234
  inputSchema: {},
2235
+ outputSchema: toolOutputSchema(athletePrefsSchema),
1833
2236
  annotations: READ
1834
2237
  }, () => attempt(async () => jsonResult(await fetchAthletePrefs(ctx.client))));
1835
2238
  server.registerTool("athlete_working_maxes", {
1836
2239
  title: "Working maxes",
1837
2240
  description: "The athlete's working max per exercise (drives % prescriptions). An entry can carry a null value: the exercise has a working-max slot but no number has been set yet, which means there is effectively no working max for it.",
1838
2241
  inputSchema: {},
2242
+ outputSchema: toolOutputSchema(athleteWorkingMaxListSchema),
1839
2243
  annotations: READ
1840
2244
  }, () => attempt(async () => jsonResult(await fetchWorkingMaxes(ctx.client))));
1841
2245
  server.registerTool("athlete_leaderboard", {
@@ -1847,6 +2251,7 @@ function registerProfileTools(server, ctx, whoami, userId) {
1847
2251
  pageSize: z.number().int().positive().max(200).optional(),
1848
2252
  gender: z.number().int().optional()
1849
2253
  },
2254
+ outputSchema: opaqueOutputSchema,
1850
2255
  annotations: READ
1851
2256
  }, ({ workoutId, page, pageSize, gender }) => attempt(async () => {
1852
2257
  const opts = {};
@@ -1903,6 +2308,7 @@ function registerExerciseTools(server, ctx, userId) {
1903
2308
  limit: z.number().int().positive().max(200).optional(),
1904
2309
  summary: z.boolean().optional()
1905
2310
  },
2311
+ outputSchema: toolOutputSchema(athleteWorkoutsOutputSchema),
1906
2312
  annotations: READ
1907
2313
  }, (args) => runAthleteWorkouts(ctx, args));
1908
2314
  server.registerTool("athlete_exercises", {
@@ -1912,6 +2318,7 @@ function registerExerciseTools(server, ctx, userId) {
1912
2318
  q: z.string().optional(),
1913
2319
  limit: z.number().int().positive().optional()
1914
2320
  },
2321
+ outputSchema: toolOutputSchema(athleteExerciseCatalogOutputSchema),
1915
2322
  annotations: READ
1916
2323
  }, (args) => runAthleteExercises(ctx, args));
1917
2324
  server.registerTool("athlete_exercise_history", {
@@ -1923,6 +2330,7 @@ function registerExerciseTools(server, ctx, userId) {
1923
2330
  since: dateString.optional(),
1924
2331
  until: dateString.optional()
1925
2332
  },
2333
+ outputSchema: toolOutputSchema(exerciseHistoryOutputSchema),
1926
2334
  annotations: READ
1927
2335
  }, ({ exerciseId, raw, since, until }) => attempt(async () => {
1928
2336
  const detail = await fetchExerciseHistoryDetail(ctx.client, toId(exerciseId), await userId());
@@ -1933,6 +2341,7 @@ function registerExerciseTools(server, ctx, userId) {
1933
2341
  title: "Exercise personal records",
1934
2342
  description: "The all-time PR board for an exercise (reps/weight per rep-max, strength-standard filters). Get the exercise id from athlete_exercises. A lift is often logged under several name variants, each with its own board, so if a PR looks missing check the other variants. For a point-in-time snapshot use athlete_exercise_stats; for the dated session trend use athlete_exercise_history.",
1935
2343
  inputSchema: { exerciseId: idParam },
2344
+ outputSchema: toolOutputSchema(personalRecordListSchema),
1936
2345
  annotations: READ
1937
2346
  }, ({ exerciseId }) => attempt(async () => jsonResult(await fetchPersonalRecords(ctx.client, toId(exerciseId)))));
1938
2347
  server.registerTool("athlete_exercise_stats", {
@@ -1942,6 +2351,7 @@ function registerExerciseTools(server, ctx, userId) {
1942
2351
  exerciseId: idParam,
1943
2352
  date: dateString
1944
2353
  },
2354
+ outputSchema: toolOutputSchema(exerciseStatsSchema),
1945
2355
  annotations: READ
1946
2356
  }, ({ exerciseId, date }) => attempt(async () => jsonResult(await fetchExerciseStats(ctx.client, toId(exerciseId), await userId(), date))));
1947
2357
  }
@@ -1958,6 +2368,7 @@ function registerLogTargetsTool(server, ctx) {
1958
2368
  teamId: idParam.optional(),
1959
2369
  raw: z.boolean().optional()
1960
2370
  },
2371
+ outputSchema: toolOutputSchema(logTargetsOutputSchema),
1961
2372
  annotations: READ
1962
2373
  }, ({ startDate, endDate, program, programId, teamId, raw }) => attempt(async () => {
1963
2374
  const workouts = selectWorkoutsByProgram(await fetchAthleteWorkouts(ctx.client, startDate, endDate), definedProps({
@@ -1975,11 +2386,8 @@ function registerSessionTools(server, ctx) {
1975
2386
  title: "Create personal workout session",
1976
2387
  description: "Create a new personal workout session for a given YYYY-MM-DD date on the athlete's personal calendar. Returns programWorkoutId, workoutId (pass to athlete_session_add_exercises), savedWorkoutId, groupId, and date.",
1977
2388
  inputSchema: { date: dateString },
1978
- annotations: {
1979
- readOnlyHint: false,
1980
- destructiveHint: false,
1981
- openWorldHint: true
1982
- }
2389
+ outputSchema: toolOutputSchema(personalWorkoutCreatedOutputSchema),
2390
+ annotations: ADDITIVE
1983
2391
  }, ({ date }) => attempt(async () => jsonResult(await createPersonalWorkout(ctx.client, date))));
1984
2392
  server.registerTool("athlete_session_add_exercises", {
1985
2393
  title: "Add exercises to a personal workout session",
@@ -1991,11 +2399,8 @@ function registerSessionTools(server, ctx) {
1991
2399
  order: z.number().int().positive()
1992
2400
  })).min(1)
1993
2401
  },
1994
- annotations: {
1995
- readOnlyHint: false,
1996
- destructiveHint: false,
1997
- openWorldHint: true
1998
- }
2402
+ outputSchema: opaqueOutputSchema,
2403
+ annotations: ADDITIVE
1999
2404
  }, ({ workoutId, exercises }) => attempt(async () => {
2000
2405
  const mapped = exercises.map((e) => ({
2001
2406
  exerciseId: toId(e.exerciseId),
@@ -2010,6 +2415,7 @@ function registerSessionTools(server, ctx) {
2010
2415
  ...athleteSessionRemoveArgsSchema.shape,
2011
2416
  confirm: z.boolean().optional()
2012
2417
  },
2418
+ outputSchema: toolOutputSchema(athleteSessionRemovedOutputSchema),
2013
2419
  annotations: DESTRUCTIVE
2014
2420
  }, ({ programWorkoutId, date, confirm }, extra) => attempt(async () => {
2015
2421
  const id = toId(programWorkoutId);
@@ -2052,6 +2458,7 @@ function registerLogTool(server, ctx) {
2052
2458
  ...logSessionArgsSchema.shape,
2053
2459
  confirm: z.boolean().optional()
2054
2460
  },
2461
+ outputSchema: toolOutputSchema(sessionLogOutputSchema),
2055
2462
  annotations: DESTRUCTIVE
2056
2463
  }, ({ date, exercises, confirm }, extra) => attempt(async () => {
2057
2464
  const blocked = confirmGate(extra, `Log a session of ${exercises.length} exercise(s) on ${date}? This writes to your coach-visible training log.`, confirm);
@@ -2070,6 +2477,7 @@ function registerLogTool(server, ctx) {
2070
2477
  ...logSetArgsSchema.shape,
2071
2478
  confirm: z.boolean().optional()
2072
2479
  },
2480
+ outputSchema: toolOutputSchema(setLogOutputSchema),
2073
2481
  annotations: DESTRUCTIVE
2074
2482
  }, ({ date, savedWorkoutSetId, results, confirm }, extra) => attempt(async () => {
2075
2483
  const blocked = confirmGate(extra, `Log results to saved workout set ${toId(savedWorkoutSetId)} on ${date}? This writes to your coach-visible training log.`, confirm);
@@ -2087,6 +2495,7 @@ function registerLogTool(server, ctx) {
2087
2495
  ...athletePrescribeSetArgsSchema.shape,
2088
2496
  confirm: z.boolean().optional()
2089
2497
  },
2498
+ outputSchema: toolOutputSchema(setPrescriptionOutputSchema),
2090
2499
  annotations: DESTRUCTIVE
2091
2500
  }, ({ date, savedWorkoutSetId, results, confirm }, extra) => attempt(async () => {
2092
2501
  const id = toId(savedWorkoutSetId);
@@ -2113,6 +2522,7 @@ function registerSwapTool(server, ctx) {
2113
2522
  ...swapAthleteExerciseArgsSchema.shape,
2114
2523
  confirm: z.boolean().optional()
2115
2524
  },
2525
+ outputSchema: toolOutputSchema(exerciseSwapOutputSchema),
2116
2526
  annotations: DESTRUCTIVE
2117
2527
  }, ({ savedWorkoutSetExerciseId, exerciseId, confirm }, extra) => attempt(async () => {
2118
2528
  const sweId = toId(savedWorkoutSetExerciseId);
@@ -2125,6 +2535,29 @@ function registerSwapTool(server, ctx) {
2125
2535
  }));
2126
2536
  }));
2127
2537
  }
2538
+ function registerCatalogReads(server, ctx) {
2539
+ server.registerTool("athlete_circuits", {
2540
+ title: "Circuit history",
2541
+ description: "Named circuit history for the logged-in athlete (GET /v5/users/circuits/{recent|history}). kind defaults to recent. Empty when the athlete has no saved circuits. Distinct from circuit *blocks* in workout_build (type 1).",
2542
+ inputSchema: { kind: z.enum(["recent", "history"]).optional() },
2543
+ outputSchema: opaqueOutputSchema,
2544
+ annotations: READ
2545
+ }, ({ kind }) => attempt(async () => jsonResult(await fetchAthleteCircuits(ctx.client, kind ?? "recent"))));
2546
+ server.registerTool("athlete_programming_programs", {
2547
+ title: "Subscribed programs",
2548
+ description: "Programs the athlete is subscribed to (GET /1.0/athlete/programming/programs). Not the coach list_programs surface. Empty when the athlete has no subscriptions.",
2549
+ inputSchema: {},
2550
+ outputSchema: opaqueOutputSchema,
2551
+ annotations: READ
2552
+ }, () => attempt(async () => jsonResult(await fetchAthleteProgrammingPrograms(ctx.client))));
2553
+ server.registerTool("athlete_recent_exercises", {
2554
+ title: "Recent exercises",
2555
+ description: "Recently used exercises (GET /v5/users/exercises/recent). Distinct from athlete_exercises (full logged catalog) and athlete_exercise_history (one lift).",
2556
+ inputSchema: {},
2557
+ outputSchema: opaqueOutputSchema,
2558
+ annotations: READ
2559
+ }, () => attempt(async () => jsonResult(await fetchRecentExercises(ctx.client))));
2560
+ }
2128
2561
  /**
2129
2562
  * Live tools over the logged-in user's own training (history, scheduled/completed workouts,
2130
2563
  * PRs, working maxes), plus a gated set-logging write. The athlete user id is
@@ -2147,6 +2580,7 @@ function registerAthleteTrainingTools(server, ctx) {
2147
2580
  };
2148
2581
  registerProfileTools(server, ctx, whoami, userId);
2149
2582
  registerExerciseTools(server, ctx, userId);
2583
+ registerCatalogReads(server, ctx);
2150
2584
  registerLogTargetsTool(server, ctx);
2151
2585
  registerSessionTools(server, ctx);
2152
2586
  registerLogTool(server, ctx);
@@ -2154,7 +2588,7 @@ function registerAthleteTrainingTools(server, ctx) {
2154
2588
  }
2155
2589
  //#endregion
2156
2590
  //#region package.json
2157
- var version = "3.1.1";
2591
+ var version = "3.3.0";
2158
2592
  //#endregion
2159
2593
  //#region src/server.ts
2160
2594
  function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/athlete-mcp",
3
- "version": "3.1.1",
3
+ "version": "3.3.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,13 +21,13 @@
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/server": "2.0.0",
23
23
  "zod": "^4.4.3",
24
- "@trainheroic-unofficial/core": "3.1.1",
25
- "@trainheroic-unofficial/js": "3.1.1"
24
+ "@trainheroic-unofficial/core": "3.3.0",
25
+ "@trainheroic-unofficial/js": "3.3.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^26.2.0",
29
29
  "tsdown": "^0.22.14",
30
- "tsx": "^4.23.11",
30
+ "tsx": "^4.23.12",
31
31
  "typescript": "^7.0.2",
32
32
  "vitest": "^4.1.10"
33
33
  },