@trainheroic-unofficial/core 0.1.0 → 0.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.
package/README.md CHANGED
@@ -16,7 +16,9 @@ functions:
16
16
  ```ts
17
17
  import {
18
18
  registerReadTools,
19
- registerRawTools,
19
+ registerAthleteTools,
20
+ registerTeamTools,
21
+ registerAnalyticsTools,
20
22
  registerExerciseTools,
21
23
  registerWorkoutTools,
22
24
  registerMessagingTools,
@@ -36,15 +38,18 @@ change.
36
38
  ## What the tools cover
37
39
 
38
40
  The tools group into coach reads (profile, athletes, teams, programs, notifications,
39
- analytics), exercise library operations (resolve, search, get, sync, create, forget,
40
- stats), the workout lifecycle (build a draft, read it back, publish, remove), messaging
41
- (list, read, draft, send, delete), and a `th_request` escape hatch for any endpoint the
42
- dedicated tools do not cover.
41
+ analytics catalog), athlete management (invite, archive, restore), team management (create,
42
+ rename, delete, join codes), analytics report pulls (`analytics_query`: readiness, 1RM and
43
+ working-max history, training summary, compliance, lift progress), exercise library
44
+ operations (resolve, search, get, sync, create,
45
+ forget, stats), the workout/session lifecycle (build a draft, read it back, publish,
46
+ unpublish, copy, save as template, remove), and messaging (list, read, draft, send, delete).
47
+ There is no raw-request escape hatch; every endpoint reaches the model through a typed tool.
43
48
 
44
49
  Tools return their result in-band. A failure comes back as an error result the model can
45
50
  read and correct, not a thrown exception. Reads are marked read-only. Athlete-facing or
46
- destructive actions (publish, remove, send, delete, and non-GET `th_request`) pass through a
47
- confirmation gate before they run.
51
+ destructive actions (publish, unpublish, remove, send, delete, archive, team/code delete)
52
+ pass through a confirmation gate before they run.
48
53
 
49
54
  The D1-backed warehouse sync tools are not here; they live in the `cloudflare` package
50
55
  because they depend on its storage.
package/dist/index.d.mts CHANGED
@@ -63,13 +63,26 @@ declare function confirmGate(server: McpServer, requestId: string | number | und
63
63
  /** Read-only coach/athlete queries. Exercise lookups live in the exercise store tools. */
64
64
  declare function registerReadTools(server: McpServer, ctx: ToolContext): void;
65
65
  //#endregion
66
- //#region src/tools/raw.d.ts
66
+ //#region src/tools/athletes.d.ts
67
67
  /**
68
- * Escape hatch covering every endpoint without a dedicated tool (e.g. the analytics
69
- * POSTs). GET is ungated; mutating methods go through the same confirmation gate as
70
- * the dedicated destructive tools so this cannot be used to bypass it.
68
+ * Athlete management. TrainHeroic has no "create athlete" primitive: a coach invites a
69
+ * person by email to a team, and the athlete record only exists once they accept and set
70
+ * their own name. So the create path is the two-step invite from the API reference:
71
+ * validate the address (POST /v5/emails/validate), then inviteToTeam
72
+ * (POST /v5/athletes/inviteToTeam). Both act on the live account, so the invite is gated.
71
73
  */
72
- declare function registerRawTools(server: McpServer, ctx: ToolContext): void;
74
+ declare function registerAthleteTools(server: McpServer, ctx: ToolContext): void;
75
+ //#endregion
76
+ //#region src/tools/teams.d.ts
77
+ /**
78
+ * Team write tools. The team reads (list_teams, get_team, list_team_codes) live in
79
+ * reads.ts; this module covers create/rename/delete plus the join-code lifecycle.
80
+ */
81
+ declare function registerTeamTools(server: McpServer, ctx: ToolContext): void;
82
+ //#endregion
83
+ //#region src/tools/analytics.d.ts
84
+ /** Analytics report pulls. Read-only despite the POST verb, so ungated. */
85
+ declare function registerAnalyticsTools(server: McpServer, ctx: ToolContext): void;
73
86
  //#endregion
74
87
  //#region src/tools/exercises.d.ts
75
88
  /**
@@ -80,11 +93,11 @@ declare function registerRawTools(server: McpServer, ctx: ToolContext): void;
80
93
  declare function registerExerciseTools(server: McpServer, ctx: ToolContext): void;
81
94
  //#endregion
82
95
  //#region src/tools/workout.d.ts
83
- /** Workout building, read-back, publishing, and removal. */
96
+ /** Workout building, read-back, publishing, and the session calendar lifecycle. */
84
97
  declare function registerWorkoutTools(server: McpServer, ctx: ToolContext): void;
85
98
  //#endregion
86
99
  //#region src/tools/messaging.d.ts
87
100
  /** Live messaging: list/read conversations, draft a message, and the gated send/delete. */
88
101
  declare function registerMessagingTools(server: McpServer, ctx: ToolContext): void;
89
102
  //#endregion
90
- export { BudgetHint, DEFAULT_RESULT_BUDGET, DESTRUCTIVE, NOT_CONFIRMED, READ, SYNC, ToolContext, apiCall, attempt, boundedSerialize, confirmGate, errorResult, idParam, jsonResult, registerExerciseTools, registerMessagingTools, registerRawTools, registerReadTools, registerWorkoutTools, resultBudget, toId };
103
+ export { BudgetHint, DEFAULT_RESULT_BUDGET, DESTRUCTIVE, NOT_CONFIRMED, READ, SYNC, ToolContext, apiCall, attempt, boundedSerialize, confirmGate, errorResult, idParam, jsonResult, registerAnalyticsTools, registerAthleteTools, registerExerciseTools, registerMessagingTools, registerReadTools, registerTeamTools, registerWorkoutTools, resultBudget, toId };
package/dist/index.mjs CHANGED
@@ -221,12 +221,12 @@ const SIMPLE_GETS = [
221
221
  {
222
222
  name: "analytics_categories",
223
223
  title: "Analytics categories",
224
- description: "Lists available analytics types. Pull the data via th_request POST /v5/analytics/*.",
224
+ description: "Lists available analytics types. Pull a report with analytics_query (pick the matching metric).",
225
225
  path: "/v5/analytics"
226
226
  }
227
227
  ];
228
- /** Read-only coach/athlete queries. Exercise lookups live in the exercise store tools. */
229
- function registerReadTools(server, ctx) {
228
+ /** Roster-level reads: the fixed GETs plus the filterable athlete and team lists. */
229
+ function registerRosterReads(server, ctx) {
230
230
  for (const t of SIMPLE_GETS) server.registerTool(t.name, {
231
231
  title: t.title,
232
232
  description: t.description,
@@ -279,6 +279,9 @@ function registerReadTools(server, ctx) {
279
279
  const query = qs.toString();
280
280
  return apiCall(ctx, "GET", `/1.0/coach/teams${query ? `?${query}` : ""}`);
281
281
  });
282
+ }
283
+ /** Reads scoped to a single entity (team, program, athlete) plus the activity feed. */
284
+ function registerEntityReads(server, ctx) {
282
285
  server.registerTool("get_team", {
283
286
  title: "Get team",
284
287
  description: "Full team object by team id.",
@@ -297,39 +300,283 @@ function registerReadTools(server, ctx) {
297
300
  inputSchema: { programId: idParam },
298
301
  annotations: READ
299
302
  }, ({ programId }) => apiCall(ctx, "GET", `/3.0/coach/program/${enc(programId)}`, void 0, "This is a large, deep object. If it is truncated, fetch a narrower view (a specific session) instead."));
303
+ server.registerTool("coach_activity_feed", {
304
+ title: "Coach activity feed",
305
+ description: "Recent athlete activity across the roster (GET /v5/coaches/activityFeed): completed workouts, PRs, and posts. Paginate with page/pageSize.",
306
+ inputSchema: {
307
+ page: z.number().int().positive().optional(),
308
+ pageSize: z.number().int().positive().optional()
309
+ },
310
+ annotations: READ
311
+ }, ({ page, pageSize }) => {
312
+ const qs = new URLSearchParams();
313
+ if (page !== void 0) qs.set("page", String(page));
314
+ if (pageSize !== void 0) qs.set("pageSize", String(pageSize));
315
+ const query = qs.toString();
316
+ return apiCall(ctx, "GET", `/v5/coaches/activityFeed${query ? `?${query}` : ""}`);
317
+ });
318
+ server.registerTool("athlete_workouts", {
319
+ title: "Athlete workout history",
320
+ description: "One athlete's workouts with their survey/readiness data (GET /v5/coaches/athletes/{athleteId}/workouts). Bound it with startDate/endDate (YYYY-MM-DD) to keep the response small.",
321
+ inputSchema: {
322
+ athleteId: idParam,
323
+ startDate: z.string().optional(),
324
+ endDate: z.string().optional()
325
+ },
326
+ annotations: READ
327
+ }, ({ athleteId, startDate, endDate }) => {
328
+ const qs = new URLSearchParams();
329
+ if (startDate !== void 0) qs.set("startDate", startDate);
330
+ if (endDate !== void 0) qs.set("endDate", endDate);
331
+ const query = qs.toString();
332
+ return apiCall(ctx, "GET", `/v5/coaches/athletes/${enc(athleteId)}/workouts${query ? `?${query}` : ""}`, void 0, "Narrow the date range to shrink this if it is truncated.");
333
+ });
334
+ }
335
+ /** Read-only coach/athlete queries. Exercise lookups live in the exercise store tools. */
336
+ function registerReadTools(server, ctx) {
337
+ registerRosterReads(server, ctx);
338
+ registerEntityReads(server, ctx);
300
339
  }
301
340
  //#endregion
302
- //#region src/tools/raw.ts
341
+ //#region src/tools/athletes.ts
342
+ const DEFAULT_INVITE_MESSAGE = "Follow these steps and you'll be set up and ready to go!";
343
+ /** Normalize one-or-many emails into a deduped, trimmed list. */
344
+ function asEmailList(emails) {
345
+ return [...new Set((Array.isArray(emails) ? emails : [emails]).map((e) => e.trim()).filter((e) => e.length > 0))];
346
+ }
303
347
  /**
304
- * Escape hatch covering every endpoint without a dedicated tool (e.g. the analytics
305
- * POSTs). GET is ungated; mutating methods go through the same confirmation gate as
306
- * the dedicated destructive tools so this cannot be used to bypass it.
348
+ * Athlete management. TrainHeroic has no "create athlete" primitive: a coach invites a
349
+ * person by email to a team, and the athlete record only exists once they accept and set
350
+ * their own name. So the create path is the two-step invite from the API reference:
351
+ * validate the address (POST /v5/emails/validate), then inviteToTeam
352
+ * (POST /v5/athletes/inviteToTeam). Both act on the live account, so the invite is gated.
307
353
  */
308
- function registerRawTools(server, ctx) {
309
- server.registerTool("th_request", {
310
- title: "Raw TrainHeroic request",
311
- description: "Call any TrainHeroic endpoint directly. `path` is everything after the host. `base` selects the host: 'coach' = api.trainheroic.com (default), 'apis' = apis.trainheroic.com. Prefer dedicated tools where they exist. POST/PUT/DELETE act on the live account and require confirmation.",
354
+ function registerAthleteTools(server, ctx) {
355
+ server.registerTool("athlete_invite", {
356
+ title: "Invite an athlete",
357
+ description: "Add athletes by emailing them a TrainHeroic team invitation this is how you 'create' an athlete; there is no other create path, and no name is collected here (the athlete sets their own on accept). Targets a team by id, so list_teams first if you need one. Validates each address, then sends the invite. ATHLETE-FACING and immediate; requires confirmation (elicitation, or confirm:true).",
358
+ inputSchema: {
359
+ teamId: idParam,
360
+ emails: z.union([z.string(), z.array(z.string()).min(1)]),
361
+ message: z.string().optional(),
362
+ confirm: z.boolean().optional()
363
+ },
364
+ annotations: DESTRUCTIVE
365
+ }, ({ teamId, emails, message, confirm }, extra) => attempt(async () => {
366
+ const list = asEmailList(emails);
367
+ if (list.length === 0) return errorResult("Provide at least one email address to invite.");
368
+ const id = toId(teamId);
369
+ if (!await confirmGate(server, extra.requestId, `Invite ${list.join(", ")} to team ${id}? This emails them a real TrainHeroic invitation.`, confirm)) return errorResult(NOT_CONFIRMED);
370
+ const validation = await ctx.client.request("POST", "/v5/emails/validate", { body: { emails: list.join(",") } });
371
+ if (!validation.ok) {
372
+ const detail = typeof validation.data === "string" ? validation.data : JSON.stringify(validation.data);
373
+ return errorResult(`Email validation failed (HTTP ${validation.status}): ${detail}`);
374
+ }
375
+ const valid = Array.isArray(validation.data) ? validation.data : list;
376
+ if (valid.length === 0) return errorResult(`No valid addresses among: ${list.join(", ")}. They may be malformed or already on the team.`);
377
+ const invite = await ctx.client.request("POST", "/v5/athletes/inviteToTeam", { body: {
378
+ teamType: 0,
379
+ teamId: id,
380
+ orgId: null,
381
+ emails: valid,
382
+ message: message ?? DEFAULT_INVITE_MESSAGE
383
+ } });
384
+ if (!invite.ok) {
385
+ const detail = typeof invite.data === "string" ? invite.data : JSON.stringify(invite.data);
386
+ return errorResult(`Invite failed (HTTP ${invite.status}): ${detail}`);
387
+ }
388
+ return jsonResult({
389
+ invited: true,
390
+ teamId: id,
391
+ result: invite.data
392
+ });
393
+ }));
394
+ server.registerTool("athlete_archive", {
395
+ title: "Archive athletes",
396
+ description: "Remove one or more athletes from the active roster (PUT /v5/athletes/archive). Their data is preserved and they can be restored. Acts on the live account; requires confirmation (elicitation, or confirm:true).",
397
+ inputSchema: {
398
+ athleteIds: z.array(idParam).min(1),
399
+ confirm: z.boolean().optional()
400
+ },
401
+ annotations: DESTRUCTIVE
402
+ }, ({ athleteIds, confirm }, extra) => attempt(async () => {
403
+ const ids = athleteIds.map(toId);
404
+ if (!await confirmGate(server, extra.requestId, `Archive athlete(s) ${ids.join(", ")}? They leave the active roster (data is kept and restorable).`, confirm)) return errorResult(NOT_CONFIRMED);
405
+ return apiCall(ctx, "PUT", "/v5/athletes/archive", { body: { athleteIds: ids } });
406
+ }));
407
+ server.registerTool("athlete_restore", {
408
+ title: "Restore athletes",
409
+ description: "Restore previously archived athletes to the active roster (PUT /v5/athletes/restore).",
410
+ inputSchema: { athleteIds: z.array(idParam).min(1) },
411
+ annotations: {
412
+ readOnlyHint: false,
413
+ idempotentHint: true,
414
+ destructiveHint: false,
415
+ openWorldHint: true
416
+ }
417
+ }, ({ athleteIds }) => apiCall(ctx, "PUT", "/v5/athletes/restore", { body: { athleteIds: athleteIds.map(toId) } }));
418
+ }
419
+ //#endregion
420
+ //#region src/tools/teams.ts
421
+ const ADDITIVE = {
422
+ readOnlyHint: false,
423
+ destructiveHint: false,
424
+ openWorldHint: true
425
+ };
426
+ /**
427
+ * Team write tools. The team reads (list_teams, get_team, list_team_codes) live in
428
+ * reads.ts; this module covers create/rename/delete plus the join-code lifecycle.
429
+ */
430
+ function registerTeamTools(server, ctx) {
431
+ server.registerTool("team_create", {
432
+ title: "Create a team",
433
+ description: "Create a team (POST /1.0/coach/team/createWithTitleAndCode). Also creates the team's calendar/program. Returns the new team including its calendar id. Use the team id with athlete_invite.",
434
+ inputSchema: { title: z.string().min(1) },
435
+ annotations: ADDITIVE
436
+ }, ({ title }) => apiCall(ctx, "POST", "/1.0/coach/team/createWithTitleAndCode", { body: { title } }));
437
+ server.registerTool("team_update", {
438
+ title: "Rename a team",
439
+ description: "Update a team's settings, e.g. its title (PUT /v5/teams/{teamId}).",
440
+ inputSchema: {
441
+ teamId: idParam,
442
+ title: z.string().min(1)
443
+ },
444
+ annotations: ADDITIVE
445
+ }, ({ teamId, title }) => apiCall(ctx, "PUT", `/v5/teams/${toId(teamId)}`, { body: { title } }));
446
+ server.registerTool("team_delete", {
447
+ title: "Delete a team",
448
+ description: "Delete a team (DELETE /v5/teams/{teamId}). Removes the team and its calendar from the live account; hard to undo. Requires confirmation (elicitation, or confirm:true).",
449
+ inputSchema: {
450
+ teamId: idParam,
451
+ confirm: z.boolean().optional()
452
+ },
453
+ annotations: DESTRUCTIVE
454
+ }, ({ teamId, confirm }, extra) => attempt(async () => {
455
+ const id = toId(teamId);
456
+ if (!await confirmGate(server, extra.requestId, `Delete team ${id}? This removes the team and its calendar from the live account.`, confirm)) return errorResult(NOT_CONFIRMED);
457
+ return apiCall(ctx, "DELETE", `/v5/teams/${id}`);
458
+ }));
459
+ server.registerTool("team_code_create", {
460
+ title: "Create a team join code",
461
+ description: "Create an access code athletes use to self-join a team (POST /v5/teams/{teamId}/teamCodes). `type` defaults to 2, the standard join code.",
312
462
  inputSchema: {
313
- method: z.enum([
314
- "GET",
315
- "POST",
316
- "PUT",
317
- "DELETE"
318
- ]),
319
- path: z.string().min(1),
320
- body: z.unknown().optional(),
321
- base: z.enum(["coach", "apis"]).optional(),
463
+ teamId: idParam,
464
+ type: z.number().int().optional()
465
+ },
466
+ annotations: ADDITIVE
467
+ }, ({ teamId, type }) => apiCall(ctx, "POST", `/v5/teams/${toId(teamId)}/teamCodes`, { body: { type: type ?? 2 } }));
468
+ server.registerTool("team_code_delete", {
469
+ title: "Delete a team join code",
470
+ description: "Delete a team access code by its id (DELETE /v5/teamCodes/{codeId}). Athletes can no longer use it to join. Requires confirmation (elicitation, or confirm:true).",
471
+ inputSchema: {
472
+ codeId: idParam,
322
473
  confirm: z.boolean().optional()
323
474
  },
324
475
  annotations: DESTRUCTIVE
325
- }, async ({ method, path, body, base, confirm }, extra) => {
326
- if (method !== "GET") {
327
- if (!await confirmGate(server, extra.requestId, `Run ${method} ${path} against the live TrainHeroic account?`, confirm)) return errorResult(NOT_CONFIRMED);
476
+ }, ({ codeId, confirm }, extra) => attempt(async () => {
477
+ const id = toId(codeId);
478
+ if (!await confirmGate(server, extra.requestId, `Delete team join code ${id}? Athletes can no longer use it to join.`, confirm)) return errorResult(NOT_CONFIRMED);
479
+ return apiCall(ctx, "DELETE", `/v5/teamCodes/${id}`);
480
+ }));
481
+ }
482
+ //#endregion
483
+ //#region src/tools/analytics.ts
484
+ const METRIC_KEYS = [
485
+ "readiness-team",
486
+ "readiness-athlete",
487
+ "lift-1rm-history",
488
+ "training-summary-athlete",
489
+ "training-summary-team",
490
+ "compliance-team",
491
+ "lift-progress-team",
492
+ "working-max-history"
493
+ ];
494
+ const METRICS = {
495
+ "readiness-team": {
496
+ path: "/v5/analytics/readiness/teams",
497
+ scope: "team",
498
+ fields: ["date"]
499
+ },
500
+ "readiness-athlete": {
501
+ path: "/v5/analytics/readiness/users",
502
+ scope: "user",
503
+ fields: ["date"]
504
+ },
505
+ "lift-1rm-history": {
506
+ path: "/v5/analytics/lift-one-rep-max-history/users",
507
+ scope: "user",
508
+ fields: [
509
+ "date_start",
510
+ "date_end",
511
+ "exercise_id",
512
+ "use_metric"
513
+ ]
514
+ },
515
+ "training-summary-athlete": {
516
+ path: "/v5/analytics/training-summary/users",
517
+ scope: "user",
518
+ fields: ["date_start", "date_end"]
519
+ },
520
+ "training-summary-team": {
521
+ path: "/v5/analytics/training-summary/teams",
522
+ scope: "team",
523
+ fields: ["date_start", "date_end"]
524
+ },
525
+ "compliance-team": {
526
+ path: "/v5/analytics/compliance",
527
+ scope: "team",
528
+ fields: ["date_start", "date_end"]
529
+ },
530
+ "lift-progress-team": {
531
+ path: "/v5/analytics/lift-progress/teams",
532
+ scope: "team",
533
+ fields: [
534
+ "exercise_id",
535
+ "date_start",
536
+ "date_end"
537
+ ]
538
+ },
539
+ "working-max-history": {
540
+ path: "/v5/analytics/working-max-history/users",
541
+ scope: "user",
542
+ fields: ["date_start", "date_end"]
543
+ }
544
+ };
545
+ /** Analytics report pulls. Read-only despite the POST verb, so ungated. */
546
+ function registerAnalyticsTools(server, ctx) {
547
+ server.registerTool("analytics_query", {
548
+ title: "Pull an analytics report",
549
+ description: "Read a TrainHeroic analytics report. `metric` picks the report (run analytics_categories for the catalog). Team metrics need teamId; athlete metrics need userIds. Dates are YYYY-MM-DD: readiness takes a single `date`, history/summary metrics take dateStart/dateEnd. lift-1rm-history and lift-progress-team also take exerciseId.",
550
+ inputSchema: {
551
+ metric: z.enum(METRIC_KEYS),
552
+ teamId: idParam.optional(),
553
+ userIds: z.array(idParam).optional(),
554
+ exerciseId: idParam.optional(),
555
+ date: z.string().optional(),
556
+ dateStart: z.string().optional(),
557
+ dateEnd: z.string().optional(),
558
+ useMetric: z.boolean().optional()
559
+ },
560
+ annotations: READ
561
+ }, async ({ metric, teamId, userIds, exerciseId, date, dateStart, dateEnd, useMetric }) => {
562
+ const spec = METRICS[metric];
563
+ const body = {};
564
+ if (spec.scope === "team") {
565
+ if (teamId === void 0) return errorResult(`${metric} needs teamId.`);
566
+ body.teamId = toId(teamId);
567
+ } else {
568
+ if (userIds === void 0 || userIds.length === 0) return errorResult(`${metric} needs userIds (one or more athlete ids).`);
569
+ body.user_ids = userIds.map((u) => String(toId(u)));
328
570
  }
329
- const options = {};
330
- if (body !== void 0) options.body = body;
331
- if (base !== void 0) options.base = base;
332
- return apiCall(ctx, method, path, options, "Large or unfiltered response. Add query params to narrow it, or use a dedicated tool for this endpoint if one exists.");
571
+ const provided = {
572
+ date,
573
+ date_start: dateStart,
574
+ date_end: dateEnd,
575
+ exercise_id: exerciseId === void 0 ? void 0 : String(toId(exerciseId)),
576
+ use_metric: useMetric
577
+ };
578
+ for (const field of spec.fields) if (provided[field] !== void 0) body[field] = provided[field];
579
+ return apiCall(ctx, "POST", spec.path, { body }, "Narrow with a tighter date range or fewer athletes.");
333
580
  });
334
581
  }
335
582
  //#endregion
@@ -343,7 +590,7 @@ function registerExerciseTools(server, ctx) {
343
590
  const index = ctx.index;
344
591
  server.registerTool("exercise_resolve", {
345
592
  title: "Resolve exercise name",
346
- description: "Map a name to an exercise id via the local mirror. Returns the match plus ranked candidates; when ambiguous, match is null and you should pick from candidates. Units (param_1_unit/param_2_unit) are fixed per exercise — check them before prescribing.",
593
+ description: "Map a name to an exercise id via the local mirror. Returns the match plus ranked candidates; when ambiguous, match is null and you should pick from candidates. Each result's `units` array lists the fixed measurement units by entry slot; they are fixed per exercise — check them before prescribing.",
347
594
  inputSchema: { name: z.string().min(1) },
348
595
  annotations: READ
349
596
  }, ({ name }) => attempt(async () => jsonResult(await index.resolve(name))));
@@ -407,8 +654,8 @@ function registerExerciseTools(server, ctx) {
407
654
  }
408
655
  //#endregion
409
656
  //#region src/tools/workout.ts
410
- /** Workout building, read-back, publishing, and removal. */
411
- function registerWorkoutTools(server, ctx) {
657
+ /** Build a draft, read it back, and publish it. */
658
+ function registerBuild(server, ctx) {
412
659
  server.registerTool("workout_build", {
413
660
  title: "Build a workout session (draft)",
414
661
  description: "Build an UNPUBLISHED session from a spec (program -> session -> blocks -> exercises). Two exercises in one block become a superset. Add a block 'leaderboard' for a Red-Zone score, or a top-level 'instruction' for the session note (Coach Instructions). Returns the draft ids, a read-back, and unit advisories. Review, then workout_publish.",
@@ -481,6 +728,9 @@ function registerWorkoutTools(server, ctx) {
481
728
  readback: await readSession(ctx.client, programId, parseWorkoutDate(date), pwId)
482
729
  });
483
730
  }));
731
+ }
732
+ /** Calendar lifecycle for an existing session: remove, unpublish, copy, save to library. */
733
+ function registerLifecycle(server, ctx) {
484
734
  server.registerTool("session_remove", {
485
735
  title: "Remove a session",
486
736
  description: "Delete a session from the live calendar (also the way to replace a date: remove then build). Hard to undo. Requires confirmation (elicitation, or confirm:true).",
@@ -499,6 +749,66 @@ function registerWorkoutTools(server, ctx) {
499
749
  await removeSession(ctx.client, programId, pwId);
500
750
  return jsonResult({ removed: pwId });
501
751
  }));
752
+ server.registerTool("session_unpublish", {
753
+ title: "Unpublish a session",
754
+ description: "Unpublish a previously published session (POST .../programWorkout/unPublish/{pwId}). It is no longer athlete-facing. Requires confirmation (elicitation, or confirm:true).",
755
+ inputSchema: {
756
+ pwId: z.number(),
757
+ confirm: z.boolean().optional()
758
+ },
759
+ annotations: {
760
+ readOnlyHint: false,
761
+ destructiveHint: true,
762
+ openWorldHint: true
763
+ }
764
+ }, ({ pwId, confirm }, extra) => attempt(async () => {
765
+ if (!await confirmGate(server, extra.requestId, `Unpublish session ${pwId}? Athletes will no longer see it.`, confirm)) return errorResult(NOT_CONFIRMED);
766
+ return apiCall(ctx, "POST", `/2.0/coach/calendar/programWorkout/unPublish/${pwId}`);
767
+ }));
768
+ server.registerTool("session_copy", {
769
+ title: "Copy a session to a date",
770
+ description: "Copy/repeat a session to a target date on a program (POST .../copyProgramWorkout). toDate is YYYY-M-D. Creates a new session; review and publish it separately.",
771
+ inputSchema: {
772
+ toProgramId: z.number(),
773
+ pwId: z.number(),
774
+ toDate: z.string()
775
+ },
776
+ annotations: {
777
+ readOnlyHint: false,
778
+ destructiveHint: false,
779
+ openWorldHint: true
780
+ }
781
+ }, ({ toProgramId, pwId, toDate }) => attempt(async () => {
782
+ const [year, month, day] = parseWorkoutDate(toDate);
783
+ const dayOfWeek = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
784
+ return apiCall(ctx, "POST", "/2.0/coach/calendar/copyProgramWorkout", { body: {
785
+ toProgramId,
786
+ pwId,
787
+ toDate: {
788
+ date: `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
789
+ day,
790
+ month,
791
+ year,
792
+ dayOfWeek,
793
+ isToday: false
794
+ }
795
+ } });
796
+ }));
797
+ server.registerTool("session_save_as_template", {
798
+ title: "Save a session to the library",
799
+ description: "Save an existing session as a reusable template in the session library (POST .../programWorkout/saveWorkoutAsTemplate/{workoutId}). Pass the workout_id.",
800
+ inputSchema: { workoutId: z.number() },
801
+ annotations: {
802
+ readOnlyHint: false,
803
+ destructiveHint: false,
804
+ openWorldHint: true
805
+ }
806
+ }, ({ workoutId }) => apiCall(ctx, "POST", `/2.0/coach/calendar/programWorkout/saveWorkoutAsTemplate/${workoutId}`));
807
+ }
808
+ /** Workout building, read-back, publishing, and the session calendar lifecycle. */
809
+ function registerWorkoutTools(server, ctx) {
810
+ registerBuild(server, ctx);
811
+ registerLifecycle(server, ctx);
502
812
  }
