@trainheroic-unofficial/athlete-mcp 1.7.4 → 2.0.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 +83 -45
  2. package/package.json +8 -8
package/dist/server.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1
+ import { McpServer, acceptedContent, inputRequired, inputResponse } from "@modelcontextprotocol/server";
2
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
3
3
  import process from "node:process";
4
4
  import { z } from "zod";
5
5
  //#region ../dto/src/common.ts
@@ -338,7 +338,11 @@ const DESTRUCTIVE = {
338
338
  destructiveHint: true,
339
339
  openWorldHint: true
340
340
  };
341
- /** Run a tool body, converting thrown errors into an in-band tool error. */
341
+ /**
342
+ * Run a tool body, converting thrown errors into an in-band tool error. Generic so a caller that
343
+ * only ever produces a `CallToolResult` keeps that narrower type instead of widening to the
344
+ * MRTR union — that is what lets `apiCall` delegate here rather than repeat this catch.
345
+ */
342
346
  async function attempt(fn) {
343
347
  try {
344
348
  return await fn();
@@ -473,32 +477,50 @@ function errorResult(message) {
473
477
  }
474
478
  //#endregion
475
479
  //#region ../core/src/confirm.ts
476
- const NOT_CONFIRMED = "Not confirmed. Re-run with confirm:true, or connect a client that supports MCP elicitation.";
477
480
  /**
478
- * Confirm a destructive/athlete-facing action. Prefers MCP elicitation (an
479
- * in-the-moment user prompt); falls back to an explicit confirm:true argument when
480
- * the client does not support elicitation. Never proceeds without one of the two.
481
+ * Returned when the user was asked and said no. It must not tell the model how to retry: the
482
+ * refusal is the answer. (The `confirm: true` fallback for clients that cannot elicit is
483
+ * advertised in each gated tool's description, which is where a model looks before calling.)
481
484
  */
482
- async function confirmGate(server, requestId, message, confirmArg) {
483
- if (confirmArg === true) return true;
484
- try {
485
- const result = await server.server.elicitInput({
486
- message,
487
- requestedSchema: {
488
- type: "object",
489
- properties: { confirm: {
490
- type: "boolean",
491
- title: "Confirm",
492
- description: message
493
- } },
494
- required: ["confirm"]
495
- }
496
- }, requestId === void 0 ? void 0 : { relatedRequestId: requestId });
497
- return result.action === "accept" && result.content?.confirm === true;
498
- } catch (err) {
499
- console.warn("MCP elicitation unavailable; treating as not confirmed", err);
500
- return false;
501
- }
485
+ const NOT_CONFIRMED = "Not confirmed the user declined. Nothing was changed.";
486
+ /** Appended to the prompt so the fallback survives whichever way a client surfaces the request. */
487
+ const FALLBACK_HINT = " (If you cannot show this prompt, re-run the tool with confirm:true once the user has agreed.)";
488
+ /**
489
+ * Confirm a destructive/athlete-facing action.
490
+ *
491
+ * Prefers MCP multi-round-trip elicitation (`input_required`); falls back to an explicit
492
+ * `confirm:true` argument when the client already confirmed. Never proceeds without one of the two.
493
+ *
494
+ * Returns `undefined` when the action may proceed, otherwise the result to return to the client
495
+ * (`input_required` or an in-band denial).
496
+ *
497
+ * **Call this before any read or write.** MRTR is re-entrant: the client answers by re-sending
498
+ * the whole `tools/call`, and the stateless handler builds a fresh server and runs the handler
499
+ * from the top. Anything above the gate therefore executes twice on a confirmed action. If a
500
+ * confirmation message ever needs data fetched first, that fetch must be idempotent.
501
+ *
502
+ * Written once in the 2026 `inputRequired` style: modern clients retry with `inputResponses`.
503
+ * A client that cannot elicit at all never reaches the denial below — the SDK rejects the
504
+ * request before the retry — so it must pass `confirm: true`, which is why the hint rides inside
505
+ * the prompt text itself.
506
+ */
507
+ function confirmGate(ctx, message, confirmArg) {
508
+ if (confirmArg === true) return void 0;
509
+ if (acceptedContent(ctx.mcpReq.inputResponses, "confirm")?.confirm === true) return void 0;
510
+ if (inputResponse(ctx.mcpReq.inputResponses, "confirm").kind !== "missing") return errorResult(NOT_CONFIRMED);
511
+ const prompt = message + FALLBACK_HINT;
512
+ return inputRequired({ inputRequests: { confirm: inputRequired.elicit({
513
+ message: prompt,
514
+ requestedSchema: {
515
+ type: "object",
516
+ properties: { confirm: {
517
+ type: "boolean",
518
+ title: "Confirm",
519
+ description: prompt
520
+ } },
521
+ required: ["confirm"]
522
+ }
523
+ }) } });
502
524
  }
503
525
  //#endregion
504
526
  //#region ../core/src/instructions.ts
@@ -574,20 +596,27 @@ var TrainHeroicAuthError = class extends Error {
574
596
  name = "TrainHeroicAuthError";
575
597
  };
576
598
  /**
577
- * Authenticated TrainHeroic API client. Holds the coach credentials (from the grant's
578
- * encrypted props) and a lazily-acquired session token cached in memory for the life
579
- * of the Durable Object instance. On a 401/403 it re-logs in once and retries, since
599
+ * Authenticated TrainHeroic API client. Holds the credentials (on the hosted Worker, from the
600
+ * grant's encrypted props) and a lazily-acquired session token cached in memory for the life of
601
+ * *this client instance* nothing longer. On a 401/403 it re-logs in once and retries, since
580
602
  * TrainHeroic has no refresh token and sessions expire after ~1-2h.
603
+ *
604
+ * Anything that wants a session to outlive one client owns that itself: pass a previously
605
+ * acquired token as `sessionId` and keep the new one via `options.onSession`. That matters
606
+ * wherever clients are short-lived — the hosted Worker builds one per HTTP request, and each CLI
607
+ * invocation is a fresh process — because otherwise every operation replays the password.
581
608
  */
582
609
  var TrainHeroicClient = class {
583
610
  #email;
584
611
  #password;
612
+ #onSession;
585
613
  #sessionId;
586
614
  #loginInFlight = null;
587
- constructor(email, password, sessionId = null) {
615
+ constructor(email, password, sessionId = null, options = {}) {
588
616
  this.#email = email;
589
617
  this.#password = password;
590
618
  this.#sessionId = sessionId;
619
+ this.#onSession = options.onSession;
591
620
  }
592
621
  get sessionId() {
593
622
  return this.#sessionId;
@@ -605,6 +634,9 @@ var TrainHeroicClient = class {
605
634
  const session = await loginTrainHeroic(this.#email, this.#password);
606
635
  if (!session) throw new TrainHeroicAuthError("TrainHeroic login failed");
607
636
  this.#sessionId = session.sessionId;
637
+ try {
638
+ this.#onSession?.(this.#sessionId);
639
+ } catch {}
608
640
  return this.#sessionId;
609
641
  }
610
642
  async request(method, path, options = {}) {
@@ -1942,7 +1974,8 @@ function registerSessionTools(server, ctx) {
1942
1974
  annotations: DESTRUCTIVE
1943
1975
  }, ({ programWorkoutId, date, confirm }, extra) => attempt(async () => {
1944
1976
  const id = toId(programWorkoutId);
1945
- if (!await confirmGate(server, extra.requestId, `Permanently remove personal session ${id} on ${date}? This deletes the session and anything logged in it.`, confirm)) return errorResult(NOT_CONFIRMED);
1977
+ const blocked = confirmGate(extra, `Permanently remove personal session ${id} on ${date}? This deletes the session and anything logged in it.`, confirm);
1978
+ if (blocked) return blocked;
1946
1979
  await removePersonalWorkout(ctx.client, {
1947
1980
  programWorkoutId: id,
1948
1981
  date
@@ -1982,7 +2015,8 @@ function registerLogTool(server, ctx) {
1982
2015
  },
1983
2016
  annotations: DESTRUCTIVE
1984
2017
  }, ({ date, exercises, confirm }, extra) => attempt(async () => {
1985
- if (!await confirmGate(server, extra.requestId, `Log a session of ${exercises.length} exercise(s) on ${date}? This writes to your coach-visible training log.`, confirm)) return errorResult(NOT_CONFIRMED);
2018
+ const blocked = confirmGate(extra, `Log a session of ${exercises.length} exercise(s) on ${date}? This writes to your coach-visible training log.`, confirm);
2019
+ if (blocked) return blocked;
1986
2020
  const res = await logAdHocSession(ctx.client, {
1987
2021
  date,
1988
2022
  exercises: mapSessionExercises(exercises)
@@ -1999,7 +2033,8 @@ function registerLogTool(server, ctx) {
1999
2033
  },
2000
2034
  annotations: DESTRUCTIVE
2001
2035
  }, ({ date, savedWorkoutSetId, results, confirm }, extra) => attempt(async () => {
2002
- 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);
2036
+ const blocked = confirmGate(extra, `Log results to saved workout set ${toId(savedWorkoutSetId)} on ${date}? This writes to your coach-visible training log.`, confirm);
2037
+ if (blocked) return blocked;
2003
2038
  return jsonResult(await logAthleteSet(ctx.client, {
2004
2039
  date,
2005
2040
  savedWorkoutSetId: toId(savedWorkoutSetId),
@@ -2025,7 +2060,8 @@ function registerSwapTool(server, ctx) {
2025
2060
  }, ({ savedWorkoutSetExerciseId, exerciseId, confirm }, extra) => attempt(async () => {
2026
2061
  const sweId = toId(savedWorkoutSetExerciseId);
2027
2062
  const exId = toId(exerciseId);
2028
- if (!await confirmGate(server, extra.requestId, `Swap the exercise in your saved workout slot ${sweId} to exercise ${exId}? This changes what that slot is prescribed (your copy only).`, confirm)) return errorResult(NOT_CONFIRMED);
2063
+ const blocked = confirmGate(extra, `Swap the exercise in your saved workout slot ${sweId} to exercise ${exId}? This changes what that slot is prescribed (your copy only).`, confirm);
2064
+ if (blocked) return blocked;
2029
2065
  return jsonResult(await swapAthleteExercise(ctx.client, {
2030
2066
  savedWorkoutSetExerciseId: sweId,
2031
2067
  exerciseId: exId
@@ -2061,10 +2097,10 @@ function registerAthleteTrainingTools(server, ctx) {
2061
2097
  }
2062
2098
  //#endregion
2063
2099
  //#region package.json
2064
- var version = "1.7.4";
2100
+ var version = "2.0.1";
2065
2101
  //#endregion
2066
2102
  //#region src/server.ts
2067
- async function main() {
2103
+ function main() {
2068
2104
  const email = process.env.TRAINHEROIC_EMAIL;
2069
2105
  const password = process.env.TRAINHEROIC_PASSWORD;
2070
2106
  if (!email || !password) {
@@ -2072,13 +2108,15 @@ async function main() {
2072
2108
  process.exit(1);
2073
2109
  }
2074
2110
  const client = new TrainHeroicClient(email, password);
2075
- const server = new McpServer({
2076
- name: "trainheroic-athlete",
2077
- version
2078
- }, { instructions: SERVER_INSTRUCTIONS });
2079
- registerAthleteTrainingTools(server, { client });
2080
- await server.connect(new StdioServerTransport());
2081
- }
2082
- await main();
2111
+ serveStdio(() => {
2112
+ const server = new McpServer({
2113
+ name: "trainheroic-athlete",
2114
+ version
2115
+ }, { instructions: SERVER_INSTRUCTIONS });
2116
+ registerAthleteTrainingTools(server, { client });
2117
+ return server;
2118
+ });
2119
+ }
2120
+ main();
2083
2121
  //#endregion
2084
2122
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainheroic-unofficial/athlete-mcp",
3
- "version": "1.7.4",
3
+ "version": "2.0.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,16 +19,16 @@
19
19
  "dist"
20
20
  ],
21
21
  "dependencies": {
22
- "@modelcontextprotocol/sdk": "^1.29.0",
22
+ "@modelcontextprotocol/server": "2.0.0",
23
23
  "zod": "^4.4.3",
24
- "@trainheroic-unofficial/core": "1.7.4",
25
- "@trainheroic-unofficial/js": "1.7.4"
24
+ "@trainheroic-unofficial/core": "2.0.1",
25
+ "@trainheroic-unofficial/js": "2.0.1"
26
26
  },
27
27
  "devDependencies": {
28
- "@types/node": "^26.1.0",
29
- "tsdown": "^0.22.3",
30
- "tsx": "^4.23.0",
31
- "typescript": "^6.0.3",
28
+ "@types/node": "^26.1.2",
29
+ "tsdown": "^0.22.14",
30
+ "tsx": "^4.23.4",
31
+ "typescript": "^7.0.2",
32
32
  "vitest": "^4.1.10"
33
33
  },
34
34
  "scripts": {