503
813
  //#endregion
504
814
  //#region src/tools/messaging.ts
@@ -581,4 +891,4 @@ function registerMessagingTools(server, ctx) {
581
891
  registerWrites(server, ctx);
582
892
  }
583
893
  //#endregion
584
- export { DEFAULT_RESULT_BUDGET, DESTRUCTIVE, NOT_CONFIRMED, READ, SYNC, apiCall, attempt, boundedSerialize, confirmGate, errorResult, idParam, jsonResult, registerExerciseTools, registerMessagingTools, registerRawTools, registerReadTools, registerWorkoutTools, resultBudget, toId };
894
+ export { DEFAULT_RESULT_BUDGET, DESTRUCTIVE, NOT_CONFIRMED, READ, SYNC, apiCall, attempt, boundedSerialize, confirmGate, errorResult, idParam, jsonResult, registerAnalyticsTools, registerAthleteTools, registerExerciseTools, registerMessagingTools, registerReadTools, registerTeamTools, registerWorkoutTools, resultBudget, toId };
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "https://github.com/alandotcom/trainheroic-skill.git",
7
+ "url": "https://github.com/alandotcom/trainheroic-unofficial.git",
8
8
  "directory": "packages/core"
9
9
  },
10
10
  "description": "Shared MCP tool layer for TrainHeroic, used by the local and Cloudflare servers.",
@@ -25,8 +25,8 @@
25
25
  "@cfworker/json-schema": "^4.1.1",
26
26
  "@modelcontextprotocol/sdk": "^1.29.0",
27
27
  "zod": "^4.4.3",
28
- "@trainheroic-unofficial/dto": "0.1.0",
29
- "@trainheroic-unofficial/js": "0.1.0"
28
+ "@trainheroic-unofficial/dto": "0.3.0",
29
+ "@trainheroic-unofficial/js": "0.3.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^26.0.0",